♻️(frontend) use refs instead of queryselector for panels

Replace document.querySelector with panelRef for robustness
This commit is contained in:
Cyril
2026-01-15 11:49:02 +01:00
parent 4fbcf8d64e
commit 17602ab548
9 changed files with 183 additions and 10 deletions
@@ -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<HTMLElement>('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<HTMLElement>(
'[role="switch"]:first-of-type'
)
if (firstSwitch) {
firstSwitch.focus({ preventScroll: true })
}
}
})
},
restoreFocusRaf: true,
preventScroll: true,
})
return (
<Div
display="flex"
@@ -9,9 +9,14 @@ import { useRoomData } from '../hooks/useRoomData'
import { formatPinCode } from '../../utils/telephony'
import { useTelephony } from '../hooks/useTelephony'
import { useCopyRoomToClipboard } from '../hooks/useCopyRoomToClipboard'
import { useSidePanel } from '../hooks/useSidePanel'
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
import { useSidePanelRef } from '../hooks/useSidePanelRef'
export const Info = () => {
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<HTMLElement>('[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<HTMLElement>(
'[data-attr="copy-info-sidepannel"]'
)
if (firstButton) {
firstButton.focus({ preventScroll: true })
}
}
})
},
restoreFocusRaf: true,
preventScroll: true,
})
return (
<Div
display="flex"
@@ -13,6 +13,8 @@ import { Effects } from './effects/Effects'
import { Admin } from './Admin'
import { Tools } from './Tools'
import { Info } from './Info'
import { SidePanelProvider } from '../contexts/SidePanelContext'
import { useSidePanelRef } from '../hooks/useSidePanelRef'
import { HStack } from '@/styled-system/jsx'
type StyledSidePanelProps = {
@@ -24,6 +26,7 @@ type StyledSidePanelProps = {
closeButtonTooltip: string
isSubmenu: boolean
onBack: () => void
panelRef: React.RefObject<HTMLElement>
backButtonLabel: string
}
@@ -36,9 +39,11 @@ const StyledSidePanel = ({
closeButtonTooltip,
isSubmenu = false,
onBack,
panelRef,
backButtonLabel,
}: StyledSidePanelProps) => (
<aside
ref={panelRef}
className={css({
borderWidth: '1px',
borderStyle: 'solid',
@@ -135,7 +140,7 @@ const Panel = ({ isOpen, keepAlive = false, children }: PanelProps) => (
{keepAlive || isOpen ? children : null}
</div>
)
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 (
<StyledSidePanel
@@ -165,8 +171,9 @@ export const SidePanel = () => {
isSubmenu={isSubPanelOpen}
backButtonLabel={t('backToTools')}
onBack={() => (layoutStore.activeSubPanelId = null)}
panelRef={panelRef}
>
<Panel isOpen={isParticipantsOpen}>
<Panel isOpen={isParticipantsOpen} keepAlive={true}>
<ParticipantsList />
</Panel>
<Panel isOpen={isEffectsOpen} keepAlive={true}>
@@ -178,12 +185,20 @@ export const SidePanel = () => {
<Panel isOpen={isToolsOpen} keepAlive={true}>
<Tools />
</Panel>
<Panel isOpen={isAdminOpen}>
<Panel isOpen={isAdminOpen} keepAlive={true}>
<Admin />
</Panel>
<Panel isOpen={isInfoOpen}>
<Panel isOpen={isInfoOpen} keepAlive={true}>
<Info />
</Panel>
</StyledSidePanel>
)
}
export const SidePanel = () => {
return (
<SidePanelProvider>
<SidePanelContent />
</SidePanelProvider>
)
}
@@ -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<HTMLElement>(
'[data-attr="tools-list"]'
)
if (toolsList) {
const firstToolButton = toolsList.querySelector<HTMLElement>(
'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"
>
<Text
variant="note"
@@ -12,10 +12,15 @@ import { useWaitingParticipants } from '@/features/rooms/hooks/useWaitingPartici
import { Participant } from 'livekit-client'
import { WaitingParticipant } from '@/features/rooms/api/listWaitingParticipants'
import { MuteEveryoneButton } from './MuteEveryoneButton'
import { useSidePanel } from '../../../hooks/useSidePanel'
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
import { useSidePanelRef } from '../../../hooks/useSidePanelRef'
// TODO: Optimize rendering performance, especially for longer participant lists, even though they are generally short.
export const ParticipantsList = () => {
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<HTMLElement>(
'[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<HTMLElement>(
'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 (
<Div overflowY="scroll">
@@ -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<HTMLElement>(
'[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<HTMLElement>(
'[data-attr="toggle-blur-light"]'
)
if (firstButton) {
firstButton.focus({ preventScroll: true })
}
}
})
},
@@ -0,0 +1,12 @@
import { useRef, ReactNode } from 'react'
import { SidePanelContext } from './sidePanelContextValue'
export const SidePanelProvider = ({ children }: { children: ReactNode }) => {
const panelRef = useRef<HTMLElement>(null)
return (
<SidePanelContext.Provider value={{ panelRef }}>
{children}
</SidePanelContext.Provider>
)
}
@@ -0,0 +1,9 @@
import { createContext } from 'react'
export type SidePanelContextValue = {
panelRef: React.RefObject<HTMLElement>
}
export const SidePanelContext = createContext<SidePanelContextValue | null>(
null
)
@@ -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
}