️(frontend) focus device menu on open

Add focus hook and trigger refs for device menus
This commit is contained in:
Cyril
2026-01-15 13:07:46 +01:00
parent 5fa287c2b0
commit f711346073
7 changed files with 84 additions and 6 deletions
@@ -1,3 +1,4 @@
import React from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useTrackToggle, UseTrackToggleProps } from '@livekit/components-react' import { useTrackToggle, UseTrackToggleProps } from '@livekit/components-react'
import { Button, Popover } from '@/primitives' import { Button, Popover } from '@/primitives'
@@ -9,13 +10,13 @@ import { css } from '@/styled-system/css'
import { usePersistentUserChoices } from '../../../hooks/usePersistentUserChoices' import { usePersistentUserChoices } from '../../../hooks/usePersistentUserChoices'
import { useCanPublishTrack } from '../../../hooks/useCanPublishTrack' import { useCanPublishTrack } from '../../../hooks/useCanPublishTrack'
import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice' import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
import * as React from 'react'
import { SelectDevice } from './SelectDevice' 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 Source = Track.Source import Source = Track.Source
import { isSafari } from '@/utils/livekit' import { isSafari } from '@/utils/livekit'
import { useFocusOnOpen } from '@/hooks/useFocusOnOpen'
type AudioDevicesControlProps = Omit< type AudioDevicesControlProps = Omit<
UseTrackToggleProps<Source.Microphone>, UseTrackToggleProps<Source.Microphone>,
@@ -29,6 +30,8 @@ export const AudioDevicesControl = ({
...props ...props
}: AudioDevicesControlProps) => { }: AudioDevicesControlProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' }) const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
const [isMenuOpen, setIsMenuOpen] = React.useState(false)
const popoverContentRef = React.useRef<HTMLDivElement>(null)
const { const {
userChoices: { audioDeviceId, audioOutputDeviceId }, userChoices: { audioDeviceId, audioOutputDeviceId },
@@ -55,6 +58,12 @@ 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({
@@ -76,7 +85,12 @@ export const AudioDevicesControl = ({
}} }}
/> />
{!hideMenu && ( {!hideMenu && (
<Popover variant="dark" withArrow={false}> <Popover
variant="dark"
withArrow={false}
isOpen={isMenuOpen}
onOpenChange={setIsMenuOpen}
>
<Button <Button
tooltip={selectLabel} tooltip={selectLabel}
aria-label={selectLabel} aria-label={selectLabel}
@@ -92,6 +106,7 @@ 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',
@@ -100,6 +115,7 @@ export const AudioDevicesControl = ({
})} })}
> >
<div <div
data-attr="audio-input-select"
style={{ style={{
flex: '1 1 0', flex: '1 1 0',
minWidth: 0, minWidth: 0,
@@ -10,6 +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 { 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'
@@ -17,10 +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'
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()
return ( return (
<Button <Button
size="sm" size="sm"
@@ -28,6 +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)}
onPress={() => { onPress={() => {
if (!isEffectsOpen) toggleEffects() if (!isEffectsOpen) toggleEffects()
onPress() onPress()
@@ -50,6 +54,9 @@ export const VideoDeviceControl = ({
...props ...props
}: VideoDeviceControlProps) => { }: VideoDeviceControlProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' }) const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
const [isMenuOpen, setIsMenuOpen] = React.useState(false)
const popoverContentRef = React.useRef<HTMLDivElement>(null)
const { setTrigger } = useSidePanelTriggers()
const { userChoices, saveVideoInputDeviceId, saveVideoInputEnabled } = const { userChoices, saveVideoInputDeviceId, saveVideoInputEnabled } =
usePersistentUserChoices() usePersistentUserChoices()
@@ -99,6 +106,12 @@ 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({
@@ -120,12 +133,18 @@ export const VideoDeviceControl = ({
}} }}
/> />
{!hideMenu && ( {!hideMenu && (
<Popover variant="dark" withArrow={false}> <Popover
variant="dark"
withArrow={false}
isOpen={isMenuOpen}
onOpenChange={setIsMenuOpen}
>
<Button <Button
tooltip={selectLabel} tooltip={selectLabel}
aria-label={selectLabel} aria-label={selectLabel}
groupPosition="right" groupPosition="right"
square square
ref={(el) => setTrigger('cameraMenu', el)}
variant={ variant={
!canPublishTrack || !trackProps.enabled || cannotUseDevice !canPublishTrack || !trackProps.enabled || cannotUseDevice
? 'error2' ? 'error2'
@@ -136,6 +155,7 @@ 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',
@@ -144,6 +164,7 @@ export const VideoDeviceControl = ({
})} })}
> >
<div <div
data-attr="video-input-select"
style={{ style={{
flex: '1 1 0', flex: '1 1 0',
minWidth: 0, minWidth: 0,
@@ -10,6 +10,7 @@ export const SidePanelProvider = ({ children }: { children: ReactNode }) => {
admin: null, admin: null,
options: null, options: null,
effects: null, effects: null,
cameraMenu: null,
}) })
const setTrigger = (key: SidePanelTriggerKey, el: HTMLElement | null) => { const setTrigger = (key: SidePanelTriggerKey, el: HTMLElement | null) => {
@@ -7,6 +7,7 @@ export type SidePanelTriggerKey =
| 'admin' | 'admin'
| 'options' | 'options'
| 'effects' | 'effects'
| 'cameraMenu'
export type SidePanelContextValue = { export type SidePanelContextValue = {
panelRef: React.RefObject<HTMLElement> panelRef: React.RefObject<HTMLElement>
+24
View File
@@ -0,0 +1,24 @@
import { useEffect } from 'react'
type UseFocusOnOpenOptions = {
selector: string
delayMs?: number
preventScroll?: boolean
}
export const useFocusOnOpen = (
isOpen: boolean,
containerRef: React.RefObject<HTMLElement>,
{ selector, delayMs = 0, preventScroll = true }: UseFocusOnOpenOptions
) => {
useEffect(() => {
if (!isOpen) return
const timer = setTimeout(() => {
requestAnimationFrame(() => {
const first = containerRef.current?.querySelector<HTMLElement>(selector)
first?.focus({ preventScroll })
})
}, delayMs)
return () => clearTimeout(timer)
}, [containerRef, delayMs, isOpen, preventScroll, selector])
}
+7 -2
View File
@@ -41,6 +41,13 @@ export function useRestoreFocus(
document.addEventListener('keydown', handleKeyDown) document.addEventListener('keydown', handleKeyDown)
document.addEventListener('mousedown', handleMouseDown) document.addEventListener('mousedown', handleMouseDown)
return () => {
document.removeEventListener('keydown', handleKeyDown)
document.removeEventListener('mousedown', handleMouseDown)
}
}, [])
useEffect(() => {
const wasOpen = prevIsOpenRef.current const wasOpen = prevIsOpenRef.current
// Just opened // Just opened
@@ -87,8 +94,6 @@ export function useRestoreFocus(
// Cleanup: remove focus ring if component unmounts before focus changes // Cleanup: remove focus ring if component unmounts before focus changes
return () => { return () => {
document.removeEventListener('keydown', handleKeyDown)
document.removeEventListener('mousedown', handleMouseDown)
cleanupRef.current?.() cleanupRef.current?.()
cleanupRef.current = null cleanupRef.current = null
} }
+11 -1
View File
@@ -70,6 +70,9 @@ export const Popover = ({
children, children,
variant = 'light', variant = 'light',
withArrow = true, withArrow = true,
isOpen,
defaultOpen,
onOpenChange,
...dialogProps ...dialogProps
}: { }: {
children: [ children: [
@@ -80,10 +83,17 @@ export const Popover = ({
] ]
variant?: 'dark' | 'light' variant?: 'dark' | 'light'
withArrow?: boolean withArrow?: boolean
isOpen?: boolean
defaultOpen?: boolean
onOpenChange?: (isOpen: boolean) => void
} & Omit<DialogProps, 'children'>) => { } & Omit<DialogProps, 'children'>) => {
const [trigger, popoverContent] = children const [trigger, popoverContent] = children
return ( return (
<DialogTrigger> <DialogTrigger
isOpen={isOpen}
defaultOpen={defaultOpen}
onOpenChange={onOpenChange}
>
{trigger} {trigger}
<StyledPopover> <StyledPopover>
{withArrow && ( {withArrow && (