From daa3d6bcc6aa8515fa52112ec010f45fd1b830a8 Mon Sep 17 00:00:00 2001 From: Cyril Date: Thu, 29 Jan 2026 10:55:31 +0100 Subject: [PATCH] =?UTF-8?q?=E2=99=BF=EF=B8=8F(frontend)=20centralize=20pop?= =?UTF-8?q?over=20focus=20and=20reuse=20side=20panel=20close?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit move popover focus handling into primitive and reuse close helper --- .../rooms/livekit/components/AdminToggle.tsx | 11 +-- .../rooms/livekit/components/SidePanel.tsx | 13 +-- .../controls/Device/AudioDevicesControl.tsx | 14 ++-- .../controls/Device/VideoDeviceControl.tsx | 24 +++--- .../controls/Device/deviceFocusSelectors.ts | 6 ++ .../components/controls/InfoToggle.tsx | 11 +-- .../controls/Options/OptionsButton.tsx | 11 +-- .../Participants/ParticipantsToggle.tsx | 11 +-- .../components/controls/ToolsToggle.tsx | 11 +-- src/frontend/src/primitives/Popover.tsx | 81 ++++++++++++++++++- 10 files changed, 111 insertions(+), 82 deletions(-) create mode 100644 src/frontend/src/features/rooms/livekit/components/controls/Device/deviceFocusSelectors.ts diff --git a/src/frontend/src/features/rooms/livekit/components/AdminToggle.tsx b/src/frontend/src/features/rooms/livekit/components/AdminToggle.tsx index 8b25bd79..18f71e01 100644 --- a/src/frontend/src/features/rooms/livekit/components/AdminToggle.tsx +++ b/src/frontend/src/features/rooms/livekit/components/AdminToggle.tsx @@ -1,4 +1,3 @@ -import { useCallback } from 'react' import { ToggleButton } from '@/primitives' import { RiAdminLine } from '@remixicon/react' import { useTranslation } from 'react-i18next' @@ -6,7 +5,7 @@ import { css } from '@/styled-system/css' import { ToggleButtonProps } from '@/primitives/ToggleButton' import { useIsAdminOrOwner } from '../hooks/useIsAdminOrOwner' import { useSidePanel } from '../hooks/useSidePanel' -import { useSidePanelTriggers } from '../hooks/useSidePanelTriggers' +import { useSidePanelTriggerRef } from '../hooks/useSidePanelTriggerRef' export const AdminToggle = ({ variant = 'primaryTextDark', @@ -16,14 +15,8 @@ export const AdminToggle = ({ const { t } = useTranslation('rooms', { keyPrefix: 'controls.admin' }) const { isAdminOpen, toggleAdmin } = useSidePanel() - const { setTrigger } = useSidePanelTriggers() const tooltipLabel = isAdminOpen ? 'open' : 'closed' - const setAdminTriggerRef = useCallback( - (el: HTMLElement | null) => { - setTrigger('admin', el) - }, - [setTrigger] - ) + const setAdminTriggerRef = useSidePanelTriggerRef('admin') const hasAdminAccess = useIsAdminOrOwner() if (!hasAdminAccess) return null diff --git a/src/frontend/src/features/rooms/livekit/components/SidePanel.tsx b/src/frontend/src/features/rooms/livekit/components/SidePanel.tsx index af62af9d..2e991532 100644 --- a/src/frontend/src/features/rooms/livekit/components/SidePanel.tsx +++ b/src/frontend/src/features/rooms/livekit/components/SidePanel.tsx @@ -152,6 +152,7 @@ const SidePanelContent = () => { isInfoOpen, isSubPanelOpen, activeSubPanelId, + closeSidePanel, } = useSidePanel() const { t } = useTranslation('rooms', { keyPrefix: 'sidePanel' }) const panelRef = useSidePanelRef() @@ -159,12 +160,7 @@ const SidePanelContent = () => { useEscapeKey( () => { // Close subpanel + panel together for a consistent Escape behavior - if (isSubPanelOpen) { - layoutStore.activeSubPanelId = null - layoutStore.activePanelId = null - return - } - layoutStore.activePanelId = null + closeSidePanel() }, { isActive: isSidePanelOpen, @@ -176,10 +172,7 @@ const SidePanelContent = () => { { - layoutStore.activePanelId = null - layoutStore.activeSubPanelId = null - }} + onClose={closeSidePanel} closeButtonTooltip={t('closeButton', { content: t(`content.${activeSubPanelId || activePanelId}`), })} diff --git a/src/frontend/src/features/rooms/livekit/components/controls/Device/AudioDevicesControl.tsx b/src/frontend/src/features/rooms/livekit/components/controls/Device/AudioDevicesControl.tsx index 3da291cd..b7a226e1 100644 --- a/src/frontend/src/features/rooms/livekit/components/controls/Device/AudioDevicesControl.tsx +++ b/src/frontend/src/features/rooms/livekit/components/controls/Device/AudioDevicesControl.tsx @@ -16,7 +16,7 @@ import { SettingsDialogExtendedKey } from '@/features/settings/type' import { TrackSource } from '@livekit/protocol' import Source = Track.Source import { isSafari } from '@/utils/livekit' -import { useFocusOnOpen } from '@/hooks/useFocusOnOpen' +import { AUDIO_INPUT_FOCUS_SELECTOR } from './deviceFocusSelectors' type AudioDevicesControlProps = Omit< UseTrackToggleProps, @@ -31,7 +31,6 @@ export const AudioDevicesControl = ({ }: AudioDevicesControlProps) => { const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' }) const [isMenuOpen, setIsMenuOpen] = React.useState(false) - const popoverContentRef = React.useRef(null) const { userChoices: { audioDeviceId, audioOutputDeviceId }, @@ -58,12 +57,6 @@ export const AudioDevicesControl = ({ const canPublishTrack = useCanPublishTrack(TrackSource.MICROPHONE) - useFocusOnOpen(isMenuOpen, popoverContentRef, { - selector: - '[data-attr="audio-input-select"] button, [data-attr="audio-input-select"] [role="combobox"]', - delayMs: 250, - }) - return (
{({ close }) => (
{ - setTrigger('info', el) - }, - [setTrigger] - ) + const setInfoTriggerRef = useSidePanelTriggerRef('info') return (
{ const { t } = useTranslation('rooms') - const { setTrigger } = useSidePanelTriggers() - const setOptionsTriggerRef = useCallback( - (el: HTMLElement | null) => { - setTrigger('options', el) - }, - [setTrigger] - ) + const setOptionsTriggerRef = useSidePanelTriggerRef('options') return ( diff --git a/src/frontend/src/features/rooms/livekit/components/controls/Participants/ParticipantsToggle.tsx b/src/frontend/src/features/rooms/livekit/components/controls/Participants/ParticipantsToggle.tsx index dbb17055..55f9f9dd 100644 --- a/src/frontend/src/features/rooms/livekit/components/controls/Participants/ParticipantsToggle.tsx +++ b/src/frontend/src/features/rooms/livekit/components/controls/Participants/ParticipantsToggle.tsx @@ -1,4 +1,3 @@ -import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import { RiGroupLine, RiInfinityLine } from '@remixicon/react' import { ToggleButton } from '@/primitives' @@ -7,7 +6,7 @@ import { css } from '@/styled-system/css' import { useParticipants } from '@livekit/components-react' import { useSidePanel } from '../../../hooks/useSidePanel' import { ToggleButtonProps } from '@/primitives/ToggleButton' -import { useSidePanelTriggers } from '../../../hooks/useSidePanelTriggers' +import { useSidePanelTriggerRef } from '../../../hooks/useSidePanelTriggerRef' export const ParticipantsToggle = ({ onPress, @@ -26,15 +25,9 @@ export const ParticipantsToggle = ({ numParticipants && numParticipants > 0 ? numParticipants : 1 const { isParticipantsOpen, toggleParticipants } = useSidePanel() - const { setTrigger } = useSidePanelTriggers() + const setParticipantsTriggerRef = useSidePanelTriggerRef('participants') const tooltipLabel = isParticipantsOpen ? 'open' : 'closed' - const setParticipantsTriggerRef = useCallback( - (el: HTMLElement | null) => { - setTrigger('participants', el) - }, - [setTrigger] - ) return (
{ - setTrigger('tools', el) - }, - [setTrigger] - ) + const setToolsTriggerRef = useSidePanelTriggerRef('tools') return (
+ selector?: string + delayMs?: number + preventScroll?: boolean +} + +const scheduleFocus = ( + target: HTMLElement, + { delayMs = 0, preventScroll = true }: { delayMs?: number; preventScroll?: boolean } +) => { + const timer = setTimeout(() => { + requestAnimationFrame(() => { + target.focus({ preventScroll }) + }) + }, delayMs) + return () => clearTimeout(timer) +} + +const resolveFocusTarget = (options: FocusOnCloseOptions) => { + if (options.ref?.current) return options.ref.current + if (!options.selector) return null + return document.querySelector(options.selector) +} + /** * a Popover is a tuple of a trigger component (most usually a Button) that toggles some content in a tooltip around the trigger * @@ -73,6 +105,8 @@ export const Popover = ({ isOpen, defaultOpen, onOpenChange, + focusOnOpen, + focusOnClose, ...dialogProps }: { children: [ @@ -86,13 +120,47 @@ export const Popover = ({ isOpen?: boolean defaultOpen?: boolean onOpenChange?: (isOpen: boolean) => void + focusOnOpen?: FocusOnOpenOptions + focusOnClose?: FocusOnCloseOptions } & Omit) => { const [trigger, popoverContent] = children + const popoverContentRef = useRef(null) + const isControlled = isOpen !== undefined + const [internalOpen, setInternalOpen] = useState(!!defaultOpen) + const effectiveOpen = isControlled ? isOpen : internalOpen + const prevOpenRef = useRef(effectiveOpen) + + const handleOpenChange = (nextOpen: boolean) => { + if (!isControlled) { + setInternalOpen(nextOpen) + } + onOpenChange?.(nextOpen) + } + + useEffect(() => { + if (!effectiveOpen || !focusOnOpen) return + const first = popoverContentRef.current?.querySelector( + focusOnOpen.selector + ) + if (!first) return + return scheduleFocus(first, focusOnOpen) + }, [effectiveOpen, focusOnOpen]) + + useEffect(() => { + const wasOpen = prevOpenRef.current + let cleanup: (() => void) | undefined + if (wasOpen && !effectiveOpen && focusOnClose) { + const target = resolveFocusTarget(focusOnClose) + if (target) cleanup = scheduleFocus(target, focusOnClose) + } + prevOpenRef.current = effectiveOpen + return cleanup + }, [effectiveOpen, focusOnClose]) return ( {trigger} @@ -105,7 +173,12 @@ export const Popover = ({ )} {({ close }) => ( - + {typeof popoverContent === 'function' ? popoverContent({ close }) : popoverContent}