️(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 { ToggleButton } from '@/primitives'
import { RiAdminLine } from '@remixicon/react' import { RiAdminLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@@ -6,7 +5,7 @@ import { css } from '@/styled-system/css'
import { ToggleButtonProps } from '@/primitives/ToggleButton' import { ToggleButtonProps } from '@/primitives/ToggleButton'
import { useIsAdminOrOwner } from '../hooks/useIsAdminOrOwner' import { useIsAdminOrOwner } from '../hooks/useIsAdminOrOwner'
import { useSidePanel } from '../hooks/useSidePanel' import { useSidePanel } from '../hooks/useSidePanel'
import { useSidePanelTriggers } from '../hooks/useSidePanelTriggers' import { useSidePanelTriggerRef } from '../hooks/useSidePanelTriggerRef'
export const AdminToggle = ({ export const AdminToggle = ({
variant = 'primaryTextDark', variant = 'primaryTextDark',
@@ -16,14 +15,8 @@ export const AdminToggle = ({
const { t } = useTranslation('rooms', { keyPrefix: 'controls.admin' }) const { t } = useTranslation('rooms', { keyPrefix: 'controls.admin' })
const { isAdminOpen, toggleAdmin } = useSidePanel() const { isAdminOpen, toggleAdmin } = useSidePanel()
const { setTrigger } = useSidePanelTriggers()
const tooltipLabel = isAdminOpen ? 'open' : 'closed' const tooltipLabel = isAdminOpen ? 'open' : 'closed'
const setAdminTriggerRef = useCallback( const setAdminTriggerRef = useSidePanelTriggerRef('admin')
(el: HTMLElement | null) => {
setTrigger('admin', el)
},
[setTrigger]
)
const hasAdminAccess = useIsAdminOrOwner() const hasAdminAccess = useIsAdminOrOwner()
if (!hasAdminAccess) return null if (!hasAdminAccess) return null
@@ -152,6 +152,7 @@ const SidePanelContent = () => {
isInfoOpen, isInfoOpen,
isSubPanelOpen, isSubPanelOpen,
activeSubPanelId, activeSubPanelId,
closeSidePanel,
} = useSidePanel() } = useSidePanel()
const { t } = useTranslation('rooms', { keyPrefix: 'sidePanel' }) const { t } = useTranslation('rooms', { keyPrefix: 'sidePanel' })
const panelRef = useSidePanelRef() const panelRef = useSidePanelRef()
@@ -159,12 +160,7 @@ const SidePanelContent = () => {
useEscapeKey( useEscapeKey(
() => { () => {
// Close subpanel + panel together for a consistent Escape behavior // Close subpanel + panel together for a consistent Escape behavior
if (isSubPanelOpen) { closeSidePanel()
layoutStore.activeSubPanelId = null
layoutStore.activePanelId = null
return
}
layoutStore.activePanelId = null
}, },
{ {
isActive: isSidePanelOpen, isActive: isSidePanelOpen,
@@ -176,10 +172,7 @@ const SidePanelContent = () => {
<StyledSidePanel <StyledSidePanel
title={t(`heading.${activeSubPanelId || activePanelId}`)} title={t(`heading.${activeSubPanelId || activePanelId}`)}
ariaLabel={t('ariaLabel')} ariaLabel={t('ariaLabel')}
onClose={() => { onClose={closeSidePanel}
layoutStore.activePanelId = null
layoutStore.activeSubPanelId = null
}}
closeButtonTooltip={t('closeButton', { closeButtonTooltip={t('closeButton', {
content: t(`content.${activeSubPanelId || activePanelId}`), content: t(`content.${activeSubPanelId || activePanelId}`),
})} })}
@@ -16,7 +16,7 @@ import { SettingsDialogExtendedKey } from '@/features/settings/type'
import { TrackSource } from '@livekit/protocol' import { TrackSource } from '@livekit/protocol'
import Source = Track.Source import Source = Track.Source
import { isSafari } from '@/utils/livekit' import { isSafari } from '@/utils/livekit'
import { useFocusOnOpen } from '@/hooks/useFocusOnOpen' import { AUDIO_INPUT_FOCUS_SELECTOR } from './deviceFocusSelectors'
type AudioDevicesControlProps = Omit< type AudioDevicesControlProps = Omit<
UseTrackToggleProps<Source.Microphone>, UseTrackToggleProps<Source.Microphone>,
@@ -31,7 +31,6 @@ export const AudioDevicesControl = ({
}: AudioDevicesControlProps) => { }: AudioDevicesControlProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' }) const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
const [isMenuOpen, setIsMenuOpen] = React.useState(false) const [isMenuOpen, setIsMenuOpen] = React.useState(false)
const popoverContentRef = React.useRef<HTMLDivElement>(null)
const { const {
userChoices: { audioDeviceId, audioOutputDeviceId }, userChoices: { audioDeviceId, audioOutputDeviceId },
@@ -58,12 +57,6 @@ export const AudioDevicesControl = ({
const canPublishTrack = useCanPublishTrack(TrackSource.MICROPHONE) 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 ( return (
<div <div
className={css({ className={css({
@@ -90,6 +83,10 @@ export const AudioDevicesControl = ({
withArrow={false} withArrow={false}
isOpen={isMenuOpen} isOpen={isMenuOpen}
onOpenChange={setIsMenuOpen} onOpenChange={setIsMenuOpen}
focusOnOpen={{
selector: AUDIO_INPUT_FOCUS_SELECTOR,
delayMs: 250,
}}
> >
<Button <Button
tooltip={selectLabel} tooltip={selectLabel}
@@ -106,7 +103,6 @@ export const AudioDevicesControl = ({
</Button> </Button>
{({ close }) => ( {({ close }) => (
<div <div
ref={popoverContentRef}
className={css({ className={css({
maxWidth: '36rem', maxWidth: '36rem',
padding: '0.15rem', padding: '0.15rem',
@@ -10,7 +10,7 @@ import { usePersistentUserChoices } from '../../../hooks/usePersistentUserChoice
import { useCanPublishTrack } from '../../../hooks/useCanPublishTrack' import { useCanPublishTrack } from '../../../hooks/useCanPublishTrack'
import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice' import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
import { useSidePanel } from '../../../hooks/useSidePanel' import { useSidePanel } from '../../../hooks/useSidePanel'
import { useSidePanelTriggers } from '../../../hooks/useSidePanelTriggers' import { useSidePanelTriggerRef } from '../../../hooks/useSidePanelTriggerRef'
import { BackgroundProcessorFactory } from '../../blur' import { BackgroundProcessorFactory } from '../../blur'
import Source = Track.Source import Source = Track.Source
import * as React from 'react' import * as React from 'react'
@@ -18,12 +18,12 @@ import { SelectDevice } from './SelectDevice'
import { SettingsButton } from './SettingsButton' import { SettingsButton } from './SettingsButton'
import { SettingsDialogExtendedKey } from '@/features/settings/type' import { SettingsDialogExtendedKey } from '@/features/settings/type'
import { TrackSource } from '@livekit/protocol' import { TrackSource } from '@livekit/protocol'
import { useFocusOnOpen } from '@/hooks/useFocusOnOpen' import { VIDEO_INPUT_FOCUS_SELECTOR } from './deviceFocusSelectors'
const EffectsButton = ({ onPress }: { onPress: () => void }) => { const EffectsButton = ({ onPress }: { onPress: () => void }) => {
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' }) const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
const { isEffectsOpen, toggleEffects } = useSidePanel() const { isEffectsOpen, toggleEffects } = useSidePanel()
const { setTrigger } = useSidePanelTriggers() const setEffectsTriggerRef = useSidePanelTriggerRef('effects')
return ( return (
<Button <Button
size="sm" size="sm"
@@ -31,7 +31,7 @@ const EffectsButton = ({ onPress }: { onPress: () => void }) => {
tooltip={t('effects')} tooltip={t('effects')}
aria-label={t('effects')} aria-label={t('effects')}
variant="primaryDark" variant="primaryDark"
ref={(el) => setTrigger('effects', el)} ref={setEffectsTriggerRef}
onPress={() => { onPress={() => {
if (!isEffectsOpen) toggleEffects() if (!isEffectsOpen) toggleEffects()
onPress() onPress()
@@ -55,8 +55,7 @@ export const VideoDeviceControl = ({
}: VideoDeviceControlProps) => { }: VideoDeviceControlProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' }) const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
const [isMenuOpen, setIsMenuOpen] = React.useState(false) const [isMenuOpen, setIsMenuOpen] = React.useState(false)
const popoverContentRef = React.useRef<HTMLDivElement>(null) const setCameraMenuTriggerRef = useSidePanelTriggerRef('cameraMenu')
const { setTrigger } = useSidePanelTriggers()
const { userChoices, saveVideoInputDeviceId, saveVideoInputEnabled } = const { userChoices, saveVideoInputDeviceId, saveVideoInputEnabled } =
usePersistentUserChoices() usePersistentUserChoices()
@@ -106,12 +105,6 @@ export const VideoDeviceControl = ({
const selectLabel = t(`settings.${SettingsDialogExtendedKey.VIDEO}`) const selectLabel = t(`settings.${SettingsDialogExtendedKey.VIDEO}`)
const canPublishTrack = useCanPublishTrack(TrackSource.CAMERA) 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 ( return (
<div <div
className={css({ className={css({
@@ -138,13 +131,17 @@ export const VideoDeviceControl = ({
withArrow={false} withArrow={false}
isOpen={isMenuOpen} isOpen={isMenuOpen}
onOpenChange={setIsMenuOpen} onOpenChange={setIsMenuOpen}
focusOnOpen={{
selector: VIDEO_INPUT_FOCUS_SELECTOR,
delayMs: 250,
}}
> >
<Button <Button
tooltip={selectLabel} tooltip={selectLabel}
aria-label={selectLabel} aria-label={selectLabel}
groupPosition="right" groupPosition="right"
square square
ref={(el) => setTrigger('cameraMenu', el)} ref={setCameraMenuTriggerRef}
variant={ variant={
!canPublishTrack || !trackProps.enabled || cannotUseDevice !canPublishTrack || !trackProps.enabled || cannotUseDevice
? 'error2' ? 'error2'
@@ -155,7 +152,6 @@ export const VideoDeviceControl = ({
</Button> </Button>
{({ close }) => ( {({ close }) => (
<div <div
ref={popoverContentRef}
className={css({ className={css({
maxWidth: '36rem', maxWidth: '36rem',
padding: '0.15rem', 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 { useTranslation } from 'react-i18next'
import { RiInformationLine } from '@remixicon/react' import { RiInformationLine } from '@remixicon/react'
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
import { ToggleButton } from '@/primitives' import { ToggleButton } from '@/primitives'
import { useSidePanel } from '../../hooks/useSidePanel' import { useSidePanel } from '../../hooks/useSidePanel'
import { ToggleButtonProps } from '@/primitives/ToggleButton' import { ToggleButtonProps } from '@/primitives/ToggleButton'
import { useSidePanelTriggers } from '../../hooks/useSidePanelTriggers' import { useSidePanelTriggerRef } from '../../hooks/useSidePanelTriggerRef'
export const InfoToggle = ({ export const InfoToggle = ({
onPress, onPress,
@@ -14,14 +13,8 @@ export const InfoToggle = ({
const { t } = useTranslation('rooms', { keyPrefix: 'controls.info' }) const { t } = useTranslation('rooms', { keyPrefix: 'controls.info' })
const { isInfoOpen, toggleInfo } = useSidePanel() const { isInfoOpen, toggleInfo } = useSidePanel()
const { setTrigger } = useSidePanelTriggers()
const tooltipLabel = isInfoOpen ? 'open' : 'closed' const tooltipLabel = isInfoOpen ? 'open' : 'closed'
const setInfoTriggerRef = useCallback( const setInfoTriggerRef = useSidePanelTriggerRef('info')
(el: HTMLElement | null) => {
setTrigger('info', el)
},
[setTrigger]
)
return ( return (
<div <div
@@ -1,19 +1,12 @@
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { RiMoreFill } from '@remixicon/react' import { RiMoreFill } from '@remixicon/react'
import { Button, Menu } from '@/primitives' import { Button, Menu } from '@/primitives'
import { OptionsMenuItems } from './OptionsMenuItems' import { OptionsMenuItems } from './OptionsMenuItems'
import { useSidePanelTriggers } from '../../../hooks/useSidePanelTriggers' import { useSidePanelTriggerRef } from '../../../hooks/useSidePanelTriggerRef'
export const OptionsButton = () => { export const OptionsButton = () => {
const { t } = useTranslation('rooms') const { t } = useTranslation('rooms')
const { setTrigger } = useSidePanelTriggers() const setOptionsTriggerRef = useSidePanelTriggerRef('options')
const setOptionsTriggerRef = useCallback(
(el: HTMLElement | null) => {
setTrigger('options', el)
},
[setTrigger]
)
return ( return (
<Menu variant="dark"> <Menu variant="dark">
@@ -1,4 +1,3 @@
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { RiGroupLine, RiInfinityLine } from '@remixicon/react' import { RiGroupLine, RiInfinityLine } from '@remixicon/react'
import { ToggleButton } from '@/primitives' import { ToggleButton } from '@/primitives'
@@ -7,7 +6,7 @@ import { css } from '@/styled-system/css'
import { useParticipants } from '@livekit/components-react' import { useParticipants } from '@livekit/components-react'
import { useSidePanel } from '../../../hooks/useSidePanel' import { useSidePanel } from '../../../hooks/useSidePanel'
import { ToggleButtonProps } from '@/primitives/ToggleButton' import { ToggleButtonProps } from '@/primitives/ToggleButton'
import { useSidePanelTriggers } from '../../../hooks/useSidePanelTriggers' import { useSidePanelTriggerRef } from '../../../hooks/useSidePanelTriggerRef'
export const ParticipantsToggle = ({ export const ParticipantsToggle = ({
onPress, onPress,
@@ -26,15 +25,9 @@ export const ParticipantsToggle = ({
numParticipants && numParticipants > 0 ? numParticipants : 1 numParticipants && numParticipants > 0 ? numParticipants : 1
const { isParticipantsOpen, toggleParticipants } = useSidePanel() const { isParticipantsOpen, toggleParticipants } = useSidePanel()
const { setTrigger } = useSidePanelTriggers() const setParticipantsTriggerRef = useSidePanelTriggerRef('participants')
const tooltipLabel = isParticipantsOpen ? 'open' : 'closed' const tooltipLabel = isParticipantsOpen ? 'open' : 'closed'
const setParticipantsTriggerRef = useCallback(
(el: HTMLElement | null) => {
setTrigger('participants', el)
},
[setTrigger]
)
return ( return (
<div <div
@@ -1,11 +1,10 @@
import { useCallback } from 'react'
import { ToggleButton } from '@/primitives' import { ToggleButton } from '@/primitives'
import { RiShapesLine } from '@remixicon/react' import { RiShapesLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useSidePanel } from '../../hooks/useSidePanel' import { useSidePanel } from '../../hooks/useSidePanel'
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
import { ToggleButtonProps } from '@/primitives/ToggleButton' import { ToggleButtonProps } from '@/primitives/ToggleButton'
import { useSidePanelTriggers } from '../../hooks/useSidePanelTriggers' import { useSidePanelTriggerRef } from '../../hooks/useSidePanelTriggerRef'
export const ToolsToggle = ({ export const ToolsToggle = ({
variant = 'primaryTextDark', variant = 'primaryTextDark',
@@ -15,14 +14,8 @@ export const ToolsToggle = ({
const { t } = useTranslation('rooms', { keyPrefix: 'controls.tools' }) const { t } = useTranslation('rooms', { keyPrefix: 'controls.tools' })
const { isToolsOpen, toggleTools } = useSidePanel() const { isToolsOpen, toggleTools } = useSidePanel()
const { setTrigger } = useSidePanelTriggers()
const tooltipLabel = isToolsOpen ? 'open' : 'closed' const tooltipLabel = isToolsOpen ? 'open' : 'closed'
const setToolsTriggerRef = useCallback( const setToolsTriggerRef = useSidePanelTriggerRef('tools')
(el: HTMLElement | null) => {
setTrigger('tools', el)
},
[setTrigger]
)
return ( return (
<div <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 { import {
DialogProps, DialogProps,
DialogTrigger, 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 * 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, isOpen,
defaultOpen, defaultOpen,
onOpenChange, onOpenChange,
focusOnOpen,
focusOnClose,
...dialogProps ...dialogProps
}: { }: {
children: [ children: [
@@ -86,13 +120,47 @@ export const Popover = ({
isOpen?: boolean isOpen?: boolean
defaultOpen?: boolean defaultOpen?: boolean
onOpenChange?: (isOpen: boolean) => void onOpenChange?: (isOpen: boolean) => void
focusOnOpen?: FocusOnOpenOptions
focusOnClose?: FocusOnCloseOptions
} & Omit<DialogProps, 'children'>) => { } & Omit<DialogProps, 'children'>) => {
const [trigger, popoverContent] = 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 ( return (
<DialogTrigger <DialogTrigger
isOpen={isOpen} isOpen={effectiveOpen}
defaultOpen={defaultOpen} defaultOpen={defaultOpen}
onOpenChange={onOpenChange} onOpenChange={handleOpenChange}
> >
{trigger} {trigger}
<StyledPopover> <StyledPopover>
@@ -105,7 +173,12 @@ export const Popover = ({
)} )}
<Dialog {...dialogProps}> <Dialog {...dialogProps}>
{({ close }) => ( {({ close }) => (
<Box size="sm" type="popover" variant={variant}> <Box
size="sm"
type="popover"
variant={variant}
ref={popoverContentRef}
>
{typeof popoverContent === 'function' {typeof popoverContent === 'function'
? popoverContent({ close }) ? popoverContent({ close })
: popoverContent} : popoverContent}