Compare commits

...

2 Commits

Author SHA1 Message Date
Cyril 2861aa143e fixup! ♻️(frontend) use react aria for side panel a11y 2026-02-23 14:55:38 +01:00
Cyril 4748b55089 ♻️(frontend) use react aria for side panel a11y
leverage FocusScope for tab containment, escape and focus restore
2026-02-20 16:08:15 +01:00
5 changed files with 114 additions and 86 deletions
+4
View File
@@ -8,6 +8,10 @@ and this project adheres to
## [Unreleased]
### Changed
- ♻️(frontend) Use React Aria FocusScope for side panel accessibility
## [1.7.0] - 2026-02-19
### Added
@@ -7,7 +7,7 @@ import { RiArrowLeftLine, RiCloseLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { ParticipantsList } from './controls/Participants/ParticipantsList'
import { useSidePanel } from '../hooks/useSidePanel'
import { ReactNode } from 'react'
import { ReactNode, useEffect, useRef } from 'react'
import { Chat } from '../prefabs/Chat'
import { Effects } from './effects/Effects'
import { Admin } from './Admin'
@@ -15,9 +15,11 @@ import { Tools } from './Tools'
import { Info } from './Info'
import { HStack } from '@/styled-system/jsx'
const SIDE_PANEL_HEADING_ID = 'side-panel-heading'
const SIDE_PANEL_CLOSE_ID = 'side-panel-close'
type StyledSidePanelProps = {
title: string
ariaLabel: string
children: ReactNode
onClose: () => void
isClosed: boolean
@@ -29,7 +31,6 @@ type StyledSidePanelProps = {
const StyledSidePanel = ({
title,
ariaLabel,
children,
onClose,
isClosed,
@@ -38,7 +39,14 @@ const StyledSidePanel = ({
onBack,
backButtonLabel,
}: StyledSidePanelProps) => (
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- role="dialog" makes this interactive
<aside
role="dialog"
aria-labelledby={!isClosed ? SIDE_PANEL_HEADING_ID : undefined}
aria-hidden={isClosed || undefined}
onKeyDown={(e) => {
if (e.key === 'Escape') onClose()
}}
className={css({
borderWidth: '1px',
borderStyle: 'solid',
@@ -63,57 +71,57 @@ const StyledSidePanel = ({
style={{
transform: isClosed ? 'translateX(calc(360px + 1.5rem))' : 'none',
}}
aria-hidden={isClosed}
aria-label={ariaLabel}
>
<HStack alignItems="center">
{isSubmenu && (
<Button
variant="secondaryText"
size="sm"
square
className={css({ marginRight: '0.5rem', marginLeft: '1rem' })}
aria-label={backButtonLabel}
onPress={onBack}
{isSubmenu && (
<Button
variant="secondaryText"
size="sm"
square
className={css({ marginRight: '0.5rem', marginLeft: '1rem' })}
aria-label={backButtonLabel}
onPress={onBack}
>
<RiArrowLeftLine size={20} aria-hidden="true" />
</Button>
)}
<Heading
id={SIDE_PANEL_HEADING_ID}
slot="title"
level={1}
className={text({ variant: 'h2' })}
style={{
paddingLeft: isSubmenu ? 0 : '1.5rem',
paddingTop: '1rem',
display: isClosed ? 'none' : 'flex',
justifyContent: 'start',
alignItems: 'center',
}}
>
<RiArrowLeftLine size={20} aria-hidden="true" />
</Button>
)}
<Heading
slot="title"
level={1}
className={text({ variant: 'h2' })}
{title}
</Heading>
</HStack>
<Div
position="absolute"
top="5"
right="5"
style={{
paddingLeft: isSubmenu ? 0 : '1.5rem',
paddingTop: '1rem',
display: isClosed ? 'none' : 'flex',
justifyContent: 'start',
alignItems: 'center',
display: isClosed ? 'none' : undefined,
}}
>
{title}
</Heading>
</HStack>
<Div
position="absolute"
top="5"
right="5"
style={{
display: isClosed ? 'none' : undefined,
}}
>
<Button
invisible
variant="tertiaryText"
size="xs"
onPress={onClose}
aria-label={closeButtonTooltip}
tooltip={closeButtonTooltip}
>
<RiCloseLine />
</Button>
</Div>
{children}
<Button
id={SIDE_PANEL_CLOSE_ID}
invisible
variant="tertiaryText"
size="xs"
onPress={onClose}
aria-label={closeButtonTooltip}
tooltip={closeButtonTooltip}
>
<RiCloseLine />
</Button>
</Div>
{children}
</aside>
)
@@ -135,6 +143,7 @@ const Panel = ({ isOpen, keepAlive = false, children }: PanelProps) => (
{keepAlive || isOpen ? children : null}
</div>
)
export const SidePanel = () => {
const {
activePanelId,
@@ -150,14 +159,51 @@ export const SidePanel = () => {
} = useSidePanel()
const { t } = useTranslation('rooms', { keyPrefix: 'sidePanel' })
const triggerRef = useRef<HTMLElement | null>(null)
// The aside stays mounted (CSS slide + keepAlive), so we manually handle
// auto-focus on open and restore focus on close (via handleClose).
useEffect(() => {
if (!isSidePanelOpen) return
const active = document.activeElement as HTMLElement
// Menu items render as DIVs that unmount when the menu closes — resolve to the menu trigger
triggerRef.current =
active?.tagName === 'DIV'
? (document.querySelector<HTMLElement>('#room-options-trigger') ??
active)
: active
requestAnimationFrame(() => {
const closeBtn = document.getElementById(SIDE_PANEL_CLOSE_ID)
// Skip if a child panel already moved focus inside (e.g. Chat input)
if (closeBtn?.closest('aside')?.contains(document.activeElement)) return
closeBtn?.focus({ preventScroll: true })
})
}, [isSidePanelOpen])
const handleClose = () => {
const trigger = triggerRef.current
triggerRef.current = null
layoutStore.activePanelId = null
layoutStore.activeSubPanelId = null
// Double RAF: first lets React re-render, second lets FocusScope release containment
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (trigger?.isConnected) {
trigger.focus({ preventScroll: true })
} else {
document
.querySelector<HTMLElement>('#room-options-trigger')
?.focus({ preventScroll: true })
}
})
})
}
return (
<StyledSidePanel
title={t(`heading.${activeSubPanelId || activePanelId}`)}
ariaLabel={t('ariaLabel')}
onClose={() => {
layoutStore.activePanelId = null
layoutStore.activeSubPanelId = null
}}
onClose={handleClose}
closeButtonTooltip={t('closeButton', {
content: t(`content.${activeSubPanelId || activePanelId}`),
})}
@@ -4,7 +4,6 @@ import { Button as RACButton } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import { ReactNode } from 'react'
import { SubPanelId, useSidePanel } from '../hooks/useSidePanel'
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
import {
useIsRecordingModeEnabled,
RecordingMode,
@@ -95,26 +94,10 @@ const ToolButton = ({
export const Tools = () => {
const { data } = useConfig()
const { openTranscript, openScreenRecording, activeSubPanelId, isToolsOpen } =
const { openTranscript, openScreenRecording, activeSubPanelId } =
useSidePanel()
const { t } = useTranslation('rooms', { keyPrefix: 'moreTools' })
// Restore focus to the element that opened the Tools panel
// following the same pattern as Chat.
useRestoreFocus(isToolsOpen, {
// If the active element is a MenuItem (DIV) that will be unmounted when the menu closes,
// find the "more options" button ("Plus d'options") that opened the menu
resolveTrigger: (activeEl) => {
if (activeEl?.tagName === 'DIV') {
return document.querySelector<HTMLElement>('#room-options-trigger')
}
// For direct button clicks (e.g. "Plus d'outils"), use the active element as is
return activeEl
},
restoreFocusRaf: true,
preventScroll: true,
})
const isTranscriptEnabled = useIsRecordingModeEnabled(
RecordingMode.Transcript
)
@@ -80,10 +80,12 @@ export const ChatInput = ({
<TextArea
ref={inputRef}
onKeyDown={(e) => {
e.stopPropagation()
if (e.key !== 'Escape') e.stopPropagation()
submitOnEnter(e)
}}
onKeyUp={(e) => e.stopPropagation()}
onKeyUp={(e) => {
if (e.key !== 'Escape') e.stopPropagation()
}}
placeholder={t('textArea.placeholder')}
value={text}
onChange={(e) => {
@@ -15,7 +15,6 @@ import { ChatEntry } from '../components/chat/Entry'
import { useSidePanel } from '../hooks/useSidePanel'
import { LocalParticipant, RemoteParticipant, RoomEvent } from 'livekit-client'
import { css } from '@/styled-system/css'
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
export interface ChatProps
extends React.HTMLAttributes<HTMLDivElement>, ChatOptions {}
@@ -36,18 +35,12 @@ export function Chat({ ...props }: ChatProps) {
const { isChatOpen } = useSidePanel()
const chatSnap = useSnapshot(chatStore)
// Keep track of the element that opened the chat so we can restore focus
// when the chat panel is closed.
useRestoreFocus(isChatOpen, {
// Avoid layout "jump" during the side panel slide-in animation.
// Focusing can trigger scroll into view; preventScroll keeps the animation smooth.
onOpened: () => {
requestAnimationFrame(() => {
inputRef.current?.focus({ preventScroll: true })
})
},
preventScroll: true,
})
React.useEffect(() => {
if (!isChatOpen) return
requestAnimationFrame(() => {
inputRef.current?.focus({ preventScroll: true })
})
}, [isChatOpen])
// Use useParticipants hook to trigger a re-render when the participant list changes.
const participants = useParticipants()