mirror of
https://github.com/suitenumerique/meet.git
synced 2026-08-30 20:29:07 +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 { 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,
|
||||||
|
|||||||
+22
-1
@@ -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>
|
||||||
|
|||||||
@@ -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('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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
Reference in New Issue
Block a user