From 4748b55089e24938e25b0dfa63e7acf44fc304a9 Mon Sep 17 00:00:00 2001 From: Cyril Date: Fri, 20 Feb 2026 11:50:53 +0100 Subject: [PATCH] =?UTF-8?q?=E2=99=BB=EF=B8=8F(frontend)=20use=20react=20ar?= =?UTF-8?q?ia=20for=20side=20panel=20a11y?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit leverage FocusScope for tab containment, escape and focus restore --- CHANGELOG.md | 4 + .../rooms/livekit/components/SidePanel.tsx | 159 ++++++++++++------ .../rooms/livekit/components/Tools.tsx | 19 +-- .../rooms/livekit/components/chat/Input.tsx | 6 +- .../features/rooms/livekit/prefabs/Chat.tsx | 19 +-- 5 files changed, 120 insertions(+), 87 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 36971922..2d7aaedd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/frontend/src/features/rooms/livekit/components/SidePanel.tsx b/src/frontend/src/features/rooms/livekit/components/SidePanel.tsx index e10bc126..2d56a952 100644 --- a/src/frontend/src/features/rooms/livekit/components/SidePanel.tsx +++ b/src/frontend/src/features/rooms/livekit/components/SidePanel.tsx @@ -7,17 +7,20 @@ 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' import { Tools } from './Tools' import { Info } from './Info' import { HStack } from '@/styled-system/jsx' +import { FocusScope } from '@react-aria/focus' + +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 +32,6 @@ type StyledSidePanelProps = { const StyledSidePanel = ({ title, - ariaLabel, children, onClose, isClosed, @@ -38,7 +40,14 @@ const StyledSidePanel = ({ onBack, backButtonLabel, }: StyledSidePanelProps) => ( + // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- role="dialog" makes this interactive ) @@ -135,6 +146,7 @@ const Panel = ({ isOpen, keepAlive = false, children }: PanelProps) => ( {keepAlive || isOpen ? children : null} ) + export const SidePanel = () => { const { activePanelId, @@ -150,14 +162,53 @@ export const SidePanel = () => { } = useSidePanel() const { t } = useTranslation('rooms', { keyPrefix: 'sidePanel' }) + const triggerRef = useRef(null) + + // FocusScope handles Tab containment, but autoFocus/restoreFocus rely on mount/unmount + // lifecycle, which never happens here because the aside stays mounted (CSS slide + keepAlive). + // This effect manually captures the trigger on open and auto-focuses the close button. + // Restore focus is handled in handleClose with a double RAF to let FocusScope release containment. + + 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('#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('#room-options-trigger') + ?.focus({ preventScroll: true }) + } + }) + }) + } + return ( { - layoutStore.activePanelId = null - layoutStore.activeSubPanelId = null - }} + onClose={handleClose} closeButtonTooltip={t('closeButton', { content: t(`content.${activeSubPanelId || activePanelId}`), })} diff --git a/src/frontend/src/features/rooms/livekit/components/Tools.tsx b/src/frontend/src/features/rooms/livekit/components/Tools.tsx index d28d899b..9111d036 100644 --- a/src/frontend/src/features/rooms/livekit/components/Tools.tsx +++ b/src/frontend/src/features/rooms/livekit/components/Tools.tsx @@ -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('#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 ) diff --git a/src/frontend/src/features/rooms/livekit/components/chat/Input.tsx b/src/frontend/src/features/rooms/livekit/components/chat/Input.tsx index e3de0362..1c03a148 100644 --- a/src/frontend/src/features/rooms/livekit/components/chat/Input.tsx +++ b/src/frontend/src/features/rooms/livekit/components/chat/Input.tsx @@ -80,10 +80,12 @@ export const ChatInput = ({