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:
Ajay Bura 2025-03-19 23:14:54 +11:00 committed by GitHub
parent 00f3df8719
commit 286983c833
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
73 changed files with 6196 additions and 420 deletions

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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',
});

View 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}
/>
)
);

View file

@ -0,0 +1 @@
export * from './CutoutCard';

View file

@ -654,6 +654,7 @@ export function EmojiBoard({
onCustomEmojiSelect,
onStickerSelect,
allowTextCustomEmoji,
addToRecentEmoji = true,
}: {
tab?: EmojiBoardTab;
onTabChange?: (tab: EmojiBoardTab) => void;
@ -664,6 +665,7 @@ export function EmojiBoard({
onCustomEmojiSelect?: (mxc: string, shortcode: string) => void;
onStickerSelect?: (mxc: string, shortcode: string, label: string) => void;
allowTextCustomEmoji?: boolean;
addToRecentEmoji?: boolean;
}) {
const emojiTab = tab === EmojiBoardTab.Emoji;
const stickerTab = tab === EmojiBoardTab.Sticker;
@ -735,7 +737,9 @@ export function EmojiBoard({
if (emojiInfo.type === EmojiType.Emoji) {
onEmojiSelect?.(emojiInfo.data, emojiInfo.shortcode);
if (!evt.altKey && !evt.shiftKey) {
addRecentEmoji(mx, emojiInfo.data);
if (addToRecentEmoji) {
addRecentEmoji(mx, emojiInfo.data);
}
requestClose();
}
}

View 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>
);
}
);

View file

@ -0,0 +1 @@
export * from './MemberTile';

View 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,
]);

View 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}
/>
)
);

View 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} />
);
}

View 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>
);
}

View file

@ -0,0 +1,3 @@
export * from './PowerColorBadge';
export * from './PowerIcon';
export * from './PowerSelector';

View 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>;

View file

@ -0,0 +1,16 @@
import React from 'react';
import { as, Badge, Text } from 'folds';
export const ServerBadge = as<
'div',
{
server: string;
fill?: 'Solid' | 'None';
}
>(({ as: AsServerBadge = 'div', fill, server, ...props }, ref) => (
<Badge as={AsServerBadge} variant="Secondary" fill={fill} radii="300" {...props} ref={ref}>
<Text as="span" size="L400" truncate>
{server}
</Text>
</Badge>
));

View file

@ -0,0 +1 @@
export * from './ServerBadge';