Files
cinny/src/app/features/lobby/HierarchyItemMenu.tsx
T
Lotus CIandClaude Opus 5.5 103c6f4624
CI / Build & Quality Checks (push) Successful in 1m53s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 6s
CI / Trigger Desktop Build (push) Successful in 5s
CI / Playwright smoke (e2e) (push) Successful in 11m15s
fix(a11y): closing a header/sidebar menu returns focus to its button
16 menus (room header, Home/Direct/Space sidebar tabs, create tab, lobby,
room-nav item, history visibility, client root) set
returnFocusOnDeactivate: false, so pressing Escape dropped focus to
<body> and a keyboard user had to start over from the top of the page.
That option was there so focus wouldn't be pulled back to the menu button
when an item opens a dialog; since ce8ed89f dialogs move focus into
themselves (their initial focus runs after the menu's return), so it's no
longer needed. The autocomplete menu (focus must stay in the editor) and
the two menus whose opener disappears are left as they were.

Checked: room header menu → Escape → focus on "More options" (was
<body>); menu → Leave Room → focus inside the Leave Room dialog on
Cancel, same as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-24 13:16:12 -04:00

341 lines
10 KiB
TypeScript

import React, { MouseEventHandler, useCallback, useEffect, useState } from 'react';
import FocusTrap from 'focus-trap-react';
import {
Box,
IconButton,
Icon,
Icons,
PopOut,
Menu,
MenuItem,
Text,
RectCords,
config,
Line,
Spinner,
toRem,
} from 'folds';
import { HierarchyItem } from '../../hooks/useSpaceHierarchy';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { MSpaceChildContent, StateEvent } from '../../../types/matrix/room';
import { sendStateEvent } from '../../utils/room';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { UseStateProvider } from '../../components/UseStateProvider';
import { LeaveSpacePrompt } from '../../components/leave-space-prompt';
import { LeaveRoomPrompt } from '../../components/leave-room-prompt';
import { stopPropagation } from '../../utils/keyboard';
import { useOpenRoomSettings } from '../../state/hooks/roomSettings';
import { useSpaceOptionally } from '../../hooks/useSpace';
import { useOpenSpaceSettings } from '../../state/hooks/spaceSettings';
import { IPowerLevels } from '../../hooks/usePowerLevels';
import { getRoomCreatorsForRoomId } from '../../hooks/useRoomCreators';
import { getRoomPermissionsAPI } from '../../hooks/useRoomPermissions';
import { InviteUserPrompt } from '../../components/invite-user-prompt';
type HierarchyItemWithParent = HierarchyItem & {
parentId: string;
};
function SuggestMenuItem({
item,
requestClose,
}: {
item: HierarchyItemWithParent;
requestClose: () => void;
}) {
const mx = useMatrixClient();
const { roomId, parentId, content } = item;
const [toggleState, handleToggleSuggested] = useAsyncCallback(
useCallback(() => {
const newContent: MSpaceChildContent = { ...content, suggested: !content.suggested };
return sendStateEvent(mx, parentId, StateEvent.SpaceChild, newContent, roomId);
}, [mx, parentId, roomId, content]),
);
useEffect(() => {
if (toggleState.status === AsyncStatus.Success) {
requestClose();
}
}, [requestClose, toggleState]);
return (
<MenuItem
onClick={handleToggleSuggested}
size="300"
radii="300"
before={toggleState.status === AsyncStatus.Loading && <Spinner size="100" />}
disabled={toggleState.status === AsyncStatus.Loading}
>
<Text as="span" size="T300" truncate>
{content.suggested ? 'Unset Suggested' : 'Set Suggested'}
</Text>
</MenuItem>
);
}
function RemoveMenuItem({
item,
requestClose,
}: {
item: HierarchyItemWithParent;
requestClose: () => void;
}) {
const mx = useMatrixClient();
const { roomId, parentId } = item;
const [removeState, handleRemove] = useAsyncCallback(
useCallback(
() => sendStateEvent(mx, parentId, StateEvent.SpaceChild, {}, roomId),
[mx, parentId, roomId],
),
);
useEffect(() => {
if (removeState.status === AsyncStatus.Success) {
requestClose();
}
}, [requestClose, removeState]);
return (
<MenuItem
onClick={handleRemove}
variant="Critical"
fill="None"
size="300"
radii="300"
before={
removeState.status === AsyncStatus.Loading && (
<Spinner variant="Critical" fill="Soft" size="100" />
)
}
disabled={removeState.status === AsyncStatus.Loading}
>
<Text as="span" size="T300" truncate>
Remove
</Text>
</MenuItem>
);
}
function InviteMenuItem({
item,
requestClose,
disabled,
}: {
item: HierarchyItemWithParent;
requestClose: () => void;
disabled?: boolean;
}) {
const mx = useMatrixClient();
const room = mx.getRoom(item.roomId);
const [invitePrompt, setInvitePrompt] = useState(false);
const handleInvite = () => {
setInvitePrompt(true);
};
return (
<>
<MenuItem
onClick={handleInvite}
size="300"
radii="300"
variant="Primary"
fill="None"
aria-pressed={invitePrompt}
disabled={disabled || !room}
>
<Text as="span" size="T300" truncate>
Invite
</Text>
</MenuItem>
{invitePrompt && room && (
<InviteUserPrompt
room={room}
requestClose={() => {
setInvitePrompt(false);
requestClose();
}}
/>
)}
</>
);
}
function SettingsMenuItem({
item,
requestClose,
disabled,
}: {
item: HierarchyItemWithParent;
requestClose: () => void;
disabled?: boolean;
}) {
const openRoomSettings = useOpenRoomSettings();
const openSpaceSettings = useOpenSpaceSettings();
const space = useSpaceOptionally();
const handleSettings = () => {
if ('space' in item) {
openSpaceSettings(item.roomId, item.parentId);
} else {
openRoomSettings(item.roomId, space?.roomId);
}
requestClose();
};
return (
<MenuItem onClick={handleSettings} size="300" radii="300" disabled={disabled}>
<Text as="span" size="T300" truncate>
Settings
</Text>
</MenuItem>
);
}
type HierarchyItemMenuProps = {
item: HierarchyItem & {
parentId: string;
};
joined: boolean;
powerLevels?: IPowerLevels;
canEditChild: boolean;
pinned?: boolean;
onTogglePin?: (roomId: string) => void;
};
export function HierarchyItemMenu({
item,
joined,
powerLevels,
canEditChild,
pinned,
onTogglePin,
}: HierarchyItemMenuProps) {
const mx = useMatrixClient();
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const canInvite = (): boolean => {
if (!powerLevels) return false;
const creators = getRoomCreatorsForRoomId(mx, item.roomId);
const permissions = getRoomPermissionsAPI(creators, powerLevels);
return permissions.action('invite', mx.getSafeUserId());
};
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
setMenuAnchor(evt.currentTarget.getBoundingClientRect());
};
const handleRequestClose = useCallback(() => setMenuAnchor(undefined), []);
if (!joined && !canEditChild) {
return null;
}
return (
<Box gap="200" alignItems="Center" shrink="No">
<IconButton
onClick={handleOpenMenu}
size="300"
variant="SurfaceVariant"
fill="None"
radii="300"
aria-label="More options"
aria-expanded={!!menuAnchor}
aria-haspopup="menu"
>
<Icon size="50" src={Icons.VerticalDots} />
</IconButton>
{menuAnchor && (
<PopOut
anchor={menuAnchor}
position="Bottom"
align="End"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setMenuAnchor(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<Menu style={{ maxWidth: toRem(150), width: '100vw' }}>
{joined && (
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
{onTogglePin && (
<MenuItem
size="300"
radii="300"
onClick={() => {
onTogglePin(item.roomId);
handleRequestClose();
}}
>
<Text as="span" size="T300" truncate>
{pinned ? 'Unpin from Sidebar' : 'Pin to Sidebar'}
</Text>
</MenuItem>
)}
<InviteMenuItem
item={item}
requestClose={handleRequestClose}
disabled={!canInvite()}
/>
<SettingsMenuItem item={item} requestClose={handleRequestClose} />
<UseStateProvider initial={false}>
{(promptLeave, setPromptLeave) => (
<>
<MenuItem
onClick={() => setPromptLeave(true)}
variant="Critical"
fill="None"
size="300"
after={<Icon size="100" src={Icons.ArrowGoLeft} />}
radii="300"
aria-pressed={promptLeave}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Leave
</Text>
</MenuItem>
{promptLeave &&
('space' in item ? (
<LeaveSpacePrompt
roomId={item.roomId}
onDone={handleRequestClose}
onCancel={() => setPromptLeave(false)}
/>
) : (
<LeaveRoomPrompt
roomId={item.roomId}
onDone={handleRequestClose}
onCancel={() => setPromptLeave(false)}
/>
))}
</>
)}
</UseStateProvider>
</Box>
)}
{(joined || canEditChild) && (
<Line size="300" variant="Surface" direction="Horizontal" />
)}
{canEditChild && (
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<SuggestMenuItem item={item} requestClose={handleRequestClose} />
<RemoveMenuItem item={item} requestClose={handleRequestClose} />
</Box>
)}
</Menu>
</FocusTrap>
}
/>
)}
</Box>
);
}