mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-27 12:19:10 +00:00
♿️(frontend) centralize popover focus and reuse side panel close
move popover focus handling into primitive and reuse close helper
This commit is contained in:
@@ -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}`),
|
||||
})}
|
||||
|
||||
+5
-9
@@ -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
-14
@@ -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',
|
||||
|
||||
+6
@@ -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
|
||||
|
||||
+2
-9
@@ -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">
|
||||
|
||||
+2
-9
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user