mirror of
https://github.com/suitenumerique/meet.git
synced 2026-08-22 16:16:52 +00:00
♿️(frontend) use trigger refs for side panel focus
Replace global DOM queries with scoped refs for focus management
This commit is contained in:
@@ -13,11 +13,13 @@ import { usePublishSourcesManager } from '@/features/rooms/livekit/hooks/usePubl
|
|||||||
import { useSidePanel } from '../hooks/useSidePanel'
|
import { useSidePanel } from '../hooks/useSidePanel'
|
||||||
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
|
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
|
||||||
import { useSidePanelRef } from '../hooks/useSidePanelRef'
|
import { useSidePanelRef } from '../hooks/useSidePanelRef'
|
||||||
|
import { useSidePanelTriggers } from '../hooks/useSidePanelTriggers'
|
||||||
|
|
||||||
export const Admin = () => {
|
export const Admin = () => {
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'admin' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'admin' })
|
||||||
const { isAdminOpen } = useSidePanel()
|
const { isAdminOpen } = useSidePanel()
|
||||||
const panelRef = useSidePanelRef()
|
const panelRef = useSidePanelRef()
|
||||||
|
const { getTrigger } = useSidePanelTriggers()
|
||||||
|
|
||||||
const { roomId } = useParams()
|
const { roomId } = useParams()
|
||||||
|
|
||||||
@@ -46,11 +48,7 @@ export const Admin = () => {
|
|||||||
// Restore focus to the element that opened the Admin panel
|
// Restore focus to the element that opened the Admin panel
|
||||||
useRestoreFocus(isAdminOpen, {
|
useRestoreFocus(isAdminOpen, {
|
||||||
resolveTrigger: (activeEl) => {
|
resolveTrigger: (activeEl) => {
|
||||||
// Find the Admin toggle button - it doesn't have a data-attr, so we'll search by aria-label
|
return getTrigger('admin') ?? activeEl
|
||||||
const adminButton = Array.from(
|
|
||||||
document.querySelectorAll<HTMLElement>('button')
|
|
||||||
).find((btn) => btn.getAttribute('aria-label')?.includes('admin'))
|
|
||||||
return adminButton || activeEl
|
|
||||||
},
|
},
|
||||||
// Focus the first focusable element when the panel opens (first Field switch)
|
// Focus the first focusable element when the panel opens (first Field switch)
|
||||||
onOpened: () => {
|
onOpened: () => {
|
||||||
@@ -58,9 +56,8 @@ export const Admin = () => {
|
|||||||
const panel = panelRef.current
|
const panel = panelRef.current
|
||||||
if (panel) {
|
if (panel) {
|
||||||
// Find the first switch in the moderation section
|
// Find the first switch in the moderation section
|
||||||
const firstSwitch = panel.querySelector<HTMLElement>(
|
const firstSwitch =
|
||||||
'[role="switch"]:first-of-type'
|
panel.querySelector<HTMLElement>('[role="switch"]')
|
||||||
)
|
|
||||||
if (firstSwitch) {
|
if (firstSwitch) {
|
||||||
firstSwitch.focus({ preventScroll: true })
|
firstSwitch.focus({ preventScroll: true })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
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'
|
||||||
@@ -5,6 +6,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'
|
||||||
|
|
||||||
export const AdminToggle = ({
|
export const AdminToggle = ({
|
||||||
variant = 'primaryTextDark',
|
variant = 'primaryTextDark',
|
||||||
@@ -14,10 +16,17 @@ 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(
|
||||||
|
(el: HTMLElement | null) => {
|
||||||
|
setTrigger('admin', el)
|
||||||
|
},
|
||||||
|
[setTrigger]
|
||||||
|
)
|
||||||
|
|
||||||
const hasAdminAccess = useIsAdminOrOwner()
|
const hasAdminAccess = useIsAdminOrOwner()
|
||||||
if (!hasAdminAccess) return
|
if (!hasAdminAccess) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -32,6 +41,7 @@ export const AdminToggle = ({
|
|||||||
aria-label={t(tooltipLabel)}
|
aria-label={t(tooltipLabel)}
|
||||||
tooltip={t(tooltipLabel)}
|
tooltip={t(tooltipLabel)}
|
||||||
isSelected={isAdminOpen}
|
isSelected={isAdminOpen}
|
||||||
|
ref={setAdminTriggerRef}
|
||||||
onPress={(e) => {
|
onPress={(e) => {
|
||||||
toggleAdmin()
|
toggleAdmin()
|
||||||
onPress?.(e)
|
onPress?.(e)
|
||||||
|
|||||||
@@ -12,11 +12,13 @@ import { useCopyRoomToClipboard } from '../hooks/useCopyRoomToClipboard'
|
|||||||
import { useSidePanel } from '../hooks/useSidePanel'
|
import { useSidePanel } from '../hooks/useSidePanel'
|
||||||
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
|
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
|
||||||
import { useSidePanelRef } from '../hooks/useSidePanelRef'
|
import { useSidePanelRef } from '../hooks/useSidePanelRef'
|
||||||
|
import { useSidePanelTriggers } from '../hooks/useSidePanelTriggers'
|
||||||
|
|
||||||
export const Info = () => {
|
export const Info = () => {
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'info' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'info' })
|
||||||
const { isInfoOpen } = useSidePanel()
|
const { isInfoOpen } = useSidePanel()
|
||||||
const panelRef = useSidePanelRef()
|
const panelRef = useSidePanelRef()
|
||||||
|
const { getTrigger } = useSidePanelTriggers()
|
||||||
|
|
||||||
const data = useRoomData()
|
const data = useRoomData()
|
||||||
const roomUrl = getRouteUrl('room', data?.slug)
|
const roomUrl = getRouteUrl('room', data?.slug)
|
||||||
@@ -31,17 +33,14 @@ export const Info = () => {
|
|||||||
|
|
||||||
// Restore focus to the element that opened the Info panel
|
// Restore focus to the element that opened the Info panel
|
||||||
useRestoreFocus(isInfoOpen, {
|
useRestoreFocus(isInfoOpen, {
|
||||||
resolveTrigger: () => {
|
resolveTrigger: (activeEl) => getTrigger('info') ?? activeEl,
|
||||||
// Find the Info toggle button
|
|
||||||
return document.querySelector<HTMLElement>('[data-attr*="controls-info"]')
|
|
||||||
},
|
|
||||||
// Focus the first focusable element when the panel opens
|
// Focus the first focusable element when the panel opens
|
||||||
onOpened: () => {
|
onOpened: () => {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
const panel = panelRef.current
|
const panel = panelRef.current
|
||||||
if (panel) {
|
if (panel) {
|
||||||
const firstButton = panel.querySelector<HTMLElement>(
|
const firstButton = panel.querySelector<HTMLElement>(
|
||||||
'[data-attr="copy-info-sidepannel"]'
|
'[data-attr="copy-info-sidepanel"]'
|
||||||
)
|
)
|
||||||
if (firstButton) {
|
if (firstButton) {
|
||||||
firstButton.focus({ preventScroll: true })
|
firstButton.focus({ preventScroll: true })
|
||||||
@@ -100,7 +99,7 @@ export const Info = () => {
|
|||||||
variant={isCopied ? 'success' : 'tertiaryText'}
|
variant={isCopied ? 'success' : 'tertiaryText'}
|
||||||
aria-label={t('roomInformation.button.ariaLabel')}
|
aria-label={t('roomInformation.button.ariaLabel')}
|
||||||
onPress={copyRoomToClipboard}
|
onPress={copyRoomToClipboard}
|
||||||
data-attr="copy-info-sidepannel"
|
data-attr="copy-info-sidepanel"
|
||||||
style={{
|
style={{
|
||||||
marginLeft: '-8px',
|
marginLeft: '-8px',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import { Effects } from './effects/Effects'
|
|||||||
import { Admin } from './Admin'
|
import { Admin } from './Admin'
|
||||||
import { Tools } from './Tools'
|
import { Tools } from './Tools'
|
||||||
import { Info } from './Info'
|
import { Info } from './Info'
|
||||||
import { SidePanelProvider } from '../contexts/SidePanelContext'
|
|
||||||
import { useSidePanelRef } from '../hooks/useSidePanelRef'
|
import { useSidePanelRef } from '../hooks/useSidePanelRef'
|
||||||
import { HStack } from '@/styled-system/jsx'
|
import { HStack } from '@/styled-system/jsx'
|
||||||
|
|
||||||
@@ -173,6 +172,8 @@ const SidePanelContent = () => {
|
|||||||
onBack={() => (layoutStore.activeSubPanelId = null)}
|
onBack={() => (layoutStore.activeSubPanelId = null)}
|
||||||
panelRef={panelRef}
|
panelRef={panelRef}
|
||||||
>
|
>
|
||||||
|
{/* keepAlive preserves focus restoration + state (e.g. scroll/input) across panels;
|
||||||
|
revisit if memory becomes a concern */}
|
||||||
<Panel isOpen={isParticipantsOpen} keepAlive={true}>
|
<Panel isOpen={isParticipantsOpen} keepAlive={true}>
|
||||||
<ParticipantsList />
|
<ParticipantsList />
|
||||||
</Panel>
|
</Panel>
|
||||||
@@ -196,9 +197,5 @@ const SidePanelContent = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const SidePanel = () => {
|
export const SidePanel = () => {
|
||||||
return (
|
return <SidePanelContent />
|
||||||
<SidePanelProvider>
|
|
||||||
<SidePanelContent />
|
|
||||||
</SidePanelProvider>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { ReactNode } from 'react'
|
|||||||
import { SubPanelId, useSidePanel } from '../hooks/useSidePanel'
|
import { SubPanelId, useSidePanel } from '../hooks/useSidePanel'
|
||||||
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
|
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
|
||||||
import { useSidePanelRef } from '../hooks/useSidePanelRef'
|
import { useSidePanelRef } from '../hooks/useSidePanelRef'
|
||||||
|
import { useSidePanelTriggers } from '../hooks/useSidePanelTriggers'
|
||||||
import {
|
import {
|
||||||
useIsRecordingModeEnabled,
|
useIsRecordingModeEnabled,
|
||||||
RecordingMode,
|
RecordingMode,
|
||||||
@@ -100,6 +101,7 @@ export const Tools = () => {
|
|||||||
useSidePanel()
|
useSidePanel()
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'moreTools' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'moreTools' })
|
||||||
const panelRef = useSidePanelRef()
|
const panelRef = useSidePanelRef()
|
||||||
|
const { getTrigger } = useSidePanelTriggers()
|
||||||
|
|
||||||
// Restore focus to the element that opened the Tools panel
|
// Restore focus to the element that opened the Tools panel
|
||||||
// following the same pattern as Chat.
|
// following the same pattern as Chat.
|
||||||
@@ -108,10 +110,10 @@ export const Tools = () => {
|
|||||||
// find the "more options" button ("Plus d'options") that opened the menu
|
// find the "more options" button ("Plus d'options") that opened the menu
|
||||||
resolveTrigger: (activeEl) => {
|
resolveTrigger: (activeEl) => {
|
||||||
if (activeEl?.tagName === 'DIV') {
|
if (activeEl?.tagName === 'DIV') {
|
||||||
return document.querySelector<HTMLElement>('#room-options-trigger')
|
return getTrigger('options') ?? activeEl
|
||||||
}
|
}
|
||||||
// For direct button clicks (e.g. "Plus d'outils"), use the active element as is
|
// For direct button clicks (e.g. "Plus d'outils"), use the active element as is
|
||||||
return activeEl
|
return getTrigger('tools') ?? activeEl
|
||||||
},
|
},
|
||||||
// Focus the first focusable element when the panel opens
|
// Focus the first focusable element when the panel opens
|
||||||
onOpened: () => {
|
onOpened: () => {
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
|
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'
|
||||||
|
|
||||||
export const InfoToggle = ({
|
export const InfoToggle = ({
|
||||||
onPress,
|
onPress,
|
||||||
@@ -12,7 +14,14 @@ 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(
|
||||||
|
(el: HTMLElement | null) => {
|
||||||
|
setTrigger('info', el)
|
||||||
|
},
|
||||||
|
[setTrigger]
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -27,6 +36,7 @@ export const InfoToggle = ({
|
|||||||
aria-label={t(tooltipLabel)}
|
aria-label={t(tooltipLabel)}
|
||||||
tooltip={t(tooltipLabel)}
|
tooltip={t(tooltipLabel)}
|
||||||
isSelected={isInfoOpen}
|
isSelected={isInfoOpen}
|
||||||
|
ref={setInfoTriggerRef}
|
||||||
onPress={(e) => {
|
onPress={(e) => {
|
||||||
toggleInfo()
|
toggleInfo()
|
||||||
onPress?.(e)
|
onPress?.(e)
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
|
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'
|
||||||
|
|
||||||
export const OptionsButton = () => {
|
export const OptionsButton = () => {
|
||||||
const { t } = useTranslation('rooms')
|
const { t } = useTranslation('rooms')
|
||||||
|
const { setTrigger } = useSidePanelTriggers()
|
||||||
|
const setOptionsTriggerRef = useCallback(
|
||||||
|
(el: HTMLElement | null) => {
|
||||||
|
setTrigger('options', el)
|
||||||
|
},
|
||||||
|
[setTrigger]
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu variant="dark">
|
<Menu variant="dark">
|
||||||
@@ -14,6 +23,7 @@ export const OptionsButton = () => {
|
|||||||
variant="primaryDark"
|
variant="primaryDark"
|
||||||
aria-label={t('options.buttonLabel')}
|
aria-label={t('options.buttonLabel')}
|
||||||
tooltip={t('options.buttonLabel')}
|
tooltip={t('options.buttonLabel')}
|
||||||
|
ref={setOptionsTriggerRef}
|
||||||
>
|
>
|
||||||
<RiMoreFill />
|
<RiMoreFill />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
+1
@@ -70,6 +70,7 @@ export function ParticipantsCollapsableList<T>({
|
|||||||
<ToggleHeader
|
<ToggleHeader
|
||||||
isSelected={isOpen}
|
isSelected={isOpen}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
|
data-focus-target="list-header"
|
||||||
onPress={() => setIsOpen(!isOpen)}
|
onPress={() => setIsOpen(!isOpen)}
|
||||||
style={{
|
style={{
|
||||||
borderRadius: !isOpen ? '7px' : undefined,
|
borderRadius: !isOpen ? '7px' : undefined,
|
||||||
|
|||||||
+4
-9
@@ -15,12 +15,14 @@ import { MuteEveryoneButton } from './MuteEveryoneButton'
|
|||||||
import { useSidePanel } from '../../../hooks/useSidePanel'
|
import { useSidePanel } from '../../../hooks/useSidePanel'
|
||||||
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
|
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
|
||||||
import { useSidePanelRef } from '../../../hooks/useSidePanelRef'
|
import { useSidePanelRef } from '../../../hooks/useSidePanelRef'
|
||||||
|
import { useSidePanelTriggers } from '../../../hooks/useSidePanelTriggers'
|
||||||
|
|
||||||
// TODO: Optimize rendering performance, especially for longer participant lists, even though they are generally short.
|
// TODO: Optimize rendering performance, especially for longer participant lists, even though they are generally short.
|
||||||
export const ParticipantsList = () => {
|
export const ParticipantsList = () => {
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'participants' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'participants' })
|
||||||
const { isParticipantsOpen } = useSidePanel()
|
const { isParticipantsOpen } = useSidePanel()
|
||||||
const panelRef = useSidePanelRef()
|
const panelRef = useSidePanelRef()
|
||||||
|
const { getTrigger } = useSidePanelTriggers()
|
||||||
|
|
||||||
// Preferred using the 'useParticipants' hook rather than the separate remote and local hooks,
|
// Preferred using the 'useParticipants' hook rather than the separate remote and local hooks,
|
||||||
// because the 'useLocalParticipant' hook does not update the participant's information when their
|
// because the 'useLocalParticipant' hook does not update the participant's information when their
|
||||||
@@ -56,12 +58,7 @@ export const ParticipantsList = () => {
|
|||||||
// Restore focus to the element that opened the Participants panel
|
// Restore focus to the element that opened the Participants panel
|
||||||
useRestoreFocus(isParticipantsOpen, {
|
useRestoreFocus(isParticipantsOpen, {
|
||||||
resolveTrigger: (activeEl) => {
|
resolveTrigger: (activeEl) => {
|
||||||
// Find the Participants toggle button
|
return getTrigger('participants') ?? activeEl
|
||||||
return (
|
|
||||||
document.querySelector<HTMLElement>(
|
|
||||||
'[data-attr*="controls-participants"]'
|
|
||||||
) || activeEl
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
// Focus the first focusable element when the panel opens
|
// Focus the first focusable element when the panel opens
|
||||||
onOpened: () => {
|
onOpened: () => {
|
||||||
@@ -71,10 +68,8 @@ export const ParticipantsList = () => {
|
|||||||
const panel = panelRef.current
|
const panel = panelRef.current
|
||||||
if (panel) {
|
if (panel) {
|
||||||
// Find the first ToggleHeader (collapsable list header) in the participants panel
|
// Find the first ToggleHeader (collapsable list header) in the participants panel
|
||||||
// Look for buttons with aria-label containing "liste" (list headers)
|
|
||||||
// Exclude close/back buttons
|
|
||||||
const firstListHeader = panel.querySelector<HTMLElement>(
|
const firstListHeader = panel.querySelector<HTMLElement>(
|
||||||
'button[aria-label*="liste"]:not([aria-label*="Masquer les participants"])'
|
'button[data-focus-target="list-header"]'
|
||||||
)
|
)
|
||||||
firstListHeader?.focus({ preventScroll: true })
|
firstListHeader?.focus({ preventScroll: true })
|
||||||
}
|
}
|
||||||
|
|||||||
+10
@@ -1,3 +1,4 @@
|
|||||||
|
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'
|
||||||
@@ -6,6 +7,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'
|
||||||
|
|
||||||
export const ParticipantsToggle = ({
|
export const ParticipantsToggle = ({
|
||||||
onPress,
|
onPress,
|
||||||
@@ -24,8 +26,15 @@ 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 tooltipLabel = isParticipantsOpen ? 'open' : 'closed'
|
const tooltipLabel = isParticipantsOpen ? 'open' : 'closed'
|
||||||
|
const setParticipantsTriggerRef = useCallback(
|
||||||
|
(el: HTMLElement | null) => {
|
||||||
|
setTrigger('participants', el)
|
||||||
|
},
|
||||||
|
[setTrigger]
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -42,6 +51,7 @@ export const ParticipantsToggle = ({
|
|||||||
count: announcedCount,
|
count: announcedCount,
|
||||||
})}.`}
|
})}.`}
|
||||||
isSelected={isParticipantsOpen}
|
isSelected={isParticipantsOpen}
|
||||||
|
ref={setParticipantsTriggerRef}
|
||||||
onPress={(e) => {
|
onPress={(e) => {
|
||||||
toggleParticipants()
|
toggleParticipants()
|
||||||
onPress?.(e)
|
onPress?.(e)
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
|
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'
|
||||||
|
|
||||||
export const ToolsToggle = ({
|
export const ToolsToggle = ({
|
||||||
variant = 'primaryTextDark',
|
variant = 'primaryTextDark',
|
||||||
@@ -13,7 +15,14 @@ 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(
|
||||||
|
(el: HTMLElement | null) => {
|
||||||
|
setTrigger('tools', el)
|
||||||
|
},
|
||||||
|
[setTrigger]
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -28,6 +37,7 @@ export const ToolsToggle = ({
|
|||||||
aria-label={t(tooltipLabel)}
|
aria-label={t(tooltipLabel)}
|
||||||
tooltip={t(tooltipLabel)}
|
tooltip={t(tooltipLabel)}
|
||||||
isSelected={isToolsOpen}
|
isSelected={isToolsOpen}
|
||||||
|
ref={setToolsTriggerRef}
|
||||||
onPress={(e) => {
|
onPress={(e) => {
|
||||||
toggleTools()
|
toggleTools()
|
||||||
onPress?.(e)
|
onPress?.(e)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { TrackSource } from '@livekit/protocol'
|
|||||||
import { useSidePanel } from '../../hooks/useSidePanel'
|
import { useSidePanel } from '../../hooks/useSidePanel'
|
||||||
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
|
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
|
||||||
import { useSidePanelRef } from '../../hooks/useSidePanelRef'
|
import { useSidePanelRef } from '../../hooks/useSidePanelRef'
|
||||||
|
import { useSidePanelTriggers } from '../../hooks/useSidePanelTriggers'
|
||||||
|
|
||||||
export const Effects = () => {
|
export const Effects = () => {
|
||||||
const { cameraTrack } = useLocalParticipant()
|
const { cameraTrack } = useLocalParticipant()
|
||||||
@@ -15,13 +16,14 @@ export const Effects = () => {
|
|||||||
const { saveProcessorSerialized } = usePersistentUserChoices()
|
const { saveProcessorSerialized } = usePersistentUserChoices()
|
||||||
const { isEffectsOpen } = useSidePanel()
|
const { isEffectsOpen } = useSidePanel()
|
||||||
const panelRef = useSidePanelRef()
|
const panelRef = useSidePanelRef()
|
||||||
|
const { getTrigger } = useSidePanelTriggers()
|
||||||
|
|
||||||
const canPublishCamera = useCanPublishTrack(TrackSource.CAMERA)
|
const canPublishCamera = useCanPublishTrack(TrackSource.CAMERA)
|
||||||
|
|
||||||
useRestoreFocus(isEffectsOpen, {
|
useRestoreFocus(isEffectsOpen, {
|
||||||
resolveTrigger: (activeEl) => {
|
resolveTrigger: (activeEl) => {
|
||||||
if (activeEl?.tagName === 'DIV') {
|
if (activeEl?.tagName === 'DIV') {
|
||||||
return document.querySelector<HTMLElement>('#room-options-trigger')
|
return getTrigger('options') ?? activeEl
|
||||||
}
|
}
|
||||||
// For direct button clicks, use the active element as is
|
// For direct button clicks, use the active element as is
|
||||||
return activeEl
|
return activeEl
|
||||||
|
|||||||
@@ -1,11 +1,27 @@
|
|||||||
import { useRef, ReactNode } from 'react'
|
import { useRef, ReactNode } from 'react'
|
||||||
import { SidePanelContext } from './sidePanelContextValue'
|
import { SidePanelContext, SidePanelTriggerKey } from './sidePanelContextValue'
|
||||||
|
|
||||||
export const SidePanelProvider = ({ children }: { children: ReactNode }) => {
|
export const SidePanelProvider = ({ children }: { children: ReactNode }) => {
|
||||||
const panelRef = useRef<HTMLElement>(null)
|
const panelRef = useRef<HTMLElement>(null)
|
||||||
|
const triggersRef = useRef<Record<SidePanelTriggerKey, HTMLElement | null>>({
|
||||||
|
participants: null,
|
||||||
|
tools: null,
|
||||||
|
info: null,
|
||||||
|
admin: null,
|
||||||
|
options: null,
|
||||||
|
effects: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const setTrigger = (key: SidePanelTriggerKey, el: HTMLElement | null) => {
|
||||||
|
triggersRef.current[key] = el
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTrigger = (key: SidePanelTriggerKey) => {
|
||||||
|
return triggersRef.current[key] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidePanelContext.Provider value={{ panelRef }}>
|
<SidePanelContext.Provider value={{ panelRef, setTrigger, getTrigger }}>
|
||||||
{children}
|
{children}
|
||||||
</SidePanelContext.Provider>
|
</SidePanelContext.Provider>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,17 @@
|
|||||||
import { createContext } from 'react'
|
import { createContext } from 'react'
|
||||||
|
|
||||||
|
export type SidePanelTriggerKey =
|
||||||
|
| 'participants'
|
||||||
|
| 'tools'
|
||||||
|
| 'info'
|
||||||
|
| 'admin'
|
||||||
|
| 'options'
|
||||||
|
| 'effects'
|
||||||
|
|
||||||
export type SidePanelContextValue = {
|
export type SidePanelContextValue = {
|
||||||
panelRef: React.RefObject<HTMLElement>
|
panelRef: React.RefObject<HTMLElement>
|
||||||
|
setTrigger: (key: SidePanelTriggerKey, el: HTMLElement | null) => void
|
||||||
|
getTrigger: (key: SidePanelTriggerKey) => HTMLElement | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SidePanelContext = createContext<SidePanelContextValue | null>(
|
export const SidePanelContext = createContext<SidePanelContextValue | null>(
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { useContext } from 'react'
|
||||||
|
import { SidePanelContext } from '../contexts/sidePanelContextValue'
|
||||||
|
|
||||||
|
export const useSidePanelTriggers = () => {
|
||||||
|
const context = useContext(SidePanelContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error(
|
||||||
|
'useSidePanelTriggers must be used within SidePanelProvider'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
setTrigger: context.setTrigger,
|
||||||
|
getTrigger: context.getTrigger,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ import { FocusLayout } from '../components/FocusLayout'
|
|||||||
import { ParticipantTile } from '../components/ParticipantTile'
|
import { ParticipantTile } from '../components/ParticipantTile'
|
||||||
import { SidePanel } from '../components/SidePanel'
|
import { SidePanel } from '../components/SidePanel'
|
||||||
import { useSidePanel } from '../hooks/useSidePanel'
|
import { useSidePanel } from '../hooks/useSidePanel'
|
||||||
|
import { SidePanelProvider } from '../contexts/SidePanelContext'
|
||||||
import { RecordingProvider } from '@/features/recording'
|
import { RecordingProvider } from '@/features/recording'
|
||||||
import { ScreenShareErrorModal } from '../components/ScreenShareErrorModal'
|
import { ScreenShareErrorModal } from '../components/ScreenShareErrorModal'
|
||||||
import { useConnectionObserver } from '../hooks/useConnectionObserver'
|
import { useConnectionObserver } from '../hooks/useConnectionObserver'
|
||||||
@@ -259,75 +260,77 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
|||||||
value={layoutContext}
|
value={layoutContext}
|
||||||
// onPinChange={handleFocusStateChange}
|
// onPinChange={handleFocusStateChange}
|
||||||
>
|
>
|
||||||
<ScreenShareErrorModal
|
<SidePanelProvider>
|
||||||
isOpen={isShareErrorVisible}
|
<ScreenShareErrorModal
|
||||||
onClose={() => setIsShareErrorVisible(false)}
|
isOpen={isShareErrorVisible}
|
||||||
/>
|
onClose={() => setIsShareErrorVisible(false)}
|
||||||
<IsIdleDisconnectModal />
|
/>
|
||||||
<div
|
<IsIdleDisconnectModal />
|
||||||
// todo - extract these magic values into constant
|
<div
|
||||||
style={{
|
// todo - extract these magic values into constant
|
||||||
position: 'absolute',
|
style={{
|
||||||
inset: isSidePanelOpen
|
position: 'absolute',
|
||||||
? `var(--lk-grid-gap) calc(358px + 3rem) calc(80px + var(--lk-grid-gap)) 16px`
|
inset: isSidePanelOpen
|
||||||
: `var(--lk-grid-gap) var(--lk-grid-gap) calc(80px + var(--lk-grid-gap))`,
|
? `var(--lk-grid-gap) calc(358px + 3rem) calc(80px + var(--lk-grid-gap)) 16px`
|
||||||
transition: 'inset .5s cubic-bezier(0.4,0,0.2,1) 5ms',
|
: `var(--lk-grid-gap) var(--lk-grid-gap) calc(80px + var(--lk-grid-gap))`,
|
||||||
maxHeight: '100%',
|
transition: 'inset .5s cubic-bezier(0.4,0,0.2,1) 5ms',
|
||||||
}}
|
maxHeight: '100%',
|
||||||
>
|
}}
|
||||||
<LayoutWrapper areSubtitlesOpen={areSubtitlesOpen}>
|
>
|
||||||
<div
|
<LayoutWrapper areSubtitlesOpen={areSubtitlesOpen}>
|
||||||
style={{
|
<div
|
||||||
display: 'flex',
|
style={{
|
||||||
position: 'relative',
|
display: 'flex',
|
||||||
width: '100%',
|
position: 'relative',
|
||||||
}}
|
width: '100%',
|
||||||
>
|
}}
|
||||||
{!focusTrack ? (
|
>
|
||||||
<div
|
{!focusTrack ? (
|
||||||
className="lk-grid-layout-wrapper"
|
<div
|
||||||
style={{ height: 'auto' }}
|
className="lk-grid-layout-wrapper"
|
||||||
>
|
style={{ height: 'auto' }}
|
||||||
<GridLayout tracks={tracks} style={{ padding: 0 }}>
|
>
|
||||||
<ParticipantTile />
|
<GridLayout tracks={tracks} style={{ padding: 0 }}>
|
||||||
</GridLayout>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
className="lk-focus-layout-wrapper"
|
|
||||||
style={{ height: 'auto' }}
|
|
||||||
>
|
|
||||||
<FocusLayoutContainer style={{ padding: 0 }}>
|
|
||||||
<CarouselLayout
|
|
||||||
tracks={carouselTracks}
|
|
||||||
style={{
|
|
||||||
minWidth: '200px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ParticipantTile />
|
<ParticipantTile />
|
||||||
</CarouselLayout>
|
</GridLayout>
|
||||||
{focusTrack && <FocusLayout trackRef={focusTrack} />}
|
</div>
|
||||||
</FocusLayoutContainer>
|
) : (
|
||||||
</div>
|
<div
|
||||||
)}
|
className="lk-focus-layout-wrapper"
|
||||||
</div>
|
style={{ height: 'auto' }}
|
||||||
</LayoutWrapper>
|
>
|
||||||
<Subtitles />
|
<FocusLayoutContainer style={{ padding: 0 }}>
|
||||||
<MainNotificationToast />
|
<CarouselLayout
|
||||||
</div>
|
tracks={carouselTracks}
|
||||||
<ControlBar
|
style={{
|
||||||
onDeviceError={(e) => {
|
minWidth: '200px',
|
||||||
console.error(e)
|
}}
|
||||||
if (
|
>
|
||||||
e.source == Track.Source.ScreenShare &&
|
<ParticipantTile />
|
||||||
e.error.toString() ==
|
</CarouselLayout>
|
||||||
'NotAllowedError: Permission denied by system'
|
{focusTrack && <FocusLayout trackRef={focusTrack} />}
|
||||||
) {
|
</FocusLayoutContainer>
|
||||||
setIsShareErrorVisible(true)
|
</div>
|
||||||
}
|
)}
|
||||||
}}
|
</div>
|
||||||
/>
|
</LayoutWrapper>
|
||||||
<SidePanel />
|
<Subtitles />
|
||||||
|
<MainNotificationToast />
|
||||||
|
</div>
|
||||||
|
<ControlBar
|
||||||
|
onDeviceError={(e) => {
|
||||||
|
console.error(e)
|
||||||
|
if (
|
||||||
|
e.source == Track.Source.ScreenShare &&
|
||||||
|
e.error.toString() ==
|
||||||
|
'NotAllowedError: Permission denied by system'
|
||||||
|
) {
|
||||||
|
setIsShareErrorVisible(true)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<SidePanel />
|
||||||
|
</SidePanelProvider>
|
||||||
</LayoutContextProvider>
|
</LayoutContextProvider>
|
||||||
)}
|
)}
|
||||||
<RoomAudioRenderer />
|
<RoomAudioRenderer />
|
||||||
|
|||||||
@@ -58,20 +58,20 @@ export function useRestoreFocus(
|
|||||||
trigger.focus({ preventScroll })
|
trigger.focus({ preventScroll })
|
||||||
// Only show focus ring if last interaction was keyboard (like native :focus-visible)
|
// Only show focus ring if last interaction was keyboard (like native :focus-visible)
|
||||||
if (lastInteractionRef.current === 'keyboard') {
|
if (lastInteractionRef.current === 'keyboard') {
|
||||||
trigger.setAttribute('data-focus-visible', '')
|
trigger.setAttribute('data-restore-focus-visible', '')
|
||||||
// Remove focus ring when focus moves to another element
|
// Remove focus ring only when the trigger loses focus
|
||||||
const handleFocusChange = (e: FocusEvent) => {
|
const handleBlur = () => {
|
||||||
if (e.target !== trigger && document.contains(trigger)) {
|
if (document.contains(trigger)) {
|
||||||
trigger.removeAttribute('data-focus-visible')
|
trigger.removeAttribute('data-restore-focus-visible')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
document.addEventListener('focusin', handleFocusChange, {
|
trigger.addEventListener('blur', handleBlur, { once: true })
|
||||||
once: true,
|
|
||||||
})
|
|
||||||
// Store cleanup for unmount case
|
// Store cleanup for unmount case
|
||||||
|
cleanupRef.current?.()
|
||||||
cleanupRef.current = () => {
|
cleanupRef.current = () => {
|
||||||
|
trigger.removeEventListener('blur', handleBlur)
|
||||||
if (document.contains(trigger)) {
|
if (document.contains(trigger)) {
|
||||||
trigger.removeAttribute('data-focus-visible')
|
trigger.removeAttribute('data-restore-focus-visible')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,11 +23,20 @@ body,
|
|||||||
}
|
}
|
||||||
|
|
||||||
[data-rac][data-focus-visible]:not(label, .react-aria-Select),
|
[data-rac][data-focus-visible]:not(label, .react-aria-Select),
|
||||||
|
[data-rac][data-restore-focus-visible]:not(label, .react-aria-Select),
|
||||||
:is(a, button, input[type='text'], select, textarea):not(
|
:is(a, button, input[type='text'], select, textarea):not(
|
||||||
[data-rac]
|
[data-rac]
|
||||||
):focus-visible,
|
):focus-visible,
|
||||||
/* Show focus ring when data-focus-visible is set programmatically (e.g., when restoring focus) */
|
/* Show focus ring when data-focus-visible is set programmatically (e.g., when restoring focus) */
|
||||||
[data-focus-visible]:is(a, button, input[type='text'], select, textarea):focus {
|
[data-focus-visible]:is(a, button, input[type='text'], select, textarea):focus,
|
||||||
|
/* Show focus ring when restoring focus on react-aria buttons without overriding its focus state */
|
||||||
|
[data-restore-focus-visible]:is(
|
||||||
|
a,
|
||||||
|
button,
|
||||||
|
input[type='text'],
|
||||||
|
select,
|
||||||
|
textarea
|
||||||
|
):focus {
|
||||||
outline: 2px solid var(--colors-focus-ring);
|
outline: 2px solid var(--colors-focus-ring);
|
||||||
outline-offset: 1px;
|
outline-offset: 1px;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user