mirror of
https://github.com/suitenumerique/meet.git
synced 2026-08-28 03:06:50 +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 { 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}`),
|
||||||
})}
|
})}
|
||||||
|
|||||||
+5
-9
@@ -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
-14
@@ -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',
|
||||||
|
|||||||
+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 { 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
|
||||||
|
|||||||
+2
-9
@@ -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">
|
||||||
|
|||||||
+2
-9
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user