New room settings, add customizable power levels and dev tools (#2222)
* WIP - add room settings dialog * join rule setting - WIP * show emojis & stickers in room settings - WIP * restyle join rule switcher * Merge branch 'dev' into new-room-settings * add join rule hook * open room settings from global state * open new room settings from all places * rearrange settings menu item * add option for creating new image pack * room devtools - WIP * render room state events as list * add option to open state event * add option to edit state event * refactor text area code editor into hook * add option to send message and state event * add cutout card component * add hook for room account data * display room account data - WIP * refactor global account data editor component * add account data editor in room * fix font style in devtool * show state events in compact form * add option to delete room image pack * add server badge component * add member tile component * render members in room settings * add search in room settings member * add option to reset member search * add filter in room members * fix member virtual item key * remove color from serve badge in room members * show room in settings * fix loading indicator position * power level tags in room setting - WIP * generate fallback tag in backward compatible way * add color picker * add powers editor - WIP * add props to stop adding emoji to recent usage * add beta feature notice badge * add types for power level tag icon * refactor image pack rooms code to hook * option for adding new power levels tags * remove console log * refactor power icon * add option to edit power level tags * remove power level from powers pill * fix power level labels * add option to delete power levels * fix long power level name shrinks power integer * room permissions - WIP * add power level selector component * add room permissions * move user default permission setting to other group * add power permission peek menu * fix weigh of power switch text * hide above for max power in permission switcher * improve beta badge description * render room profile in room settings * add option to edit room profile * make room topic input text area * add option to enable room encryption in room settings * add option to change message history visibility * add option to change join rule * add option for addresses in room settings * close encryption dialog after enabling
This commit is contained in:
parent
00f3df8719
commit
286983c833
73 changed files with 6196 additions and 420 deletions
11
package-lock.json
generated
11
package-lock.json
generated
|
|
@ -54,6 +54,7 @@
|
||||||
"react-aria": "3.29.1",
|
"react-aria": "3.29.1",
|
||||||
"react-autosize-textarea": "7.1.0",
|
"react-autosize-textarea": "7.1.0",
|
||||||
"react-blurhash": "0.2.0",
|
"react-blurhash": "0.2.0",
|
||||||
|
"react-colorful": "5.6.1",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"react-error-boundary": "4.0.13",
|
"react-error-boundary": "4.0.13",
|
||||||
"react-google-recaptcha": "2.1.0",
|
"react-google-recaptcha": "2.1.0",
|
||||||
|
|
@ -9654,6 +9655,16 @@
|
||||||
"react": ">=15"
|
"react": ">=15"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-colorful": {
|
||||||
|
"version": "5.6.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-colorful/-/react-colorful-5.6.1.tgz",
|
||||||
|
"integrity": "sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0",
|
||||||
|
"react-dom": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-dom": {
|
"node_modules/react-dom": {
|
||||||
"version": "18.2.0",
|
"version": "18.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,7 @@
|
||||||
"react-aria": "3.29.1",
|
"react-aria": "3.29.1",
|
||||||
"react-autosize-textarea": "7.1.0",
|
"react-autosize-textarea": "7.1.0",
|
||||||
"react-blurhash": "0.2.0",
|
"react-blurhash": "0.2.0",
|
||||||
|
"react-colorful": "5.6.1",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"react-error-boundary": "4.0.13",
|
"react-error-boundary": "4.0.13",
|
||||||
"react-google-recaptcha": "2.1.0",
|
"react-google-recaptcha": "2.1.0",
|
||||||
|
|
|
||||||
322
src/app/components/AccountDataEditor.tsx
Normal file
322
src/app/components/AccountDataEditor.tsx
Normal file
|
|
@ -0,0 +1,322 @@
|
||||||
|
import React, { FormEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Text,
|
||||||
|
Icon,
|
||||||
|
Icons,
|
||||||
|
IconButton,
|
||||||
|
Input,
|
||||||
|
Button,
|
||||||
|
TextArea as TextAreaComponent,
|
||||||
|
color,
|
||||||
|
Spinner,
|
||||||
|
Chip,
|
||||||
|
Scroll,
|
||||||
|
config,
|
||||||
|
} from 'folds';
|
||||||
|
import { MatrixError } from 'matrix-js-sdk';
|
||||||
|
import { Cursor } from '../plugins/text-area';
|
||||||
|
import { syntaxErrorPosition } from '../utils/dom';
|
||||||
|
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
|
||||||
|
import { Page, PageHeader } from './page';
|
||||||
|
import { useAlive } from '../hooks/useAlive';
|
||||||
|
import { SequenceCard } from './sequence-card';
|
||||||
|
import { TextViewerContent } from './text-viewer';
|
||||||
|
import { useTextAreaCodeEditor } from '../hooks/useTextAreaCodeEditor';
|
||||||
|
|
||||||
|
const EDITOR_INTENT_SPACE_COUNT = 2;
|
||||||
|
|
||||||
|
export type AccountDataSubmitCallback = (type: string, content: object) => Promise<void>;
|
||||||
|
|
||||||
|
type AccountDataInfo = {
|
||||||
|
type: string;
|
||||||
|
content: object;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AccountDataEditProps = {
|
||||||
|
type: string;
|
||||||
|
defaultContent: string;
|
||||||
|
submitChange: AccountDataSubmitCallback;
|
||||||
|
onCancel: () => void;
|
||||||
|
onSave: (info: AccountDataInfo) => void;
|
||||||
|
};
|
||||||
|
function AccountDataEdit({
|
||||||
|
type,
|
||||||
|
defaultContent,
|
||||||
|
submitChange,
|
||||||
|
onCancel,
|
||||||
|
onSave,
|
||||||
|
}: AccountDataEditProps) {
|
||||||
|
const alive = useAlive();
|
||||||
|
|
||||||
|
const textAreaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const [jsonError, setJSONError] = useState<SyntaxError>();
|
||||||
|
|
||||||
|
const { handleKeyDown, operations, getTarget } = useTextAreaCodeEditor(
|
||||||
|
textAreaRef,
|
||||||
|
EDITOR_INTENT_SPACE_COUNT
|
||||||
|
);
|
||||||
|
|
||||||
|
const [submitState, submit] = useAsyncCallback<void, MatrixError, [string, object]>(submitChange);
|
||||||
|
const submitting = submitState.status === AsyncStatus.Loading;
|
||||||
|
|
||||||
|
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
if (submitting) return;
|
||||||
|
|
||||||
|
const target = evt.target as HTMLFormElement | undefined;
|
||||||
|
const typeInput = target?.typeInput as HTMLInputElement | undefined;
|
||||||
|
const contentTextArea = target?.contentTextArea as HTMLTextAreaElement | undefined;
|
||||||
|
if (!typeInput || !contentTextArea) return;
|
||||||
|
|
||||||
|
const typeStr = typeInput.value.trim();
|
||||||
|
const contentStr = contentTextArea.value.trim();
|
||||||
|
|
||||||
|
let parsedContent: object;
|
||||||
|
try {
|
||||||
|
parsedContent = JSON.parse(contentStr);
|
||||||
|
} catch (e) {
|
||||||
|
setJSONError(e as SyntaxError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setJSONError(undefined);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!typeStr ||
|
||||||
|
parsedContent === null ||
|
||||||
|
defaultContent === JSON.stringify(parsedContent, null, EDITOR_INTENT_SPACE_COUNT)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
submit(typeStr, parsedContent).then(() => {
|
||||||
|
if (alive()) {
|
||||||
|
onSave({
|
||||||
|
type: typeStr,
|
||||||
|
content: parsedContent,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (jsonError) {
|
||||||
|
const errorPosition = syntaxErrorPosition(jsonError) ?? 0;
|
||||||
|
const cursor = new Cursor(errorPosition, errorPosition, 'none');
|
||||||
|
operations.select(cursor);
|
||||||
|
getTarget()?.focus();
|
||||||
|
}
|
||||||
|
}, [jsonError, operations, getTarget]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
as="form"
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
grow="Yes"
|
||||||
|
style={{
|
||||||
|
padding: config.space.S400,
|
||||||
|
}}
|
||||||
|
direction="Column"
|
||||||
|
gap="400"
|
||||||
|
aria-disabled={submitting}
|
||||||
|
>
|
||||||
|
<Box shrink="No" direction="Column" gap="100">
|
||||||
|
<Text size="L400">Account Data</Text>
|
||||||
|
<Box gap="300">
|
||||||
|
<Box grow="Yes" direction="Column">
|
||||||
|
<Input
|
||||||
|
variant={type.length > 0 || submitting ? 'SurfaceVariant' : 'Background'}
|
||||||
|
name="typeInput"
|
||||||
|
size="400"
|
||||||
|
radii="300"
|
||||||
|
readOnly={type.length > 0 || submitting}
|
||||||
|
defaultValue={type}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Button
|
||||||
|
variant="Success"
|
||||||
|
size="400"
|
||||||
|
radii="300"
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting}
|
||||||
|
before={submitting && <Spinner variant="Primary" fill="Solid" size="300" />}
|
||||||
|
>
|
||||||
|
<Text size="B400">Save</Text>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="Secondary"
|
||||||
|
fill="Soft"
|
||||||
|
size="400"
|
||||||
|
radii="300"
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
<Text size="B400">Cancel</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{submitState.status === AsyncStatus.Error && (
|
||||||
|
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||||
|
<b>{submitState.error.message}</b>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Box grow="Yes" direction="Column" gap="100">
|
||||||
|
<Box shrink="No">
|
||||||
|
<Text size="L400">JSON Content</Text>
|
||||||
|
</Box>
|
||||||
|
<TextAreaComponent
|
||||||
|
ref={textAreaRef}
|
||||||
|
name="contentTextArea"
|
||||||
|
style={{
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
}}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
defaultValue={defaultContent}
|
||||||
|
resize="None"
|
||||||
|
spellCheck="false"
|
||||||
|
required
|
||||||
|
readOnly={submitting}
|
||||||
|
/>
|
||||||
|
{jsonError && (
|
||||||
|
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||||
|
<b>
|
||||||
|
{jsonError.name}: {jsonError.message}
|
||||||
|
</b>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type AccountDataViewProps = {
|
||||||
|
type: string;
|
||||||
|
defaultContent: string;
|
||||||
|
onEdit: () => void;
|
||||||
|
};
|
||||||
|
function AccountDataView({ type, defaultContent, onEdit }: AccountDataViewProps) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
direction="Column"
|
||||||
|
style={{
|
||||||
|
padding: config.space.S400,
|
||||||
|
}}
|
||||||
|
gap="400"
|
||||||
|
>
|
||||||
|
<Box shrink="No" gap="300" alignItems="End">
|
||||||
|
<Box grow="Yes" direction="Column" gap="100">
|
||||||
|
<Text size="L400">Account Data</Text>
|
||||||
|
<Input
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
size="400"
|
||||||
|
radii="300"
|
||||||
|
readOnly
|
||||||
|
defaultValue={type}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Button variant="Secondary" size="400" radii="300" onClick={onEdit}>
|
||||||
|
<Text size="B400">Edit</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
<Box grow="Yes" direction="Column" gap="100">
|
||||||
|
<Text size="L400">JSON Content</Text>
|
||||||
|
<SequenceCard variant="SurfaceVariant">
|
||||||
|
<Scroll visibility="Always" size="300" hideTrack>
|
||||||
|
<TextViewerContent
|
||||||
|
size="T300"
|
||||||
|
style={{
|
||||||
|
padding: `${config.space.S300} ${config.space.S100} ${config.space.S300} ${config.space.S300}`,
|
||||||
|
}}
|
||||||
|
text={defaultContent}
|
||||||
|
langName="JSON"
|
||||||
|
/>
|
||||||
|
</Scroll>
|
||||||
|
</SequenceCard>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AccountDataEditorProps = {
|
||||||
|
type?: string;
|
||||||
|
content?: object;
|
||||||
|
submitChange: AccountDataSubmitCallback;
|
||||||
|
requestClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AccountDataEditor({
|
||||||
|
type,
|
||||||
|
content,
|
||||||
|
submitChange,
|
||||||
|
requestClose,
|
||||||
|
}: AccountDataEditorProps) {
|
||||||
|
const [data, setData] = useState<AccountDataInfo>({
|
||||||
|
type: type ?? '',
|
||||||
|
content: content ?? {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [edit, setEdit] = useState(!type);
|
||||||
|
|
||||||
|
const closeEdit = useCallback(() => {
|
||||||
|
if (!type) {
|
||||||
|
requestClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setEdit(false);
|
||||||
|
}, [type, requestClose]);
|
||||||
|
|
||||||
|
const handleSave = useCallback((info: AccountDataInfo) => {
|
||||||
|
setData(info);
|
||||||
|
setEdit(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const contentJSONStr = useMemo(
|
||||||
|
() => JSON.stringify(data.content, null, EDITOR_INTENT_SPACE_COUNT),
|
||||||
|
[data.content]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page>
|
||||||
|
<PageHeader outlined={false} balance>
|
||||||
|
<Box alignItems="Center" grow="Yes" gap="200">
|
||||||
|
<Box alignItems="Inherit" grow="Yes" gap="200">
|
||||||
|
<Chip
|
||||||
|
size="500"
|
||||||
|
radii="Pill"
|
||||||
|
onClick={requestClose}
|
||||||
|
before={<Icon size="100" src={Icons.ArrowLeft} />}
|
||||||
|
>
|
||||||
|
<Text size="T300">Developer Tools</Text>
|
||||||
|
</Chip>
|
||||||
|
</Box>
|
||||||
|
<Box shrink="No">
|
||||||
|
<IconButton onClick={requestClose} variant="Surface">
|
||||||
|
<Icon src={Icons.Cross} />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</PageHeader>
|
||||||
|
<Box grow="Yes" direction="Column">
|
||||||
|
{edit ? (
|
||||||
|
<AccountDataEdit
|
||||||
|
type={data.type}
|
||||||
|
defaultContent={contentJSONStr}
|
||||||
|
submitChange={submitChange}
|
||||||
|
onCancel={closeEdit}
|
||||||
|
onSave={handleSave}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<AccountDataView
|
||||||
|
type={data.type}
|
||||||
|
defaultContent={contentJSONStr}
|
||||||
|
onEdit={() => setEdit(true)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
src/app/components/BetaNoticeBadge.tsx
Normal file
25
src/app/components/BetaNoticeBadge.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { TooltipProvider, Tooltip, Box, Text, Badge, toRem } from 'folds';
|
||||||
|
|
||||||
|
export function BetaNoticeBadge() {
|
||||||
|
return (
|
||||||
|
<TooltipProvider
|
||||||
|
position="Right"
|
||||||
|
align="Center"
|
||||||
|
tooltip={
|
||||||
|
<Tooltip style={{ maxWidth: toRem(200) }}>
|
||||||
|
<Box direction="Column">
|
||||||
|
<Text size="L400">Notice</Text>
|
||||||
|
<Text size="T200">This feature is under testing and may change over time.</Text>
|
||||||
|
</Box>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(triggerRef) => (
|
||||||
|
<Badge size="500" tabIndex={0} ref={triggerRef} variant="Primary" fill="Solid">
|
||||||
|
<Text size="L400">Beta</Text>
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
src/app/components/HexColorPickerPopOut.tsx
Normal file
59
src/app/components/HexColorPickerPopOut.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import { Box, Button, config, Menu, PopOut, RectCords, Text } from 'folds';
|
||||||
|
import React, { MouseEventHandler, ReactNode, useState } from 'react';
|
||||||
|
import { stopPropagation } from '../utils/keyboard';
|
||||||
|
|
||||||
|
type HexColorPickerPopOutProps = {
|
||||||
|
children: (onOpen: MouseEventHandler<HTMLElement>, opened: boolean) => ReactNode;
|
||||||
|
picker: ReactNode;
|
||||||
|
onRemove?: () => void;
|
||||||
|
};
|
||||||
|
export function HexColorPickerPopOut({ picker, onRemove, children }: HexColorPickerPopOutProps) {
|
||||||
|
const [cords, setCords] = useState<RectCords>();
|
||||||
|
|
||||||
|
const handleOpen: MouseEventHandler<HTMLElement> = (evt) => {
|
||||||
|
setCords(evt.currentTarget.getBoundingClientRect());
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PopOut
|
||||||
|
anchor={cords}
|
||||||
|
position="Bottom"
|
||||||
|
align="Center"
|
||||||
|
content={
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
onDeactivate: () => setCords(undefined),
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Menu
|
||||||
|
style={{
|
||||||
|
padding: config.space.S100,
|
||||||
|
borderRadius: config.radii.R500,
|
||||||
|
overflow: 'initial',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box direction="Column" gap="200">
|
||||||
|
{picker}
|
||||||
|
{onRemove && (
|
||||||
|
<Button
|
||||||
|
size="300"
|
||||||
|
variant="Secondary"
|
||||||
|
fill="Soft"
|
||||||
|
radii="400"
|
||||||
|
onClick={() => onRemove()}
|
||||||
|
>
|
||||||
|
<Text size="B300">Remove</Text>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Menu>
|
||||||
|
</FocusTrap>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{children(handleOpen, !!cords)}
|
||||||
|
</PopOut>
|
||||||
|
);
|
||||||
|
}
|
||||||
138
src/app/components/JoinRulesSwitcher.tsx
Normal file
138
src/app/components/JoinRulesSwitcher.tsx
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
import React, { MouseEventHandler, useCallback, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
config,
|
||||||
|
Box,
|
||||||
|
MenuItem,
|
||||||
|
Text,
|
||||||
|
Icon,
|
||||||
|
Icons,
|
||||||
|
IconSrc,
|
||||||
|
RectCords,
|
||||||
|
PopOut,
|
||||||
|
Menu,
|
||||||
|
Button,
|
||||||
|
Spinner,
|
||||||
|
} from 'folds';
|
||||||
|
import { JoinRule } from 'matrix-js-sdk';
|
||||||
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import { stopPropagation } from '../utils/keyboard';
|
||||||
|
|
||||||
|
type JoinRuleIcons = Record<JoinRule, IconSrc>;
|
||||||
|
export const useRoomJoinRuleIcon = (): JoinRuleIcons =>
|
||||||
|
useMemo(
|
||||||
|
() => ({
|
||||||
|
[JoinRule.Invite]: Icons.HashLock,
|
||||||
|
[JoinRule.Knock]: Icons.HashLock,
|
||||||
|
[JoinRule.Restricted]: Icons.Hash,
|
||||||
|
[JoinRule.Public]: Icons.HashGlobe,
|
||||||
|
[JoinRule.Private]: Icons.HashLock,
|
||||||
|
}),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
type JoinRuleLabels = Record<JoinRule, string>;
|
||||||
|
export const useRoomJoinRuleLabel = (): JoinRuleLabels =>
|
||||||
|
useMemo(
|
||||||
|
() => ({
|
||||||
|
[JoinRule.Invite]: 'Invite Only',
|
||||||
|
[JoinRule.Knock]: 'Knock & Invite',
|
||||||
|
[JoinRule.Restricted]: 'Space Members',
|
||||||
|
[JoinRule.Public]: 'Public',
|
||||||
|
[JoinRule.Private]: 'Invite Only',
|
||||||
|
}),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
type JoinRulesSwitcherProps<T extends JoinRule[]> = {
|
||||||
|
icons: JoinRuleIcons;
|
||||||
|
labels: JoinRuleLabels;
|
||||||
|
rules: T;
|
||||||
|
value: T[number];
|
||||||
|
onChange: (value: T[number]) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
changing?: boolean;
|
||||||
|
};
|
||||||
|
export function JoinRulesSwitcher<T extends JoinRule[]>({
|
||||||
|
icons,
|
||||||
|
labels,
|
||||||
|
rules,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
disabled,
|
||||||
|
changing,
|
||||||
|
}: JoinRulesSwitcherProps<T>) {
|
||||||
|
const [cords, setCords] = useState<RectCords>();
|
||||||
|
|
||||||
|
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||||
|
setCords(evt.currentTarget.getBoundingClientRect());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = useCallback(
|
||||||
|
(selectedRule: JoinRule) => {
|
||||||
|
setCords(undefined);
|
||||||
|
onChange(selectedRule);
|
||||||
|
},
|
||||||
|
[onChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PopOut
|
||||||
|
anchor={cords}
|
||||||
|
position="Bottom"
|
||||||
|
align="End"
|
||||||
|
content={
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: () => setCords(undefined),
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
|
||||||
|
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Menu>
|
||||||
|
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||||
|
{rules.map((rule) => (
|
||||||
|
<MenuItem
|
||||||
|
key={rule}
|
||||||
|
size="300"
|
||||||
|
variant="Surface"
|
||||||
|
radii="300"
|
||||||
|
aria-pressed={value === rule}
|
||||||
|
onClick={() => handleChange(rule)}
|
||||||
|
before={<Icon size="100" src={icons[rule]} />}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<Box grow="Yes">
|
||||||
|
<Text size="T300">{labels[rule]}</Text>
|
||||||
|
</Box>
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Menu>
|
||||||
|
</FocusTrap>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
size="300"
|
||||||
|
variant="Secondary"
|
||||||
|
fill="Soft"
|
||||||
|
radii="300"
|
||||||
|
outlined
|
||||||
|
before={<Icon size="100" src={icons[value]} />}
|
||||||
|
after={
|
||||||
|
changing ? (
|
||||||
|
<Spinner size="100" variant="Secondary" fill="Soft" />
|
||||||
|
) : (
|
||||||
|
<Icon size="100" src={Icons.ChevronBottom} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onClick={handleOpenMenu}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<Text size="B300">{labels[value]}</Text>
|
||||||
|
</Button>
|
||||||
|
</PopOut>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
src/app/components/MemberSortMenu.tsx
Normal file
45
src/app/components/MemberSortMenu.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import React from 'react';
|
||||||
|
import { config, Menu, MenuItem, Text } from 'folds';
|
||||||
|
import { stopPropagation } from '../utils/keyboard';
|
||||||
|
import { useMemberSortMenu } from '../hooks/useMemberSort';
|
||||||
|
|
||||||
|
type MemberSortMenuProps = {
|
||||||
|
requestClose: () => void;
|
||||||
|
selected: number;
|
||||||
|
onSelect: (index: number) => void;
|
||||||
|
};
|
||||||
|
export function MemberSortMenu({ selected, onSelect, requestClose }: MemberSortMenuProps) {
|
||||||
|
const memberSortMenu = useMemberSortMenu();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: requestClose,
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
|
||||||
|
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Menu style={{ padding: config.space.S100 }}>
|
||||||
|
{memberSortMenu.map((menuItem, index) => (
|
||||||
|
<MenuItem
|
||||||
|
key={menuItem.name}
|
||||||
|
variant="Surface"
|
||||||
|
aria-pressed={selected === index}
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
onClick={() => {
|
||||||
|
onSelect(index);
|
||||||
|
requestClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="T300">{menuItem.name}</Text>
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Menu>
|
||||||
|
</FocusTrap>
|
||||||
|
);
|
||||||
|
}
|
||||||
49
src/app/components/MembershipFilterMenu.tsx
Normal file
49
src/app/components/MembershipFilterMenu.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import React from 'react';
|
||||||
|
import { config, Menu, MenuItem, Text } from 'folds';
|
||||||
|
import { stopPropagation } from '../utils/keyboard';
|
||||||
|
import { useMembershipFilterMenu } from '../hooks/useMemberFilter';
|
||||||
|
|
||||||
|
type MembershipFilterMenuProps = {
|
||||||
|
requestClose: () => void;
|
||||||
|
selected: number;
|
||||||
|
onSelect: (index: number) => void;
|
||||||
|
};
|
||||||
|
export function MembershipFilterMenu({
|
||||||
|
selected,
|
||||||
|
onSelect,
|
||||||
|
requestClose,
|
||||||
|
}: MembershipFilterMenuProps) {
|
||||||
|
const membershipFilterMenu = useMembershipFilterMenu();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: requestClose,
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
|
||||||
|
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Menu style={{ padding: config.space.S100 }}>
|
||||||
|
{membershipFilterMenu.map((menuItem, index) => (
|
||||||
|
<MenuItem
|
||||||
|
key={menuItem.name}
|
||||||
|
variant="Surface"
|
||||||
|
aria-pressed={selected === index}
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
onClick={() => {
|
||||||
|
onSelect(index);
|
||||||
|
requestClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="T300">{menuItem.name}</Text>
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Menu>
|
||||||
|
</FocusTrap>
|
||||||
|
);
|
||||||
|
}
|
||||||
8
src/app/components/cutout-card/CutoutCard.css.ts
Normal file
8
src/app/components/cutout-card/CutoutCard.css.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
import { config } from 'folds';
|
||||||
|
|
||||||
|
export const CutoutCard = style({
|
||||||
|
borderRadius: config.radii.R300,
|
||||||
|
borderWidth: config.borderWidth.B300,
|
||||||
|
overflow: 'hidden',
|
||||||
|
});
|
||||||
15
src/app/components/cutout-card/CutoutCard.tsx
Normal file
15
src/app/components/cutout-card/CutoutCard.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { as, ContainerColor as TContainerColor } from 'folds';
|
||||||
|
import React from 'react';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||||
|
import * as css from './CutoutCard.css';
|
||||||
|
|
||||||
|
export const CutoutCard = as<'div', { variant?: TContainerColor }>(
|
||||||
|
({ as: AsCutoutCard = 'div', className, variant = 'Surface', ...props }, ref) => (
|
||||||
|
<AsCutoutCard
|
||||||
|
className={classNames(ContainerColor({ variant }), css.CutoutCard, className)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
1
src/app/components/cutout-card/index.ts
Normal file
1
src/app/components/cutout-card/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export * from './CutoutCard';
|
||||||
|
|
@ -654,6 +654,7 @@ export function EmojiBoard({
|
||||||
onCustomEmojiSelect,
|
onCustomEmojiSelect,
|
||||||
onStickerSelect,
|
onStickerSelect,
|
||||||
allowTextCustomEmoji,
|
allowTextCustomEmoji,
|
||||||
|
addToRecentEmoji = true,
|
||||||
}: {
|
}: {
|
||||||
tab?: EmojiBoardTab;
|
tab?: EmojiBoardTab;
|
||||||
onTabChange?: (tab: EmojiBoardTab) => void;
|
onTabChange?: (tab: EmojiBoardTab) => void;
|
||||||
|
|
@ -664,6 +665,7 @@ export function EmojiBoard({
|
||||||
onCustomEmojiSelect?: (mxc: string, shortcode: string) => void;
|
onCustomEmojiSelect?: (mxc: string, shortcode: string) => void;
|
||||||
onStickerSelect?: (mxc: string, shortcode: string, label: string) => void;
|
onStickerSelect?: (mxc: string, shortcode: string, label: string) => void;
|
||||||
allowTextCustomEmoji?: boolean;
|
allowTextCustomEmoji?: boolean;
|
||||||
|
addToRecentEmoji?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const emojiTab = tab === EmojiBoardTab.Emoji;
|
const emojiTab = tab === EmojiBoardTab.Emoji;
|
||||||
const stickerTab = tab === EmojiBoardTab.Sticker;
|
const stickerTab = tab === EmojiBoardTab.Sticker;
|
||||||
|
|
@ -735,7 +737,9 @@ export function EmojiBoard({
|
||||||
if (emojiInfo.type === EmojiType.Emoji) {
|
if (emojiInfo.type === EmojiType.Emoji) {
|
||||||
onEmojiSelect?.(emojiInfo.data, emojiInfo.shortcode);
|
onEmojiSelect?.(emojiInfo.data, emojiInfo.shortcode);
|
||||||
if (!evt.altKey && !evt.shiftKey) {
|
if (!evt.altKey && !evt.shiftKey) {
|
||||||
|
if (addToRecentEmoji) {
|
||||||
addRecentEmoji(mx, emojiInfo.data);
|
addRecentEmoji(mx, emojiInfo.data);
|
||||||
|
}
|
||||||
requestClose();
|
requestClose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
53
src/app/components/member-tile/MemberTile.tsx
Normal file
53
src/app/components/member-tile/MemberTile.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
import React, { ReactNode } from 'react';
|
||||||
|
import { as, Avatar, Box, Icon, Icons, Text } from 'folds';
|
||||||
|
import { MatrixClient, Room, RoomMember } from 'matrix-js-sdk';
|
||||||
|
import { getMemberDisplayName } from '../../utils/room';
|
||||||
|
import { getMxIdLocalPart } from '../../utils/matrix';
|
||||||
|
import { UserAvatar } from '../user-avatar';
|
||||||
|
import * as css from './style.css';
|
||||||
|
|
||||||
|
const getName = (room: Room, member: RoomMember) =>
|
||||||
|
getMemberDisplayName(room, member.userId) ?? getMxIdLocalPart(member.userId) ?? member.userId;
|
||||||
|
|
||||||
|
type MemberTileProps = {
|
||||||
|
mx: MatrixClient;
|
||||||
|
room: Room;
|
||||||
|
member: RoomMember;
|
||||||
|
useAuthentication: boolean;
|
||||||
|
after?: ReactNode;
|
||||||
|
};
|
||||||
|
export const MemberTile = as<'button', MemberTileProps>(
|
||||||
|
({ as: AsMemberTile = 'button', mx, room, member, useAuthentication, after, ...props }, ref) => {
|
||||||
|
const name = getName(room, member);
|
||||||
|
const username = getMxIdLocalPart(member.userId);
|
||||||
|
|
||||||
|
const avatarMxcUrl = member.getMxcAvatarUrl();
|
||||||
|
const avatarUrl = avatarMxcUrl
|
||||||
|
? mx.mxcUrlToHttp(avatarMxcUrl, 100, 100, 'crop', undefined, false, useAuthentication)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AsMemberTile className={css.MemberTile} {...props} ref={ref}>
|
||||||
|
<Avatar size="300" radii="400">
|
||||||
|
<UserAvatar
|
||||||
|
userId={member.userId}
|
||||||
|
src={avatarUrl ?? undefined}
|
||||||
|
alt={name}
|
||||||
|
renderFallback={() => <Icon size="300" src={Icons.User} filled />}
|
||||||
|
/>
|
||||||
|
</Avatar>
|
||||||
|
<Box grow="Yes" as="span" direction="Column">
|
||||||
|
<Text as="span" size="T300" truncate>
|
||||||
|
<b>{name}</b>
|
||||||
|
</Text>
|
||||||
|
<Box alignItems="Center" justifyContent="SpaceBetween" gap="100">
|
||||||
|
<Text as="span" size="T200" priority="300" truncate>
|
||||||
|
{username}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
{after}
|
||||||
|
</AsMemberTile>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
1
src/app/components/member-tile/index.ts
Normal file
1
src/app/components/member-tile/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export * from './MemberTile';
|
||||||
32
src/app/components/member-tile/style.css.ts
Normal file
32
src/app/components/member-tile/style.css.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
import { color, config, DefaultReset, Disabled, FocusOutline } from 'folds';
|
||||||
|
|
||||||
|
export const MemberTile = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
width: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: config.space.S200,
|
||||||
|
|
||||||
|
padding: config.space.S100,
|
||||||
|
borderRadius: config.radii.R500,
|
||||||
|
|
||||||
|
selectors: {
|
||||||
|
'button&': {
|
||||||
|
cursor: 'pointer',
|
||||||
|
},
|
||||||
|
'&[aria-pressed=true]': {
|
||||||
|
backgroundColor: color.Surface.ContainerActive,
|
||||||
|
},
|
||||||
|
'button&:hover, &:focus-visible': {
|
||||||
|
backgroundColor: color.Surface.ContainerHover,
|
||||||
|
},
|
||||||
|
'button&:active': {
|
||||||
|
backgroundColor: color.Surface.ContainerActive,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
FocusOutline,
|
||||||
|
Disabled,
|
||||||
|
]);
|
||||||
21
src/app/components/power/PowerColorBadge.tsx
Normal file
21
src/app/components/power/PowerColorBadge.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { as } from 'folds';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import * as css from './style.css';
|
||||||
|
|
||||||
|
type PowerColorBadgeProps = {
|
||||||
|
color?: string;
|
||||||
|
};
|
||||||
|
export const PowerColorBadge = as<'span', PowerColorBadgeProps>(
|
||||||
|
({ as: AsPowerColorBadge = 'span', color, className, style, ...props }, ref) => (
|
||||||
|
<AsPowerColorBadge
|
||||||
|
className={classNames(css.PowerColorBadge, className)}
|
||||||
|
style={{
|
||||||
|
backgroundColor: color,
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
15
src/app/components/power/PowerIcon.tsx
Normal file
15
src/app/components/power/PowerIcon.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import React from 'react';
|
||||||
|
import * as css from './style.css';
|
||||||
|
import { JUMBO_EMOJI_REG } from '../../utils/regex';
|
||||||
|
|
||||||
|
type PowerIconProps = css.PowerIconVariants & {
|
||||||
|
iconSrc: string;
|
||||||
|
name?: string;
|
||||||
|
};
|
||||||
|
export function PowerIcon({ size, iconSrc, name }: PowerIconProps) {
|
||||||
|
return JUMBO_EMOJI_REG.test(iconSrc) ? (
|
||||||
|
<span className={css.PowerIcon({ size })}>{iconSrc}</span>
|
||||||
|
) : (
|
||||||
|
<img className={css.PowerIcon({ size })} src={iconSrc} alt={name} />
|
||||||
|
);
|
||||||
|
}
|
||||||
94
src/app/components/power/PowerSelector.tsx
Normal file
94
src/app/components/power/PowerSelector.tsx
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
import React, { forwardRef, MouseEventHandler, ReactNode, useState } from 'react';
|
||||||
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import { Box, config, Menu, MenuItem, PopOut, Scroll, Text, toRem, RectCords } from 'folds';
|
||||||
|
import { getPowers, PowerLevelTags } from '../../hooks/usePowerLevelTags';
|
||||||
|
import { PowerColorBadge } from './PowerColorBadge';
|
||||||
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
|
|
||||||
|
type PowerSelectorProps = {
|
||||||
|
powerLevelTags: PowerLevelTags;
|
||||||
|
value: number;
|
||||||
|
onChange: (value: number) => void;
|
||||||
|
};
|
||||||
|
export const PowerSelector = forwardRef<HTMLDivElement, PowerSelectorProps>(
|
||||||
|
({ powerLevelTags, value, onChange }, ref) => (
|
||||||
|
<Menu
|
||||||
|
ref={ref}
|
||||||
|
style={{
|
||||||
|
maxHeight: '75vh',
|
||||||
|
maxWidth: toRem(300),
|
||||||
|
display: 'flex',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box grow="Yes">
|
||||||
|
<Scroll size="0" hideTrack visibility="Hover">
|
||||||
|
<div style={{ padding: config.space.S100 }}>
|
||||||
|
{getPowers(powerLevelTags).map((power) => {
|
||||||
|
const selected = value === power;
|
||||||
|
const tag = powerLevelTags[power];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MenuItem
|
||||||
|
key={power}
|
||||||
|
aria-pressed={selected}
|
||||||
|
radii="300"
|
||||||
|
onClick={selected ? undefined : () => onChange(power)}
|
||||||
|
before={<PowerColorBadge color={tag.color} />}
|
||||||
|
after={<Text size="L400">{power}</Text>}
|
||||||
|
>
|
||||||
|
<Text style={{ flexGrow: 1 }} size="B400" truncate>
|
||||||
|
{tag.name}
|
||||||
|
</Text>
|
||||||
|
</MenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Scroll>
|
||||||
|
</Box>
|
||||||
|
</Menu>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
type PowerSwitcherProps = PowerSelectorProps & {
|
||||||
|
children: (handleOpen: MouseEventHandler<HTMLButtonElement>, opened: boolean) => ReactNode;
|
||||||
|
};
|
||||||
|
export function PowerSwitcher({ powerLevelTags, value, onChange, children }: PowerSwitcherProps) {
|
||||||
|
const [menuCords, setMenuCords] = useState<RectCords>();
|
||||||
|
|
||||||
|
const handleOpen: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||||
|
setMenuCords(evt.currentTarget.getBoundingClientRect());
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PopOut
|
||||||
|
anchor={menuCords}
|
||||||
|
offset={5}
|
||||||
|
position="Bottom"
|
||||||
|
align="End"
|
||||||
|
content={
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: () => setMenuCords(undefined),
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
isKeyForward: (evt: KeyboardEvent) =>
|
||||||
|
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
|
||||||
|
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PowerSelector
|
||||||
|
powerLevelTags={powerLevelTags}
|
||||||
|
value={value}
|
||||||
|
onChange={(v) => {
|
||||||
|
onChange(v);
|
||||||
|
setMenuCords(undefined);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FocusTrap>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{children(handleOpen, !!menuCords)}
|
||||||
|
</PopOut>
|
||||||
|
);
|
||||||
|
}
|
||||||
3
src/app/components/power/index.ts
Normal file
3
src/app/components/power/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export * from './PowerColorBadge';
|
||||||
|
export * from './PowerIcon';
|
||||||
|
export * from './PowerSelector';
|
||||||
73
src/app/components/power/style.css.ts
Normal file
73
src/app/components/power/style.css.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
import { createVar, style } from '@vanilla-extract/css';
|
||||||
|
import { recipe, RecipeVariants } from '@vanilla-extract/recipes';
|
||||||
|
import { color, config, DefaultReset, toRem } from 'folds';
|
||||||
|
|
||||||
|
export const PowerColorBadge = style({
|
||||||
|
display: 'inline-block',
|
||||||
|
flexShrink: 0,
|
||||||
|
width: toRem(16),
|
||||||
|
height: toRem(16),
|
||||||
|
backgroundColor: color.Surface.OnContainer,
|
||||||
|
borderRadius: config.radii.Pill,
|
||||||
|
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const PowerIconSize = createVar();
|
||||||
|
export const PowerIcon = recipe({
|
||||||
|
base: [
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
display: 'inline-flex',
|
||||||
|
height: PowerIconSize,
|
||||||
|
minWidth: PowerIconSize,
|
||||||
|
fontSize: PowerIconSize,
|
||||||
|
lineHeight: PowerIconSize,
|
||||||
|
borderRadius: config.radii.R300,
|
||||||
|
cursor: 'default',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
variants: {
|
||||||
|
size: {
|
||||||
|
'50': {
|
||||||
|
vars: {
|
||||||
|
[PowerIconSize]: config.size.X50,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'100': {
|
||||||
|
vars: {
|
||||||
|
[PowerIconSize]: config.size.X100,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'200': {
|
||||||
|
vars: {
|
||||||
|
[PowerIconSize]: config.size.X200,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'300': {
|
||||||
|
vars: {
|
||||||
|
[PowerIconSize]: config.size.X300,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'400': {
|
||||||
|
vars: {
|
||||||
|
[PowerIconSize]: config.size.X400,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'500': {
|
||||||
|
vars: {
|
||||||
|
[PowerIconSize]: config.size.X500,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'600': {
|
||||||
|
vars: {
|
||||||
|
[PowerIconSize]: config.size.X600,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
size: '400',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export type PowerIconVariants = RecipeVariants<typeof PowerIcon>;
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue