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 = ({