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:
@@ -95,7 +95,6 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) =>
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
returnFocusOnDeactivate: false,
|
||||
clickOutsideDeactivates: true,
|
||||
onDeactivate: () => setViewTopic(false),
|
||||
escapeDeactivates: stopPropagation,
|
||||
|
||||
@@ -583,7 +583,6 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
returnFocusOnDeactivate: false,
|
||||
clickOutsideDeactivates: true,
|
||||
onDeactivate: () => setViewTopic(false),
|
||||
escapeDeactivates: stopPropagation,
|
||||
|
||||
@@ -25,3 +25,16 @@ export const RoomViewTyping = style([
|
||||
export const TypingText = style({
|
||||
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,
|
||||
});
|
||||
|
||||
@@ -37,6 +37,20 @@ export const RoomViewTyping = as<'div', RoomViewTypingProps>(
|
||||
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 = () => {
|
||||
// some homeserver does not timeout typing status
|
||||
// we have given option so user can drop their typing status
|
||||
@@ -50,7 +64,10 @@ export const RoomViewTyping = as<'div', RoomViewTypingProps>(
|
||||
};
|
||||
|
||||
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
|
||||
className={classNames(css.RoomViewTyping, className)}
|
||||
alignItems="Center"
|
||||
@@ -59,7 +76,7 @@ export const RoomViewTyping = as<'div', RoomViewTypingProps>(
|
||||
ref={ref}
|
||||
>
|
||||
<TypingIndicator />
|
||||
<Text className={css.TypingText} size="T300" truncate>
|
||||
<Text className={css.TypingText} size="T300" truncate aria-hidden>
|
||||
{typingNames.length === 1 && (
|
||||
<>
|
||||
<b>{typingNames[0]}</b>
|
||||
|
||||
@@ -106,7 +106,6 @@ export const Reactions = as<'div', ReactionsProps>(
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
returnFocusOnDeactivate: false,
|
||||
onDeactivate: () => setViewer(false),
|
||||
clickOutsideDeactivates: true,
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './KeyboardShortcutsDialog';
|
||||
@@ -42,6 +42,7 @@ import { toastQueueAtom } from '../../state/toast';
|
||||
import { useReminders } from '../../hooks/useReminders';
|
||||
import { useTauriUpdater } from '../../hooks/useTauriUpdater';
|
||||
import { TauriDesktopFeatures } from '../../components/TauriDesktopFeatures';
|
||||
import { KeyboardShortcutsDialog, useKeyboardShortcutsTrigger } from '../../features/shortcuts';
|
||||
import { useRoomsListener } from '../../hooks/useRoomsListener';
|
||||
import { threadNotificationsAtom } from '../../state/threadNotifications';
|
||||
import { roomIdToActiveThreadIdAtomFamily } from '../../state/room/thread';
|
||||
@@ -213,7 +214,7 @@ function InviteNotifications() {
|
||||
]);
|
||||
|
||||
return (
|
||||
<audio ref={audioRef} style={{ display: 'none' }}>
|
||||
<audio ref={audioRef} style={{ display: 'none' }} aria-hidden="true">
|
||||
<source src={soundSrc ?? InviteSound} type="audio/ogg" />
|
||||
</audio>
|
||||
);
|
||||
@@ -496,7 +497,7 @@ function MessageNotifications() {
|
||||
useRoomsListener(mx, ThreadEvent.NewReply, handleNewReply);
|
||||
|
||||
return (
|
||||
<audio ref={audioRef} style={{ display: 'none' }}>
|
||||
<audio ref={audioRef} style={{ display: 'none' }} aria-hidden="true">
|
||||
<source src={soundSrc ?? NotificationSound} type="audio/ogg" />
|
||||
</audio>
|
||||
);
|
||||
@@ -642,6 +643,13 @@ function LotusDenoiseFeature() {
|
||||
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) {
|
||||
return (
|
||||
<>
|
||||
@@ -656,6 +664,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
|
||||
<TauriDesktopFeatures />
|
||||
<LotusDenoiseFeature />
|
||||
<DeepLinkNavigator />
|
||||
<KeyboardShortcutsFeature />
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user