️(frontend) centralize popover focus and reuse side panel close

move popover focus handling into primitive and reuse close helper
This commit is contained in:
Cyril
2026-01-29 10:55:31 +01:00
parent 096968c222
commit daa3d6bcc6
10 changed files with 111 additions and 82 deletions
@@ -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
@@ -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 = () => {
<StyledSidePanel
title={t(`heading.${activeSubPanelId || activePanelId}`)}
ariaLabel={t('ariaLabel')}
onClose={() => {
layoutStore.activePanelId = null
layoutStore.activeSubPanelId = null
}}
onClose={closeSidePanel}
closeButtonTooltip={t('closeButton', {
content: t(`content.${activeSubPanelId || activePanelId}`),
})}
@@ -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<Source.Microphone>,
@@ -31,7 +31,6 @@ export const AudioDevicesControl = ({
}: AudioDevicesControlProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
const [isMenuOpen, setIsMenuOpen] = React.useState(false)
const popoverContentRef = React.useRef<HTMLDivElement>(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 (
<div
className={css({
@@ -90,6 +83,10 @@ export const AudioDevicesControl = ({
withArrow={false}
isOpen={isMenuOpen}
onOpenChange={setIsMenuOpen}
focusOnOpen={{
selector: AUDIO_INPUT_FOCUS_SELECTOR,
delayMs: 250,
}}
>
<Button
tooltip={selectLabel}
@@ -106,7 +103,6 @@ export const AudioDevicesControl = ({
</Button>
{({ close }) => (
<div
ref={popoverContentRef}
className={css({
maxWidth: '36rem',
padding: '0.15rem',
@@ -10,7 +10,7 @@ import { usePersistentUserChoices } from '../../../hooks/usePersistentUserChoice
import { useCanPublishTrack } from '../../../hooks/useCanPublishTrack'
import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
import { useSidePanel } from '../../../hooks/useSidePanel'
import { useSidePanelTriggers } from '../../../hooks/useSidePanelTriggers'
import { useSidePanelTriggerRef } from '../../../hooks/useSidePanelTriggerRef'
import { BackgroundProcessorFactory } from '../../blur'
import Source = Track.Source
import * as React from 'react'
@@ -18,12 +18,12 @@ import { SelectDevice } from './SelectDevice'
import { SettingsButton } from './SettingsButton'
import { SettingsDialogExtendedKey } from '@/features/settings/type'
import { TrackSource } from '@livekit/protocol'
import { useFocusOnOpen } from '@/hooks/useFocusOnOpen'
import { VIDEO_INPUT_FOCUS_SELECTOR } from './deviceFocusSelectors'
const EffectsButton = ({ onPress }: { onPress: () => void }) => {
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
const { isEffectsOpen, toggleEffects } = useSidePanel()
const { setTrigger } = useSidePanelTriggers()
const setEffectsTriggerRef = useSidePanelTriggerRef('effects')
return (
<Button
size="sm"
@@ -31,7 +31,7 @@ const EffectsButton = ({ onPress }: { onPress: () => void }) => {
tooltip={t('effects')}
aria-label={t('effects')}
variant="primaryDark"
ref={(el) => setTrigger('effects', el)}
ref={setEffectsTriggerRef}
onPress={() => {
if (!isEffectsOpen) toggleEffects()
onPress()
@@ -55,8 +55,7 @@ export const VideoDeviceControl = ({
}: VideoDeviceControlProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
const [isMenuOpen, setIsMenuOpen] = React.useState(false)
const popoverContentRef = React.useRef<HTMLDivElement>(null)
const { setTrigger } = useSidePanelTriggers()
const setCameraMenuTriggerRef = useSidePanelTriggerRef('cameraMenu')
const { userChoices, saveVideoInputDeviceId, saveVideoInputEnabled } =
usePersistentUserChoices()
@@ -106,12 +105,6 @@ export const VideoDeviceControl = ({
const selectLabel = t(`settings.${SettingsDialogExtendedKey.VIDEO}`)
const canPublishTrack = useCanPublishTrack(TrackSource.CAMERA)
useFocusOnOpen(isMenuOpen, popoverContentRef, {
selector:
'[data-attr="video-input-select"] button, [data-attr="video-input-select"] [role="combobox"]',
delayMs: 250,
})
return (
<div
className={css({
@@ -138,13 +131,17 @@ export const VideoDeviceControl = ({
withArrow={false}
isOpen={isMenuOpen}
onOpenChange={setIsMenuOpen}
focusOnOpen={{
selector: VIDEO_INPUT_FOCUS_SELECTOR,
delayMs: 250,
}}
>
<Button
tooltip={selectLabel}
aria-label={selectLabel}
groupPosition="right"
square
ref={(el) => setTrigger('cameraMenu', el)}
ref={setCameraMenuTriggerRef}
variant={
!canPublishTrack || !trackProps.enabled || cannotUseDevice
? 'error2'
@@ -155,7 +152,6 @@ export const VideoDeviceControl = ({
</Button>
{({ close }) => (
<div
ref={popoverContentRef}
className={css({
maxWidth: '36rem',
padding: '0.15rem',
@@ -0,0 +1,6 @@
export const AUDIO_INPUT_FOCUS_SELECTOR =
'[data-attr="audio-input-select"] button, [data-attr="audio-input-select"] [role="combobox"]'
export const VIDEO_INPUT_FOCUS_SELECTOR =
'[data-attr="video-input-select"] button, [data-attr="video-input-select"] [role="combobox"]'
@@ -1,11 +1,10 @@
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { RiInformationLine } from '@remixicon/react'
import { css } from '@/styled-system/css'
import { ToggleButton } from '@/primitives'
import { useSidePanel } from '../../hooks/useSidePanel'
import { ToggleButtonProps } from '@/primitives/ToggleButton'
import { useSidePanelTriggers } from '../../hooks/useSidePanelTriggers'
import { useSidePanelTriggerRef } from '../../hooks/useSidePanelTriggerRef'
export const InfoToggle = ({
onPress,
@@ -14,14 +13,8 @@ export const InfoToggle = ({
const { t } = useTranslation('rooms', { keyPrefix: 'controls.info' })
const { isInfoOpen, toggleInfo } = useSidePanel()
const { setTrigger } = useSidePanelTriggers()
const tooltipLabel = isInfoOpen ? 'open' : 'closed'
const setInfoTriggerRef = useCallback(
(el: HTMLElement | null) => {
setTrigger('info', el)
},
[setTrigger]
)
const setInfoTriggerRef = useSidePanelTriggerRef('info')
return (
<div
@@ -1,19 +1,12 @@
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { RiMoreFill } from '@remixicon/react'
import { Button, Menu } from '@/primitives'
import { OptionsMenuItems } from './OptionsMenuItems'
import { useSidePanelTriggers } from '../../../hooks/useSidePanelTriggers'
import { useSidePanelTriggerRef } from '../../../hooks/useSidePanelTriggerRef'
export const OptionsButton = () => {
const { t } = useTranslation('rooms')
const { setTrigger } = useSidePanelTriggers()
const setOptionsTriggerRef = useCallback(
(el: HTMLElement | null) => {
setTrigger('options', el)
},
[setTrigger]
)
const setOptionsTriggerRef = useSidePanelTriggerRef('options')
return (
<Menu variant="dark">
@@ -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 (
<div
@@ -1,11 +1,10 @@
import { useCallback } from 'react'
import { ToggleButton } from '@/primitives'
import { RiShapesLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { useSidePanel } from '../../hooks/useSidePanel'
import { css } from '@/styled-system/css'
import { ToggleButtonProps } from '@/primitives/ToggleButton'
import { useSidePanelTriggers } from '../../hooks/useSidePanelTriggers'
import { useSidePanelTriggerRef } from '../../hooks/useSidePanelTriggerRef'
export const ToolsToggle = ({
variant = 'primaryTextDark',
@@ -15,14 +14,8 @@ export const ToolsToggle = ({
const { t } = useTranslation('rooms', { keyPrefix: 'controls.tools' })
const { isToolsOpen, toggleTools } = useSidePanel()
const { setTrigger } = useSidePanelTriggers()
const tooltipLabel = isToolsOpen ? 'open' : 'closed'
const setToolsTriggerRef = useCallback(
(el: HTMLElement | null) => {
setTrigger('tools', el)
},
[setTrigger]
)
const setToolsTriggerRef = useSidePanelTriggerRef('tools')
return (
<div
+77 -4
View File
@@ -1,4 +1,5 @@
import { ReactNode } from 'react'
import type { RefObject } from 'react'
import { ReactNode, useEffect, useRef, useState } from 'react'
import {
DialogProps,
DialogTrigger,
@@ -60,6 +61,37 @@ const StyledOverlayArrow = styled(OverlayArrow, {
},
})
type FocusOnOpenOptions = {
selector: string
delayMs?: number
preventScroll?: boolean
}
type FocusOnCloseOptions = {
ref?: RefObject<HTMLElement>
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<HTMLElement>(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<DialogProps, 'children'>) => {
const [trigger, popoverContent] = children
const popoverContentRef = useRef<HTMLDivElement>(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<HTMLElement>(
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 (
<DialogTrigger
isOpen={isOpen}
isOpen={effectiveOpen}
defaultOpen={defaultOpen}
onOpenChange={onOpenChange}
onOpenChange={handleOpenChange}
>
{trigger}
<StyledPopover>
@@ -105,7 +173,12 @@ export const Popover = ({
)}
<Dialog {...dialogProps}>
{({ close }) => (
<Box size="sm" type="popover" variant={variant}>
<Box
size="sm"
type="popover"
variant={variant}
ref={popoverContentRef}
>
{typeof popoverContent === 'function'
? popoverContent({ close })
: popoverContent}