Compare commits

...

2 Commits

Author SHA1 Message Date
Cyril f4fed231b8 ️(frontend) announce Escape close hint on side panels
Show "(Escape)" in close tooltip, add aria-describedby for SR keyboard shortcut hint.
2026-07-13 14:58:00 +02:00
Cyril 72af826402 ️(frontend) close side panel with Escape key
useEscapeToClose: close panel on Escape, restore focus, let chat input bubble
2026-07-13 14:02:41 +02:00
8 changed files with 58 additions and 12 deletions
+1
View File
@@ -24,6 +24,7 @@ and this project adheres to
- ♻️(frontend) inline model weights to avoid loading them from remote - ♻️(frontend) inline model weights to avoid loading them from remote
- ♻️(frontend) inline MediaPipe WASM modules to avoid loading from remote - ♻️(frontend) inline MediaPipe WASM modules to avoid loading from remote
- ⬆️(frontend) upgrade posthog-js from 1.387.0 to 1.391.2 - ⬆️(frontend) upgrade posthog-js from 1.387.0 to 1.391.2
- ♿️(frontend) close side panel with Escape key #1507
### Fixed ### Fixed
@@ -16,6 +16,8 @@ import { Info } from './Info'
import { HStack } from '@/styled-system/jsx' import { HStack } from '@/styled-system/jsx'
import { useReactionsToolbar } from '@/features/reactions/hooks/useReactionsToolbar' import { useReactionsToolbar } from '@/features/reactions/hooks/useReactionsToolbar'
import { useRestoreFocus } from '@/hooks/useRestoreFocus' import { useRestoreFocus } from '@/hooks/useRestoreFocus'
import { useEscapeToClose } from '@/hooks/useEscapeToClose'
import { srOnly } from '@/styles/a11y'
type StyledSidePanelProps = { type StyledSidePanelProps = {
title: string title: string
@@ -24,6 +26,7 @@ type StyledSidePanelProps = {
onClose: () => void onClose: () => void
isClosed: boolean isClosed: boolean
closeButtonTooltip: string closeButtonTooltip: string
escapeHint: string
isSubmenu: boolean isSubmenu: boolean
onBack: () => void onBack: () => void
backButtonLabel: string backButtonLabel: string
@@ -40,6 +43,7 @@ const StyledSidePanel = React.forwardRef<HTMLElement, StyledSidePanelProps>(
isClosed, isClosed,
isReactionToolbarOpen, isReactionToolbarOpen,
closeButtonTooltip, closeButtonTooltip,
escapeHint,
isSubmenu = false, isSubmenu = false,
onBack, onBack,
backButtonLabel, backButtonLabel,
@@ -84,7 +88,11 @@ const StyledSidePanel = React.forwardRef<HTMLElement, StyledSidePanelProps>(
}} }}
aria-hidden={isClosed} aria-hidden={isClosed}
aria-label={ariaLabel} aria-label={ariaLabel}
aria-describedby="side-panel-escape-hint"
> >
<span id="side-panel-escape-hint" className={srOnly}>
{escapeHint}
</span>
<HStack alignItems="center"> <HStack alignItems="center">
{isSubmenu && ( {isSubmenu && (
<Button <Button
@@ -188,24 +196,29 @@ export const SidePanel = () => {
focusAside() focusAside()
}, [activePanelId, focusAside]) }, [activePanelId, focusAside])
const closePanel = useCallback(() => {
layoutStore.activePanelId = null
layoutStore.activeSubPanelId = null
}, [])
useRestoreFocus(isSidePanelOpen, { useRestoreFocus(isSidePanelOpen, {
onOpened: handlePanelOpened, onOpened: handlePanelOpened,
preventScroll: true, preventScroll: true,
activeKey: activePanelId, activeKey: activePanelId,
}) })
useEscapeToClose(isSidePanelOpen, asideRef, closePanel)
return ( return (
<StyledSidePanel <StyledSidePanel
ref={asideRef} ref={asideRef}
title={title} title={title}
ariaLabel={t('ariaLabel', { title })} ariaLabel={t('ariaLabel', { title })}
onClose={() => { onClose={closePanel}
layoutStore.activePanelId = null
layoutStore.activeSubPanelId = null
}}
closeButtonTooltip={t('closeButton', { closeButtonTooltip={t('closeButton', {
content: t(`content.${activeSubPanelId || activePanelId}`), content: t(`content.${activeSubPanelId || activePanelId}`),
})} })}
escapeHint={t('escapeHint')}
isClosed={!isSidePanelOpen} isClosed={!isSidePanelOpen}
isSubmenu={isSubPanelOpen} isSubmenu={isSubPanelOpen}
isReactionToolbarOpen={isReactionToolbarOpen} isReactionToolbarOpen={isReactionToolbarOpen}
@@ -35,6 +35,11 @@ export const ChatInput = ({
if (!isDisabled) handleSubmit() if (!isDisabled) handleSubmit()
} }
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key !== 'Escape') e.stopPropagation()
submitOnEnter(e)
}
useEffect(() => { useEffect(() => {
const resize = () => { const resize = () => {
if (!inputRef.current) return if (!inputRef.current) return
@@ -79,10 +84,7 @@ export const ChatInput = ({
> >
<TextArea <TextArea
ref={inputRef} ref={inputRef}
onKeyDown={(e) => { onKeyDown={handleKeyDown}
e.stopPropagation()
submitOnEnter(e)
}}
onKeyUp={(e) => e.stopPropagation()} onKeyUp={(e) => e.stopPropagation()}
placeholder={t('textArea.placeholder')} placeholder={t('textArea.placeholder')}
value={text} value={text}
@@ -0,0 +1,26 @@
import { useEffect, useRef, type RefObject } from 'react'
export const useEscapeToClose = (
isActive: boolean,
containerRef: RefObject<HTMLElement | null>,
onClose: () => void
) => {
const onCloseRef = useRef(onClose)
useEffect(() => {
onCloseRef.current = onClose
})
useEffect(() => {
if (!isActive) return
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return
if (!containerRef.current?.contains(document.activeElement)) return
e.stopPropagation()
onCloseRef.current()
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [isActive, containerRef])
}
+2 -1
View File
@@ -361,7 +361,8 @@
"tools": "Weitere Tools", "tools": "Weitere Tools",
"info": "Meeting-Informationen" "info": "Meeting-Informationen"
}, },
"closeButton": "{{content}} ausblenden" "closeButton": "{{content}} ausblenden (Escape)",
"escapeHint": "Escape drücken zum Schließen"
}, },
"chat": { "chat": {
"disclaimer": "Die Nachrichten sind nur für Teilnehmende zum Zeitpunkt des Sendens sichtbar. Alle Nachrichten werden am Ende des Meetings gelöscht.", "disclaimer": "Die Nachrichten sind nur für Teilnehmende zum Zeitpunkt des Sendens sichtbar. Alle Nachrichten werden am Ende des Meetings gelöscht.",
+2 -1
View File
@@ -360,7 +360,8 @@
"tools": "more tools", "tools": "more tools",
"info": "meeting information" "info": "meeting information"
}, },
"closeButton": "Hide {{content}}" "closeButton": "Hide {{content}} (Escape)",
"escapeHint": "Press Escape to close"
}, },
"chat": { "chat": {
"disclaimer": "The messages are visible to participants only at the time they are sent. All messages are deleted at the end of the call.", "disclaimer": "The messages are visible to participants only at the time they are sent. All messages are deleted at the end of the call.",
+2 -1
View File
@@ -360,7 +360,8 @@
"tools": "outils de réunion", "tools": "outils de réunion",
"info": "informations sur la réunion" "info": "informations sur la réunion"
}, },
"closeButton": "Masquer {{content}}" "closeButton": "Masquer {{content}} (Échap)",
"escapeHint": "Appuyez sur Échap pour fermer"
}, },
"chat": { "chat": {
"disclaimer": "Les messages sont visibles par les participants uniquement au moment de\nleur envoi. Tous les messages sont supprimés à la fin de l'appel.", "disclaimer": "Les messages sont visibles par les participants uniquement au moment de\nleur envoi. Tous les messages sont supprimés à la fin de l'appel.",
+2 -1
View File
@@ -360,7 +360,8 @@
"tools": "meer tools", "tools": "meer tools",
"info": "vergaderinformatie" "info": "vergaderinformatie"
}, },
"closeButton": "Verberg {{content}}" "closeButton": "Verberg {{content}} (Escape)",
"escapeHint": "Druk op Escape om te sluiten"
}, },
"chat": { "chat": {
"disclaimer": "De berichten zijn alleen voor de deelnemers zichtbaar op het moment dat ze worden verzonden. Alle berichten worden verwijderd aan het einde van het gesprek.", "disclaimer": "De berichten zijn alleen voor de deelnemers zichtbaar op het moment dat ze worden verzonden. Alle berichten worden verwijderd aan het einde van het gesprek.",