mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-27 20:29:09 +00:00
♻️(frontend) use refs instead of queryselector for panels
Replace document.querySelector with panelRef for robustness
This commit is contained in:
@@ -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"
|
||||
|
||||
+37
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user