diff --git a/src/app/components/room-intro/RoomIntro.tsx b/src/app/components/room-intro/RoomIntro.tsx index b509c3d78..a3a22f224 100644 --- a/src/app/components/room-intro/RoomIntro.tsx +++ b/src/app/components/room-intro/RoomIntro.tsx @@ -95,7 +95,6 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) => setViewTopic(false), escapeDeactivates: stopPropagation, diff --git a/src/app/features/room/RoomViewHeader.tsx b/src/app/features/room/RoomViewHeader.tsx index a6399cfa0..e3e0f77f2 100644 --- a/src/app/features/room/RoomViewHeader.tsx +++ b/src/app/features/room/RoomViewHeader.tsx @@ -583,7 +583,6 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) { setViewTopic(false), escapeDeactivates: stopPropagation, diff --git a/src/app/features/room/RoomViewTyping.css.ts b/src/app/features/room/RoomViewTyping.css.ts index 5c90a178b..8e97fe259 100644 --- a/src/app/features/room/RoomViewTyping.css.ts +++ b/src/app/features/room/RoomViewTyping.css.ts @@ -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, +}); diff --git a/src/app/features/room/RoomViewTyping.tsx b/src/app/features/room/RoomViewTyping.tsx index 3f9149de5..2ff4bbc12 100644 --- a/src/app/features/room/RoomViewTyping.tsx +++ b/src/app/features/room/RoomViewTyping.tsx @@ -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 ( -
+
+ + {typingAnnouncement} + ( ref={ref} > - + {typingNames.length === 1 && ( <> {typingNames[0]} diff --git a/src/app/features/room/message/Reactions.tsx b/src/app/features/room/message/Reactions.tsx index 6481e095f..26e06b6ee 100644 --- a/src/app/features/room/message/Reactions.tsx +++ b/src/app/features/room/message/Reactions.tsx @@ -106,7 +106,6 @@ export const Reactions = as<'div', ReactionsProps>( setViewer(false), clickOutsideDeactivates: true, escapeDeactivates: stopPropagation, diff --git a/src/app/features/shortcuts/KeyboardShortcutsDialog.css.ts b/src/app/features/shortcuts/KeyboardShortcutsDialog.css.ts new file mode 100644 index 000000000..167db3c8f --- /dev/null +++ b/src/app/features/shortcuts/KeyboardShortcutsDialog.css.ts @@ -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, + }, +]); diff --git a/src/app/features/shortcuts/KeyboardShortcutsDialog.tsx b/src/app/features/shortcuts/KeyboardShortcutsDialog.tsx new file mode 100644 index 000000000..e89f2e9b9 --- /dev/null +++ b/src/app/features/shortcuts/KeyboardShortcutsDialog.tsx @@ -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(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 ( + + {keys.map((key, index) => ( + + {key} + + ))} + + ); +} + +/** + * 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 ( + }> + + + +
+ + + Keyboard Shortcuts + + + + + +
+ + + {sections.map((section, sectionIndex) => ( + + {sectionIndex > 0 && } + + {section.title} + + + {section.rows.map((row) => ( + + + {row.description} + + + + ))} + + + ))} + + {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.'} + + + +
+
+
+
+ ); +} diff --git a/src/app/features/shortcuts/index.ts b/src/app/features/shortcuts/index.ts new file mode 100644 index 000000000..75e3f7bf9 --- /dev/null +++ b/src/app/features/shortcuts/index.ts @@ -0,0 +1 @@ +export * from './KeyboardShortcutsDialog'; diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 750e9d20a..fba8ad33f 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -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 ( -