mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-27 04:09:26 +00:00
♿️(frontend) focus device menu on open
Add focus hook and trigger refs for device menus
This commit is contained in:
+18
-2
@@ -1,3 +1,4 @@
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useTrackToggle, UseTrackToggleProps } from '@livekit/components-react'
|
||||
import { Button, Popover } from '@/primitives'
|
||||
@@ -9,13 +10,13 @@ import { css } from '@/styled-system/css'
|
||||
import { usePersistentUserChoices } from '../../../hooks/usePersistentUserChoices'
|
||||
import { useCanPublishTrack } from '../../../hooks/useCanPublishTrack'
|
||||
import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
|
||||
import * as React from 'react'
|
||||
import { SelectDevice } from './SelectDevice'
|
||||
import { SettingsButton } from './SettingsButton'
|
||||
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'
|
||||
|
||||
type AudioDevicesControlProps = Omit<
|
||||
UseTrackToggleProps<Source.Microphone>,
|
||||
@@ -29,6 +30,8 @@ export const AudioDevicesControl = ({
|
||||
...props
|
||||
}: AudioDevicesControlProps) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
|
||||
const [isMenuOpen, setIsMenuOpen] = React.useState(false)
|
||||
const popoverContentRef = React.useRef<HTMLDivElement>(null)
|
||||
|
||||
const {
|
||||
userChoices: { audioDeviceId, audioOutputDeviceId },
|
||||
@@ -55,6 +58,12 @@ 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({
|
||||
@@ -76,7 +85,12 @@ export const AudioDevicesControl = ({
|
||||
}}
|
||||
/>
|
||||
{!hideMenu && (
|
||||
<Popover variant="dark" withArrow={false}>
|
||||
<Popover
|
||||
variant="dark"
|
||||
withArrow={false}
|
||||
isOpen={isMenuOpen}
|
||||
onOpenChange={setIsMenuOpen}
|
||||
>
|
||||
<Button
|
||||
tooltip={selectLabel}
|
||||
aria-label={selectLabel}
|
||||
@@ -92,6 +106,7 @@ export const AudioDevicesControl = ({
|
||||
</Button>
|
||||
{({ close }) => (
|
||||
<div
|
||||
ref={popoverContentRef}
|
||||
className={css({
|
||||
maxWidth: '36rem',
|
||||
padding: '0.15rem',
|
||||
@@ -100,6 +115,7 @@ export const AudioDevicesControl = ({
|
||||
})}
|
||||
>
|
||||
<div
|
||||
data-attr="audio-input-select"
|
||||
style={{
|
||||
flex: '1 1 0',
|
||||
minWidth: 0,
|
||||
|
||||
+22
-1
@@ -10,6 +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 { BackgroundProcessorFactory } from '../../blur'
|
||||
import Source = Track.Source
|
||||
import * as React from 'react'
|
||||
@@ -17,10 +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'
|
||||
|
||||
const EffectsButton = ({ onPress }: { onPress: () => void }) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
|
||||
const { isEffectsOpen, toggleEffects } = useSidePanel()
|
||||
const { setTrigger } = useSidePanelTriggers()
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -28,6 +31,7 @@ const EffectsButton = ({ onPress }: { onPress: () => void }) => {
|
||||
tooltip={t('effects')}
|
||||
aria-label={t('effects')}
|
||||
variant="primaryDark"
|
||||
ref={(el) => setTrigger('effects', el)}
|
||||
onPress={() => {
|
||||
if (!isEffectsOpen) toggleEffects()
|
||||
onPress()
|
||||
@@ -50,6 +54,9 @@ export const VideoDeviceControl = ({
|
||||
...props
|
||||
}: VideoDeviceControlProps) => {
|
||||
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 } =
|
||||
usePersistentUserChoices()
|
||||
@@ -99,6 +106,12 @@ 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({
|
||||
@@ -120,12 +133,18 @@ export const VideoDeviceControl = ({
|
||||
}}
|
||||
/>
|
||||
{!hideMenu && (
|
||||
<Popover variant="dark" withArrow={false}>
|
||||
<Popover
|
||||
variant="dark"
|
||||
withArrow={false}
|
||||
isOpen={isMenuOpen}
|
||||
onOpenChange={setIsMenuOpen}
|
||||
>
|
||||
<Button
|
||||
tooltip={selectLabel}
|
||||
aria-label={selectLabel}
|
||||
groupPosition="right"
|
||||
square
|
||||
ref={(el) => setTrigger('cameraMenu', el)}
|
||||
variant={
|
||||
!canPublishTrack || !trackProps.enabled || cannotUseDevice
|
||||
? 'error2'
|
||||
@@ -136,6 +155,7 @@ export const VideoDeviceControl = ({
|
||||
</Button>
|
||||
{({ close }) => (
|
||||
<div
|
||||
ref={popoverContentRef}
|
||||
className={css({
|
||||
maxWidth: '36rem',
|
||||
padding: '0.15rem',
|
||||
@@ -144,6 +164,7 @@ export const VideoDeviceControl = ({
|
||||
})}
|
||||
>
|
||||
<div
|
||||
data-attr="video-input-select"
|
||||
style={{
|
||||
flex: '1 1 0',
|
||||
minWidth: 0,
|
||||
|
||||
@@ -10,6 +10,7 @@ export const SidePanelProvider = ({ children }: { children: ReactNode }) => {
|
||||
admin: null,
|
||||
options: null,
|
||||
effects: null,
|
||||
cameraMenu: null,
|
||||
})
|
||||
|
||||
const setTrigger = (key: SidePanelTriggerKey, el: HTMLElement | null) => {
|
||||
|
||||
@@ -7,6 +7,7 @@ export type SidePanelTriggerKey =
|
||||
| 'admin'
|
||||
| 'options'
|
||||
| 'effects'
|
||||
| 'cameraMenu'
|
||||
|
||||
export type SidePanelContextValue = {
|
||||
panelRef: React.RefObject<HTMLElement>
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
@@ -41,6 +41,13 @@ export function useRestoreFocus(
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
document.addEventListener('mousedown', handleMouseDown)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
document.removeEventListener('mousedown', handleMouseDown)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const wasOpen = prevIsOpenRef.current
|
||||
|
||||
// Just opened
|
||||
@@ -87,8 +94,6 @@ export function useRestoreFocus(
|
||||
|
||||
// Cleanup: remove focus ring if component unmounts before focus changes
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
document.removeEventListener('mousedown', handleMouseDown)
|
||||
cleanupRef.current?.()
|
||||
cleanupRef.current = null
|
||||
}
|
||||
|
||||
@@ -70,6 +70,9 @@ export const Popover = ({
|
||||
children,
|
||||
variant = 'light',
|
||||
withArrow = true,
|
||||
isOpen,
|
||||
defaultOpen,
|
||||
onOpenChange,
|
||||
...dialogProps
|
||||
}: {
|
||||
children: [
|
||||
@@ -80,10 +83,17 @@ export const Popover = ({
|
||||
]
|
||||
variant?: 'dark' | 'light'
|
||||
withArrow?: boolean
|
||||
isOpen?: boolean
|
||||
defaultOpen?: boolean
|
||||
onOpenChange?: (isOpen: boolean) => void
|
||||
} & Omit<DialogProps, 'children'>) => {
|
||||
const [trigger, popoverContent] = children
|
||||
return (
|
||||
<DialogTrigger>
|
||||
<DialogTrigger
|
||||
isOpen={isOpen}
|
||||
defaultOpen={defaultOpen}
|
||||
onOpenChange={onOpenChange}
|
||||
>
|
||||
{trigger}
|
||||
<StyledPopover>
|
||||
{withArrow && (
|
||||
|
||||
Reference in New Issue
Block a user