diff --git a/src/frontend/src/features/rooms/livekit/components/Admin.tsx b/src/frontend/src/features/rooms/livekit/components/Admin.tsx index efe1090a..7697346a 100644 --- a/src/frontend/src/features/rooms/livekit/components/Admin.tsx +++ b/src/frontend/src/features/rooms/livekit/components/Admin.tsx @@ -10,9 +10,14 @@ import { keys } from '@/api/queryKeys' import { useQuery } from '@tanstack/react-query' import { useParams } from 'wouter' import { usePublishSourcesManager } from '@/features/rooms/livekit/hooks/usePublishSourcesManager' +import { useSidePanel } from '../hooks/useSidePanel' +import { useRestoreFocus } from '@/hooks/useRestoreFocus' +import { useSidePanelRef } from '../hooks/useSidePanelRef' export const Admin = () => { const { t } = useTranslation('rooms', { keyPrefix: 'admin' }) + const { isAdminOpen } = useSidePanel() + const panelRef = useSidePanelRef() const { roomId } = useParams() @@ -38,6 +43,34 @@ export const Admin = () => { isScreenShareEnabled, } = usePublishSourcesManager() + // Restore focus to the element that opened the Admin panel + useRestoreFocus(isAdminOpen, { + resolveTrigger: (activeEl) => { + // Find the Admin toggle button - it doesn't have a data-attr, so we'll search by aria-label + const adminButton = Array.from( + document.querySelectorAll('button') + ).find((btn) => btn.getAttribute('aria-label')?.includes('admin')) + return adminButton || activeEl + }, + // Focus the first focusable element when the panel opens (first Field switch) + onOpened: () => { + requestAnimationFrame(() => { + const panel = panelRef.current + if (panel) { + // Find the first switch in the moderation section + const firstSwitch = panel.querySelector( + '[role="switch"]:first-of-type' + ) + if (firstSwitch) { + firstSwitch.focus({ preventScroll: true }) + } + } + }) + }, + restoreFocusRaf: true, + preventScroll: true, + }) + return (
{ const { t } = useTranslation('rooms', { keyPrefix: 'info' }) + const { isInfoOpen } = useSidePanel() + const panelRef = useSidePanelRef() const data = useRoomData() const roomUrl = getRouteUrl('room', data?.slug) @@ -24,6 +29,30 @@ export const Info = () => { const { isCopied, copyRoomToClipboard } = useCopyRoomToClipboard(data) + // Restore focus to the element that opened the Info panel + useRestoreFocus(isInfoOpen, { + resolveTrigger: () => { + // Find the Info toggle button + return document.querySelector('[data-attr*="controls-info"]') + }, + // Focus the first focusable element when the panel opens + onOpened: () => { + requestAnimationFrame(() => { + const panel = panelRef.current + if (panel) { + const firstButton = panel.querySelector( + '[data-attr="copy-info-sidepannel"]' + ) + if (firstButton) { + firstButton.focus({ preventScroll: true }) + } + } + }) + }, + restoreFocusRaf: true, + preventScroll: true, + }) + return (
void + panelRef: React.RefObject backButtonLabel: string } @@ -36,9 +39,11 @@ const StyledSidePanel = ({ closeButtonTooltip, isSubmenu = false, onBack, + panelRef, backButtonLabel, }: StyledSidePanelProps) => (
) -export const SidePanel = () => { +const SidePanelContent = () => { const { activePanelId, isParticipantsOpen, @@ -149,6 +154,7 @@ export const SidePanel = () => { activeSubPanelId, } = useSidePanel() const { t } = useTranslation('rooms', { keyPrefix: 'sidePanel' }) + const panelRef = useSidePanelRef() return ( { isSubmenu={isSubPanelOpen} backButtonLabel={t('backToTools')} onBack={() => (layoutStore.activeSubPanelId = null)} + panelRef={panelRef} > - + @@ -178,12 +185,20 @@ export const SidePanel = () => { - + - + ) } + +export const SidePanel = () => { + return ( + + + + ) +} diff --git a/src/frontend/src/features/rooms/livekit/components/Tools.tsx b/src/frontend/src/features/rooms/livekit/components/Tools.tsx index d28d899b..c048de08 100644 --- a/src/frontend/src/features/rooms/livekit/components/Tools.tsx +++ b/src/frontend/src/features/rooms/livekit/components/Tools.tsx @@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next' import { ReactNode } from 'react' import { SubPanelId, useSidePanel } from '../hooks/useSidePanel' import { useRestoreFocus } from '@/hooks/useRestoreFocus' +import { useSidePanelRef } from '../hooks/useSidePanelRef' import { useIsRecordingModeEnabled, RecordingMode, @@ -98,6 +99,7 @@ export const Tools = () => { const { openTranscript, openScreenRecording, activeSubPanelId, isToolsOpen } = useSidePanel() const { t } = useTranslation('rooms', { keyPrefix: 'moreTools' }) + const panelRef = useSidePanelRef() // Restore focus to the element that opened the Tools panel // following the same pattern as Chat. @@ -111,6 +113,26 @@ export const Tools = () => { // For direct button clicks (e.g. "Plus d'outils"), use the active element as is return activeEl }, + // Focus the first focusable element when the panel opens + onOpened: () => { + requestAnimationFrame(() => { + const panel = panelRef.current + if (panel) { + // Find the first ToolButton in the tools list (transcript or screen recording button) + const toolsList = panel.querySelector( + '[data-attr="tools-list"]' + ) + if (toolsList) { + const firstToolButton = toolsList.querySelector( + 'button:first-of-type' + ) + if (firstToolButton) { + firstToolButton.focus({ preventScroll: true }) + } + } + } + }) + }, restoreFocusRaf: true, preventScroll: true, }) @@ -141,6 +163,7 @@ export const Tools = () => { flexDirection="column" alignItems="start" gap={0.5} + data-attr="tools-list" > { const { t } = useTranslation('rooms', { keyPrefix: 'participants' }) + const { isParticipantsOpen } = useSidePanel() + const panelRef = useSidePanelRef() // Preferred using the 'useParticipants' hook rather than the separate remote and local hooks, // because the 'useLocalParticipant' hook does not update the participant's information when their @@ -48,6 +53,38 @@ export const ParticipantsList = () => { const { waitingParticipants, handleParticipantEntry } = useWaitingParticipants() + // Restore focus to the element that opened the Participants panel + useRestoreFocus(isParticipantsOpen, { + resolveTrigger: (activeEl) => { + // Find the Participants toggle button + return ( + document.querySelector( + '[data-attr*="controls-participants"]' + ) || activeEl + ) + }, + // Focus the first focusable element when the panel opens + onOpened: () => { + // Use setTimeout + RAF to ensure DOM is fully rendered and transition completed + setTimeout(() => { + requestAnimationFrame(() => { + const panel = panelRef.current + if (panel) { + // Find the first ToggleHeader (collapsable list header) in the participants panel + // Look for buttons with aria-label containing "liste" (list headers) + // Exclude close/back buttons + const firstListHeader = panel.querySelector( + 'button[aria-label*="liste"]:not([aria-label*="Masquer les participants"])' + ) + firstListHeader?.focus({ preventScroll: true }) + } + }) + }, 100) // Wait for panel slide-in animation to complete + }, + restoreFocusRaf: true, + preventScroll: true, + }) + // TODO - extract inline styling in a centralized styling file, and avoid magic numbers return (
diff --git a/src/frontend/src/features/rooms/livekit/components/effects/Effects.tsx b/src/frontend/src/features/rooms/livekit/components/effects/Effects.tsx index c6989520..cfa9eaea 100644 --- a/src/frontend/src/features/rooms/livekit/components/effects/Effects.tsx +++ b/src/frontend/src/features/rooms/livekit/components/effects/Effects.tsx @@ -7,12 +7,14 @@ import { useCanPublishTrack } from '@/features/rooms/livekit/hooks/useCanPublish import { TrackSource } from '@livekit/protocol' import { useSidePanel } from '../../hooks/useSidePanel' import { useRestoreFocus } from '@/hooks/useRestoreFocus' +import { useSidePanelRef } from '../../hooks/useSidePanelRef' export const Effects = () => { const { cameraTrack } = useLocalParticipant() const localCameraTrack = cameraTrack?.track as LocalVideoTrack const { saveProcessorSerialized } = usePersistentUserChoices() const { isEffectsOpen } = useSidePanel() + const panelRef = useSidePanelRef() const canPublishCamera = useCanPublishTrack(TrackSource.CAMERA) @@ -27,12 +29,15 @@ export const Effects = () => { // Focus the first focusable element when the panel opens onOpened: () => { requestAnimationFrame(() => { - // Find the first toggle button (blur light button) - const firstButton = document.querySelector( - '[data-attr="toggle-blur-light"]' - ) - if (firstButton) { - firstButton.focus({ preventScroll: true }) + const panel = panelRef.current + if (panel) { + // Find the first toggle button (blur light button) + const firstButton = panel.querySelector( + '[data-attr="toggle-blur-light"]' + ) + if (firstButton) { + firstButton.focus({ preventScroll: true }) + } } }) }, diff --git a/src/frontend/src/features/rooms/livekit/contexts/SidePanelContext.tsx b/src/frontend/src/features/rooms/livekit/contexts/SidePanelContext.tsx new file mode 100644 index 00000000..1d39f778 --- /dev/null +++ b/src/frontend/src/features/rooms/livekit/contexts/SidePanelContext.tsx @@ -0,0 +1,12 @@ +import { useRef, ReactNode } from 'react' +import { SidePanelContext } from './sidePanelContextValue' + +export const SidePanelProvider = ({ children }: { children: ReactNode }) => { + const panelRef = useRef(null) + + return ( + + {children} + + ) +} diff --git a/src/frontend/src/features/rooms/livekit/contexts/sidePanelContextValue.ts b/src/frontend/src/features/rooms/livekit/contexts/sidePanelContextValue.ts new file mode 100644 index 00000000..15b87ec0 --- /dev/null +++ b/src/frontend/src/features/rooms/livekit/contexts/sidePanelContextValue.ts @@ -0,0 +1,9 @@ +import { createContext } from 'react' + +export type SidePanelContextValue = { + panelRef: React.RefObject +} + +export const SidePanelContext = createContext( + null +) diff --git a/src/frontend/src/features/rooms/livekit/hooks/useSidePanelRef.ts b/src/frontend/src/features/rooms/livekit/hooks/useSidePanelRef.ts new file mode 100644 index 00000000..cbdcab5d --- /dev/null +++ b/src/frontend/src/features/rooms/livekit/hooks/useSidePanelRef.ts @@ -0,0 +1,10 @@ +import { useContext } from 'react' +import { SidePanelContext } from '../contexts/sidePanelContextValue' + +export const useSidePanelRef = () => { + const context = useContext(SidePanelContext) + if (!context) { + throw new Error('useSidePanelRef must be used within SidePanelProvider') + } + return context.panelRef +}