feat(a11y): focus return, typing announcement, shortcuts help (P3-4)

- Focus returns to the trigger when closing 4 genuine dialogs (room-topic
  viewer, reaction viewer, header topic, Search) — 20 inline popouts/menus
  correctly left as-is (returning focus to a hover target would be wrong).
- Typing indicator announced via a visually-hidden role="status" region;
  the visual text is aria-hidden to avoid double announcement.
- New keyboard-shortcuts help dialog (press ?, ignored while typing),
  mounted in ClientNonUIFeatures.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 11:45:22 -04:00
parent 4380041014
commit 21dda93d1b
9 changed files with 297 additions and 7 deletions
@@ -95,7 +95,6 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) =>
<FocusTrap <FocusTrap
focusTrapOptions={{ focusTrapOptions={{
initialFocus: false, initialFocus: false,
returnFocusOnDeactivate: false,
clickOutsideDeactivates: true, clickOutsideDeactivates: true,
onDeactivate: () => setViewTopic(false), onDeactivate: () => setViewTopic(false),
escapeDeactivates: stopPropagation, escapeDeactivates: stopPropagation,
-1
View File
@@ -583,7 +583,6 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
<FocusTrap <FocusTrap
focusTrapOptions={{ focusTrapOptions={{
initialFocus: false, initialFocus: false,
returnFocusOnDeactivate: false,
clickOutsideDeactivates: true, clickOutsideDeactivates: true,
onDeactivate: () => setViewTopic(false), onDeactivate: () => setViewTopic(false),
escapeDeactivates: stopPropagation, escapeDeactivates: stopPropagation,
@@ -25,3 +25,16 @@ export const RoomViewTyping = style([
export const TypingText = style({ export const TypingText = style({
flexGrow: 1, flexGrow: 1,
}); });
// Visually hidden but available to assistive technology.
export const SrOnly = style({
position: 'absolute',
width: 1,
height: 1,
padding: 0,
margin: -1,
overflow: 'hidden',
clip: 'rect(0, 0, 0, 0)',
whiteSpace: 'nowrap',
border: 0,
});
+19 -2
View File
@@ -37,6 +37,20 @@ export const RoomViewTyping = as<'div', RoomViewTypingProps>(
return null; return null;
} }
// A single, non-truncated string for assistive technology to announce.
let typingAnnouncement = '';
if (typingNames.length === 1) {
typingAnnouncement = `${typingNames[0]} is typing`;
} else if (typingNames.length === 2) {
typingAnnouncement = `${typingNames[0]} and ${typingNames[1]} are typing`;
} else if (typingNames.length === 3) {
typingAnnouncement = `${typingNames[0]}, ${typingNames[1]} and ${typingNames[2]} are typing`;
} else {
typingAnnouncement = `${typingNames[0]}, ${typingNames[1]}, ${typingNames[2]} and ${
typingNames.length - 3
} others are typing`;
}
const handleDropAll = () => { const handleDropAll = () => {
// some homeserver does not timeout typing status // some homeserver does not timeout typing status
// we have given option so user can drop their typing status // we have given option so user can drop their typing status
@@ -50,7 +64,10 @@ export const RoomViewTyping = as<'div', RoomViewTypingProps>(
}; };
return ( return (
<div style={{ position: 'relative' }} aria-live="polite" aria-atomic="false"> <div style={{ position: 'relative' }}>
<span className={css.SrOnly} role="status" aria-live="polite" aria-atomic="true">
{typingAnnouncement}
</span>
<Box <Box
className={classNames(css.RoomViewTyping, className)} className={classNames(css.RoomViewTyping, className)}
alignItems="Center" alignItems="Center"
@@ -59,7 +76,7 @@ export const RoomViewTyping = as<'div', RoomViewTypingProps>(
ref={ref} ref={ref}
> >
<TypingIndicator /> <TypingIndicator />
<Text className={css.TypingText} size="T300" truncate> <Text className={css.TypingText} size="T300" truncate aria-hidden>
{typingNames.length === 1 && ( {typingNames.length === 1 && (
<> <>
<b>{typingNames[0]}</b> <b>{typingNames[0]}</b>
@@ -106,7 +106,6 @@ export const Reactions = as<'div', ReactionsProps>(
<FocusTrap <FocusTrap
focusTrapOptions={{ focusTrapOptions={{
initialFocus: false, initialFocus: false,
returnFocusOnDeactivate: false,
onDeactivate: () => setViewer(false), onDeactivate: () => setViewer(false),
clickOutsideDeactivates: true, clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation, escapeDeactivates: stopPropagation,
@@ -0,0 +1,49 @@
import { style } from '@vanilla-extract/css';
import { DefaultReset, color, config, toRem } from 'folds';
export const ShortcutList = style([
DefaultReset,
{
margin: 0,
},
]);
export const ShortcutRow = style({
padding: `${config.space.S100} 0`,
});
export const ShortcutTerm = style([
DefaultReset,
{
flexGrow: 1,
},
]);
export const ShortcutKeys = style([
DefaultReset,
{
display: 'flex',
alignItems: 'center',
gap: config.space.S100,
flexShrink: 0,
},
]);
export const Kbd = style([
DefaultReset,
{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
minWidth: toRem(20),
padding: `0 ${config.space.S200}`,
height: toRem(24),
fontFamily: 'inherit',
fontSize: toRem(12),
lineHeight: toRem(24),
color: color.SurfaceVariant.OnContainer,
backgroundColor: color.SurfaceVariant.Container,
border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
borderRadius: config.radii.R300,
},
]);
@@ -0,0 +1,204 @@
import React, { useCallback } from 'react';
import FocusTrap from 'focus-trap-react';
import { atom, useAtom, useSetAtom } from 'jotai';
import {
Box,
Dialog,
Header,
Icon,
IconButton,
Icons,
Line,
Overlay,
OverlayBackdrop,
OverlayCenter,
Scroll,
Text,
config,
} from 'folds';
import { stopPropagation } from '../../utils/keyboard';
import { editableActiveElement } from '../../utils/dom';
import { useKeyDown } from '../../hooks/useKeyDown';
import { useModalStyle } from '../../hooks/useModalStyle';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { isMacOS } from '../../utils/user-agent';
import { KeySymbol } from '../../utils/key-symbol';
import * as css from './KeyboardShortcutsDialog.css';
/** Global open-state for the keyboard shortcuts help dialog. */
export const keyboardShortcutsDialogAtom = atom<boolean>(false);
/** Read/control the keyboard shortcuts dialog open-state. */
export function useKeyboardShortcutsDialog() {
const [open, setOpen] = useAtom(keyboardShortcutsDialogAtom);
const openDialog = useCallback(() => setOpen(true), [setOpen]);
const closeDialog = useCallback(() => setOpen(false), [setOpen]);
return { open, openDialog, closeDialog };
}
/**
* Registers the global `Shift + /` (`?`) shortcut that opens the keyboard
* shortcuts help dialog. Ignored while the user is typing into an input,
* textarea or contenteditable so it never steals a literal `?` character.
*
* Mount once in the client shell (e.g. `ClientNonUIFeatures`).
*/
export function useKeyboardShortcutsTrigger() {
const setOpen = useSetAtom(keyboardShortcutsDialogAtom);
useKeyDown(
window,
useCallback(
(evt: KeyboardEvent) => {
// Never intercept `?` while the user is typing into a field/editor.
if (editableActiveElement()) return;
// `?` is produced by Shift + `/` on the common layouts.
if (evt.key === '?') {
evt.preventDefault();
setOpen(true);
}
},
[setOpen],
),
);
}
type ShortcutRow = {
description: string;
keys: string[];
};
type ShortcutSection = {
title: string;
rows: ShortcutRow[];
};
function ShortcutKeys({ keys }: { keys: string[] }) {
return (
<Box as="dd" className={css.ShortcutKeys}>
{keys.map((key, index) => (
<kbd key={`${key}-${index}`} className={css.Kbd}>
{key}
</kbd>
))}
</Box>
);
}
/**
* Accessible keyboard shortcuts help dialog. Renders (as a modal overlay) only
* while `keyboardShortcutsDialogAtom` is `true`. Open it with the `?` shortcut
* (see `useKeyboardShortcutsTrigger`) or via `useKeyboardShortcutsDialog`.
*/
export function KeyboardShortcutsDialog() {
const { open, closeDialog } = useKeyboardShortcutsDialog();
const modalStyle = useModalStyle(480);
const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
const modKey = isMacOS() ? KeySymbol.Command : 'Ctrl';
if (!open) return null;
const sections: ShortcutSection[] = [
{
title: 'General',
rows: [
{ description: 'Show keyboard shortcuts', keys: ['?'] },
{ description: 'Close open panel, otherwise mark room as read', keys: [KeySymbol.Escape] },
],
},
{
title: 'Composer',
rows: [
{ description: 'Focus the message composer', keys: ['Any character'] },
{
description: 'Send message',
keys: enterForNewline ? [modKey, 'Enter'] : ['Enter'],
},
{
description: 'Insert a new line',
keys: enterForNewline ? ['Enter'] : [KeySymbol.Shift, 'Enter'],
},
{ description: 'Send message (always)', keys: [modKey, 'Enter'] },
],
},
{
title: 'Messages',
rows: [
{ description: 'Reveal message actions (react, reply, more)', keys: ['Hover / focus'] },
],
},
];
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: closeDialog,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Dialog
variant="Surface"
aria-labelledby="keyboard-shortcuts-dialog-title"
style={modalStyle}
>
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
borderBottomWidth: config.borderWidth.B300,
}}
variant="Surface"
size="500"
>
<Box grow="Yes">
<Text as="h2" size="H4" id="keyboard-shortcuts-dialog-title">
Keyboard Shortcuts
</Text>
</Box>
<IconButton size="300" onClick={closeDialog} radii="300" aria-label="Close">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
<Scroll size="300" hideTrack visibility="Hover">
<Box style={{ padding: config.space.S400 }} direction="Column" gap="500">
{sections.map((section, sectionIndex) => (
<Box key={section.title} direction="Column" gap="300">
{sectionIndex > 0 && <Line variant="Surface" size="300" />}
<Text size="L400" priority="400">
{section.title}
</Text>
<Box as="dl" className={css.ShortcutList} direction="Column">
{section.rows.map((row) => (
<Box
key={row.description}
className={css.ShortcutRow}
direction="Row"
alignItems="Center"
gap="200"
>
<Text as="dt" className={css.ShortcutTerm} size="T300">
{row.description}
</Text>
<ShortcutKeys keys={row.keys} />
</Box>
))}
</Box>
</Box>
))}
<Text size="T200" priority="300">
{enterForNewline
? 'Enter inserts a new line while “Enter for newline” is enabled in Settings.'
: 'Enter sends the message. Enable “Enter for newline” in Settings to swap this.'}
</Text>
</Box>
</Scroll>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
);
}
+1
View File
@@ -0,0 +1 @@
export * from './KeyboardShortcutsDialog';
+11 -2
View File
@@ -42,6 +42,7 @@ import { toastQueueAtom } from '../../state/toast';
import { useReminders } from '../../hooks/useReminders'; import { useReminders } from '../../hooks/useReminders';
import { useTauriUpdater } from '../../hooks/useTauriUpdater'; import { useTauriUpdater } from '../../hooks/useTauriUpdater';
import { TauriDesktopFeatures } from '../../components/TauriDesktopFeatures'; import { TauriDesktopFeatures } from '../../components/TauriDesktopFeatures';
import { KeyboardShortcutsDialog, useKeyboardShortcutsTrigger } from '../../features/shortcuts';
import { useRoomsListener } from '../../hooks/useRoomsListener'; import { useRoomsListener } from '../../hooks/useRoomsListener';
import { threadNotificationsAtom } from '../../state/threadNotifications'; import { threadNotificationsAtom } from '../../state/threadNotifications';
import { roomIdToActiveThreadIdAtomFamily } from '../../state/room/thread'; import { roomIdToActiveThreadIdAtomFamily } from '../../state/room/thread';
@@ -213,7 +214,7 @@ function InviteNotifications() {
]); ]);
return ( return (
<audio ref={audioRef} style={{ display: 'none' }}> <audio ref={audioRef} style={{ display: 'none' }} aria-hidden="true">
<source src={soundSrc ?? InviteSound} type="audio/ogg" /> <source src={soundSrc ?? InviteSound} type="audio/ogg" />
</audio> </audio>
); );
@@ -496,7 +497,7 @@ function MessageNotifications() {
useRoomsListener(mx, ThreadEvent.NewReply, handleNewReply); useRoomsListener(mx, ThreadEvent.NewReply, handleNewReply);
return ( return (
<audio ref={audioRef} style={{ display: 'none' }}> <audio ref={audioRef} style={{ display: 'none' }} aria-hidden="true">
<source src={soundSrc ?? NotificationSound} type="audio/ogg" /> <source src={soundSrc ?? NotificationSound} type="audio/ogg" />
</audio> </audio>
); );
@@ -642,6 +643,13 @@ function LotusDenoiseFeature() {
return null; return null;
} }
// Registers the global `?` shortcut (ignored while typing) and renders the
// keyboard-shortcuts help dialog. Headless — the dialog self-gates on its atom.
function KeyboardShortcutsFeature() {
useKeyboardShortcutsTrigger();
return <KeyboardShortcutsDialog />;
}
export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) { export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
return ( return (
<> <>
@@ -656,6 +664,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
<TauriDesktopFeatures /> <TauriDesktopFeatures />
<LotusDenoiseFeature /> <LotusDenoiseFeature />
<DeepLinkNavigator /> <DeepLinkNavigator />
<KeyboardShortcutsFeature />
{children} {children}
</> </>
); );