Files
cinny/src/app/components/UserRoomProfileRenderer.tsx
T
Lotus CIandClaude Opus 5.5 7f2e93d389
CI / Build & Quality Checks (push) Successful in 1m41s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 11s
CI / Trigger Desktop Build (push) Successful in 9s
CI / Playwright smoke (e2e) (push) Successful in 13m14s
fix(a11y): name the headless modals too — Seen by, source, file viewers (#185)
Follow-up to ce8ed89f for the modals that have no visible heading to
point at: the three "Seen by" reader lists (read-receipt pill, "is
following", message menu), View source, the text/PDF file viewers, the
room-card join error and the user-profile modal get role="dialog",
aria-modal and an aria-label; their traps move focus in (fallbackFocus
on the dialog) where the trap is local.

Verified: the receipt pill opens a "Seen by" dialog with focus on
Close; Escape closes it and focus returns to the pill.

The remaining unnamed Modal/Dialog uses are startup/loading and error
screens (config, feature check, spec versions, client root, password
reset) and wrappers around components that carry their own role (image
viewer).

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

121 lines
3.6 KiB
TypeScript

import React from 'react';
import {
Box,
Header,
Icon,
IconButton,
Icons,
Menu,
Modal,
Overlay,
OverlayBackdrop,
OverlayCenter,
PopOut,
config,
toRem,
} from 'folds';
import FocusTrap from 'focus-trap-react';
import { useCloseUserRoomProfile, useUserRoomProfileState } from '../state/hooks/userRoomProfile';
import { UserRoomProfile } from './user-profile';
import { UserRoomProfileState } from '../state/userRoomProfile';
import { useAllJoinedRoomsSet, useGetRoom } from '../hooks/useGetRoom';
import { stopPropagation } from '../utils/keyboard';
import { SpaceProvider } from '../hooks/useSpace';
import { RoomProvider } from '../hooks/useRoom';
import { ScreenSize, useScreenSize } from '../hooks/useScreenSize';
// Matches useModalStyle's mobile branch: fill the phone screen with internal
// scroll so tall profiles (moderation actions, device list, notes) are fully
// reachable — the anchored 340px popout below can't scroll and clipped them.
const MOBILE_FULLSCREEN = {
width: '100%',
height: '100%',
maxWidth: '100%',
maxHeight: '100%',
borderRadius: 0,
display: 'flex',
flexDirection: 'column',
} as const;
function UserRoomProfileContextMenu({ state }: { state: UserRoomProfileState }) {
const { roomId, spaceId, userId, cords, position } = state;
const allJoinedRooms = useAllJoinedRoomsSet();
const getRoom = useGetRoom(allJoinedRooms);
const room = getRoom(roomId);
const space = spaceId ? getRoom(spaceId) : undefined;
const screenSize = useScreenSize();
const close = useCloseUserRoomProfile();
if (!room) return null;
const profile = (
<SpaceProvider value={space ?? null}>
<RoomProvider value={room}>
<UserRoomProfile userId={userId} />
</RoomProvider>
</SpaceProvider>
);
const focusTrapOptions = {
initialFocus: false,
onDeactivate: close,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
};
// On phones, render as a full-screen scrollable modal instead of an anchored,
// fixed-width, unscrollable popout.
if (screenSize === ScreenSize.Mobile) {
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap focusTrapOptions={focusTrapOptions}>
<Modal
id="user-profile-dialog"
role="dialog"
aria-modal="true"
aria-label="User profile"
tabIndex={-1}
size="500"
style={MOBILE_FULLSCREEN}
>
{/* Full-screen covers the backdrop (no tap-to-dismiss) and the
profile has no self-close, so provide an explicit close. */}
<Header size="600" style={{ flexShrink: 0, paddingRight: config.space.S200 }}>
<Box grow="Yes" />
<IconButton size="300" radii="300" onClick={close} aria-label="Close">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
<Box grow="Yes" style={{ overflow: 'hidden auto' }}>
{profile}
</Box>
</Modal>
</FocusTrap>
</OverlayCenter>
</Overlay>
);
}
return (
<PopOut
anchor={cords}
position={position ?? 'Top'}
align="Start"
content={
<FocusTrap focusTrapOptions={focusTrapOptions}>
<Menu style={{ width: toRem(340) }}>{profile}</Menu>
</FocusTrap>
}
/>
);
}
export function UserRoomProfileRenderer() {
const state = useUserRoomProfileState();
if (!state) return null;
return <UserRoomProfileContextMenu state={state} />;
}