Compare commits

...

11 Commits

Author SHA1 Message Date
lebaudantoine 2d448cffd7 wip poc 2026-01-28 16:44:51 +01:00
Cyril 180da8d433 📝(docs) update changelog
Document focus management pattern entry
2026-01-28 12:17:06 +01:00
Cyril 8a30071f41 📝(docs) update changelog
Document focus management pattern entry
2026-01-28 12:17:06 +01:00
Cyril 2002aa7b04 ️(frontend) focus device menu on open
Add focus hook and trigger refs for device menus
2026-01-28 12:17:06 +01:00
Cyril 69dfda4b4b ️(frontend) add escape key hook for overlays
Use shared hook to close side panel and reactions
2026-01-28 12:17:06 +01:00
Cyril a863dedc6a 📝(docs) add side panel focus pattern
Document trigger ref and panel ref focus workflow
2026-01-28 12:17:06 +01:00
Cyril 17e5c31ee2 ️(frontend) use trigger refs for side panel focus
Replace global DOM queries with scoped refs for focus management
2026-01-28 12:17:06 +01:00
Cyril b16e4e9feb ♻️(frontend) use refs instead of queryselector for panels
Replace document.querySelector with panelRef for robustness
2026-01-23 14:17:11 +01:00
Cyril b3f106baf1 (frontend) add auto refocus for effects menu
Restore focus to options button on close, focus first element on open
2026-01-23 14:16:35 +01:00
Cyril 79ad62c700 🔧(git) ignore personal rules.md file
Allow developers to keep personal rules.md files without committing them
2026-01-23 14:16:34 +01:00
Cyril 90e432ac68 ️(frontend) add visible focus ring on restore focus
Show focus ring when restoring focus, keyboard navigation only
2026-01-23 14:16:34 +01:00
30 changed files with 627 additions and 87 deletions
+3
View File
@@ -77,6 +77,9 @@ db.sqlite3
*.iml
.devcontainer
# Personal rules/config files
rules.md
# Egress output
docker/livekit/out
+1 -1
View File
@@ -7,9 +7,9 @@ and this project adheres to
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Changed
- ♿(frontend) improve menu focus management #869
- ♿️(frontend) add accessible back button in side panel #881
- ♿️(frontend) improve participants toggle a11y label #880
- ♿️(frontend) make carousel image decorative #871
+34
View File
@@ -28,3 +28,37 @@ export default {
- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked`
- Optionally add `plugin:@typescript-eslint/stylistic-type-checked`
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list
## Side Panel Focus Pattern
We use a consistent focus management pattern for side panels:
- **Open**: focus the first actionable element inside the panel.
- **Close**: restore focus to the button that opened the panel.
Implementation summary:
1. A provider stores a `panelRef` and a registry of trigger refs (`setTrigger/getTrigger`).
2. Each trigger button registers itself with `setTrigger("key", el)`.
3. Panel content uses `useRestoreFocus` with:
- `resolveTrigger` → returns `getTrigger("key")`.
- `onOpened` → finds the first actionable element inside `panelRef`.
Example:
```tsx
// Trigger button
;<ToggleButton ref={(el) => setTrigger('tools', el)} />
// Panel content
useRestoreFocus(isOpen, {
resolveTrigger: (activeEl) => getTrigger('tools') ?? activeEl,
onOpened: () => {
const first = panelRef.current?.querySelector(
'[data-attr="tools-list"] button'
)
// Leading semicolon avoids ASI issues when a line starts with '('
;(first as HTMLElement | null)?.focus({ preventScroll: true })
},
})
```
@@ -10,9 +10,16 @@ import { keys } from '@/api/queryKeys'
import { useQuery } from '@tanstack/react-query'
import { useParams } from 'wouter'
import { usePublishSourcesManager } from '@/features/rooms/livekit/hooks/usePublishSourcesManager'
import { useSidePanel } from '../hooks/useSidePanel'
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
import { useSidePanelRef } from '../hooks/useSidePanelRef'
import { useSidePanelTriggers } from '../hooks/useSidePanelTriggers'
export const Admin = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'admin' })
const { isAdminOpen } = useSidePanel()
const panelRef = useSidePanelRef()
const { getTrigger } = useSidePanelTriggers()
const { roomId } = useParams()
@@ -38,6 +45,29 @@ export const Admin = () => {
isScreenShareEnabled,
} = usePublishSourcesManager()
// Restore focus to the element that opened the Admin panel
useRestoreFocus(isAdminOpen, {
resolveTrigger: (activeEl) => {
return getTrigger('admin') ?? activeEl
},
// Focus the first focusable element when the panel opens (first Field switch)
onOpened: () => {
requestAnimationFrame(() => {
const panel = panelRef.current
if (panel) {
// Find the first switch in the moderation section
const firstSwitch =
panel.querySelector<HTMLElement>('[role="switch"]')
if (firstSwitch) {
firstSwitch.focus({ preventScroll: true })
}
}
})
},
restoreFocusRaf: true,
preventScroll: true,
})
return (
<Div
display="flex"
@@ -1,3 +1,4 @@
import { useCallback } from 'react'
import { ToggleButton } from '@/primitives'
import { RiAdminLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
@@ -5,6 +6,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'
export const AdminToggle = ({
variant = 'primaryTextDark',
@@ -14,10 +16,17 @@ 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 hasAdminAccess = useIsAdminOrOwner()
if (!hasAdminAccess) return
if (!hasAdminAccess) return null
return (
<div
@@ -32,6 +41,7 @@ export const AdminToggle = ({
aria-label={t(tooltipLabel)}
tooltip={t(tooltipLabel)}
isSelected={isAdminOpen}
ref={setAdminTriggerRef}
onPress={(e) => {
toggleAdmin()
onPress?.(e)
@@ -1,5 +1,5 @@
import { useTranslation } from 'react-i18next'
import { useMemo } from 'react'
import { useEffect, useMemo, useRef } from 'react'
import { VStack } from '@/styled-system/jsx'
import { css } from '@/styled-system/css'
import { RiCheckLine, RiFileCopyLine } from '@remixicon/react'
@@ -10,8 +10,23 @@ import { formatPinCode } from '../../utils/telephony'
import { useTelephony } from '../hooks/useTelephony'
import { useCopyRoomToClipboard } from '../hooks/useCopyRoomToClipboard'
const useFocusOnOpen = ({ ref, key }) => {
useEffect(() => {
requestAnimationFrame(() => {
const panel = ref.current
if (panel) {
const firstButton = panel.querySelector<HTMLElement>(key)
if (firstButton) {
firstButton.focus({ preventScroll: true })
}
}
})
}, [])
}
export const Info = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'info' })
const panelRef = useRef()
const data = useRoomData()
const roomUrl = getRouteUrl('room', data?.slug)
@@ -24,6 +39,8 @@ export const Info = () => {
const { isCopied, copyRoomToClipboard } = useCopyRoomToClipboard(data)
useFocusOnOpen({ ref: panelRef, key: '[data-attr="copy-info-sidepanel"]' })
return (
<Div
display="flex"
@@ -32,6 +49,7 @@ export const Info = () => {
flexGrow={1}
flexDirection="column"
alignItems="start"
ref={panelRef}
>
<VStack alignItems="start">
<Text
@@ -71,7 +89,7 @@ export const Info = () => {
variant={isCopied ? 'success' : 'tertiaryText'}
aria-label={t('roomInformation.button.ariaLabel')}
onPress={copyRoomToClipboard}
data-attr="copy-info-sidepannel"
data-attr="copy-info-sidepanel"
style={{
marginLeft: '-8px',
}}
@@ -13,6 +13,8 @@ import { Effects } from './effects/Effects'
import { Admin } from './Admin'
import { Tools } from './Tools'
import { Info } from './Info'
import { useSidePanelRef } from '../hooks/useSidePanelRef'
import { useEscapeKey } from '@/hooks/useEscapeKey'
import { HStack } from '@/styled-system/jsx'
type StyledSidePanelProps = {
@@ -24,6 +26,7 @@ type StyledSidePanelProps = {
closeButtonTooltip: string
isSubmenu: boolean
onBack: () => void
panelRef: React.RefObject<HTMLElement>
backButtonLabel: string
}
@@ -36,9 +39,11 @@ const StyledSidePanel = ({
closeButtonTooltip,
isSubmenu = false,
onBack,
panelRef,
backButtonLabel,
}: StyledSidePanelProps) => (
<aside
ref={panelRef}
className={css({
borderWidth: '1px',
borderStyle: 'solid',
@@ -135,7 +140,7 @@ const Panel = ({ isOpen, keepAlive = false, children }: PanelProps) => (
{keepAlive || isOpen ? children : null}
</div>
)
export const SidePanel = () => {
const SidePanelContent = () => {
const {
activePanelId,
isParticipantsOpen,
@@ -149,6 +154,23 @@ export const SidePanel = () => {
activeSubPanelId,
} = useSidePanel()
const { t } = useTranslation('rooms', { keyPrefix: 'sidePanel' })
const panelRef = useSidePanelRef()
useEscapeKey(
() => {
// Close subpanel + panel together for a consistent Escape behavior
if (isSubPanelOpen) {
layoutStore.activeSubPanelId = null
layoutStore.activePanelId = null
return
}
layoutStore.activePanelId = null
},
{
isActive: isSidePanelOpen,
capture: true,
}
)
return (
<StyledSidePanel
@@ -165,11 +187,14 @@ export const SidePanel = () => {
isSubmenu={isSubPanelOpen}
backButtonLabel={t('backToTools')}
onBack={() => (layoutStore.activeSubPanelId = null)}
panelRef={panelRef}
>
<Panel isOpen={isParticipantsOpen}>
{/* keepAlive preserves focus restoration + state (e.g. scroll/input) across panels;
revisit if memory becomes a concern */}
<Panel isOpen={isParticipantsOpen} keepAlive={true}>
<ParticipantsList />
</Panel>
<Panel isOpen={isEffectsOpen}>
<Panel isOpen={isEffectsOpen} keepAlive={true}>
<Effects />
</Panel>
<Panel isOpen={isChatOpen} keepAlive={true}>
@@ -178,7 +203,7 @@ export const SidePanel = () => {
<Panel isOpen={isToolsOpen} keepAlive={true}>
<Tools />
</Panel>
<Panel isOpen={isAdminOpen}>
<Panel isOpen={isAdminOpen} keepAlive={true}>
<Admin />
</Panel>
<Panel isOpen={isInfoOpen}>
@@ -187,3 +212,7 @@ export const SidePanel = () => {
</StyledSidePanel>
)
}
export const SidePanel = () => {
return <SidePanelContent />
}
@@ -5,6 +5,8 @@ import { useTranslation } from 'react-i18next'
import { ReactNode } from 'react'
import { SubPanelId, useSidePanel } from '../hooks/useSidePanel'
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
import { useSidePanelRef } from '../hooks/useSidePanelRef'
import { useSidePanelTriggers } from '../hooks/useSidePanelTriggers'
import {
useIsRecordingModeEnabled,
RecordingMode,
@@ -98,6 +100,8 @@ export const Tools = () => {
const { openTranscript, openScreenRecording, activeSubPanelId, isToolsOpen } =
useSidePanel()
const { t } = useTranslation('rooms', { keyPrefix: 'moreTools' })
const panelRef = useSidePanelRef()
const { getTrigger } = useSidePanelTriggers()
// Restore focus to the element that opened the Tools panel
// following the same pattern as Chat.
@@ -106,10 +110,30 @@ export const Tools = () => {
// find the "more options" button ("Plus d'options") that opened the menu
resolveTrigger: (activeEl) => {
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
return activeEl
return getTrigger('tools') ?? activeEl
},
// Focus the first focusable element when the panel opens
onOpened: () => {
requestAnimationFrame(() => {
const panel = panelRef.current
if (panel) {
// Find the first ToolButton in the tools list (transcript or screen recording button)
const toolsList = panel.querySelector<HTMLElement>(
'[data-attr="tools-list"]'
)
if (toolsList) {
const firstToolButton = toolsList.querySelector<HTMLElement>(
'button:first-of-type'
)
if (firstToolButton) {
firstToolButton.focus({ preventScroll: true })
}
}
}
})
},
restoreFocusRaf: true,
preventScroll: true,
@@ -141,6 +165,7 @@ export const Tools = () => {
flexDirection="column"
alignItems="start"
gap={0.5}
data-attr="tools-list"
>
<Text
variant="note"
@@ -1,3 +1,4 @@
import React from 'react'
import { useTranslation } from 'react-i18next'
import { useTrackToggle, UseTrackToggleProps } from '@livekit/components-react'
import { Button, Popover } from '@/primitives'
@@ -9,13 +10,13 @@ import { css } from '@/styled-system/css'
import { usePersistentUserChoices } from '../../../hooks/usePersistentUserChoices'
import { useCanPublishTrack } from '../../../hooks/useCanPublishTrack'
import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
import * as React from 'react'
import { SelectDevice } from './SelectDevice'
import { SettingsButton } from './SettingsButton'
import { SettingsDialogExtendedKey } from '@/features/settings/type'
import { TrackSource } from '@livekit/protocol'
import Source = Track.Source
import { isSafari } from '@/utils/livekit'
import { useFocusOnOpen } from '@/hooks/useFocusOnOpen'
type AudioDevicesControlProps = Omit<
UseTrackToggleProps<Source.Microphone>,
@@ -29,6 +30,8 @@ export const AudioDevicesControl = ({
...props
}: AudioDevicesControlProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
const [isMenuOpen, setIsMenuOpen] = React.useState(false)
const popoverContentRef = React.useRef<HTMLDivElement>(null)
const {
userChoices: { audioDeviceId, audioOutputDeviceId },
@@ -55,6 +58,12 @@ export const AudioDevicesControl = ({
const canPublishTrack = useCanPublishTrack(TrackSource.MICROPHONE)
useFocusOnOpen(isMenuOpen, popoverContentRef, {
selector:
'[data-attr="audio-input-select"] button, [data-attr="audio-input-select"] [role="combobox"]',
delayMs: 250,
})
return (
<div
className={css({
@@ -76,7 +85,12 @@ export const AudioDevicesControl = ({
}}
/>
{!hideMenu && (
<Popover variant="dark" withArrow={false}>
<Popover
variant="dark"
withArrow={false}
isOpen={isMenuOpen}
onOpenChange={setIsMenuOpen}
>
<Button
tooltip={selectLabel}
aria-label={selectLabel}
@@ -92,6 +106,7 @@ export const AudioDevicesControl = ({
</Button>
{({ close }) => (
<div
ref={popoverContentRef}
className={css({
maxWidth: '36rem',
padding: '0.15rem',
@@ -100,6 +115,7 @@ export const AudioDevicesControl = ({
})}
>
<div
data-attr="audio-input-select"
style={{
flex: '1 1 0',
minWidth: 0,
@@ -10,6 +10,7 @@ import { usePersistentUserChoices } from '../../../hooks/usePersistentUserChoice
import { useCanPublishTrack } from '../../../hooks/useCanPublishTrack'
import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
import { useSidePanel } from '../../../hooks/useSidePanel'
import { useSidePanelTriggers } from '../../../hooks/useSidePanelTriggers'
import { BackgroundProcessorFactory } from '../../blur'
import Source = Track.Source
import * as React from 'react'
@@ -17,10 +18,12 @@ import { SelectDevice } from './SelectDevice'
import { SettingsButton } from './SettingsButton'
import { SettingsDialogExtendedKey } from '@/features/settings/type'
import { TrackSource } from '@livekit/protocol'
import { useFocusOnOpen } from '@/hooks/useFocusOnOpen'
const EffectsButton = ({ onPress }: { onPress: () => void }) => {
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
const { isEffectsOpen, toggleEffects } = useSidePanel()
const { setTrigger } = useSidePanelTriggers()
return (
<Button
size="sm"
@@ -28,6 +31,7 @@ const EffectsButton = ({ onPress }: { onPress: () => void }) => {
tooltip={t('effects')}
aria-label={t('effects')}
variant="primaryDark"
ref={(el) => setTrigger('effects', el)}
onPress={() => {
if (!isEffectsOpen) toggleEffects()
onPress()
@@ -50,6 +54,9 @@ export const VideoDeviceControl = ({
...props
}: VideoDeviceControlProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
const [isMenuOpen, setIsMenuOpen] = React.useState(false)
const popoverContentRef = React.useRef<HTMLDivElement>(null)
const { setTrigger } = useSidePanelTriggers()
const { userChoices, saveVideoInputDeviceId, saveVideoInputEnabled } =
usePersistentUserChoices()
@@ -99,6 +106,12 @@ export const VideoDeviceControl = ({
const selectLabel = t(`settings.${SettingsDialogExtendedKey.VIDEO}`)
const canPublishTrack = useCanPublishTrack(TrackSource.CAMERA)
useFocusOnOpen(isMenuOpen, popoverContentRef, {
selector:
'[data-attr="video-input-select"] button, [data-attr="video-input-select"] [role="combobox"]',
delayMs: 250,
})
return (
<div
className={css({
@@ -120,12 +133,18 @@ export const VideoDeviceControl = ({
}}
/>
{!hideMenu && (
<Popover variant="dark" withArrow={false}>
<Popover
variant="dark"
withArrow={false}
isOpen={isMenuOpen}
onOpenChange={setIsMenuOpen}
>
<Button
tooltip={selectLabel}
aria-label={selectLabel}
groupPosition="right"
square
ref={(el) => setTrigger('cameraMenu', el)}
variant={
!canPublishTrack || !trackProps.enabled || cannotUseDevice
? 'error2'
@@ -136,6 +155,7 @@ export const VideoDeviceControl = ({
</Button>
{({ close }) => (
<div
ref={popoverContentRef}
className={css({
maxWidth: '36rem',
padding: '0.15rem',
@@ -144,6 +164,7 @@ export const VideoDeviceControl = ({
})}
>
<div
data-attr="video-input-select"
style={{
flex: '1 1 0',
minWidth: 0,
@@ -1,9 +1,11 @@
import { useCallback, useRef } 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'
export const InfoToggle = ({
onPress,
@@ -12,8 +14,18 @@ export const InfoToggle = ({
const { t } = useTranslation('rooms', { keyPrefix: 'controls.info' })
const { isInfoOpen, toggleInfo } = useSidePanel()
// const { setTrigger } = useSidePanelTriggers()
const tooltipLabel = isInfoOpen ? 'open' : 'closed'
const wip = useRef<HTMLElement | null>()
// const setInfoTriggerRef = useCallback(
// (el: HTMLElement | null) => {
// setTrigger('info', el)
// },
// [setTrigger]
// )
return (
<div
className={css({
@@ -27,8 +39,9 @@ export const InfoToggle = ({
aria-label={t(tooltipLabel)}
tooltip={t(tooltipLabel)}
isSelected={isInfoOpen}
ref={wip}
onPress={(e) => {
toggleInfo()
toggleInfo(wip)
onPress?.(e)
}}
data-attr={`controls-info-${tooltipLabel}`}
@@ -1,10 +1,19 @@
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'
export const OptionsButton = () => {
const { t } = useTranslation('rooms')
const { setTrigger } = useSidePanelTriggers()
const setOptionsTriggerRef = useCallback(
(el: HTMLElement | null) => {
setTrigger('options', el)
},
[setTrigger]
)
return (
<Menu variant="dark">
@@ -14,6 +23,7 @@ export const OptionsButton = () => {
variant="primaryDark"
aria-label={t('options.buttonLabel')}
tooltip={t('options.buttonLabel')}
ref={setOptionsTriggerRef}
>
<RiMoreFill />
</Button>
@@ -70,6 +70,7 @@ export function ParticipantsCollapsableList<T>({
<ToggleHeader
isSelected={isOpen}
aria-label={label}
data-focus-target="list-header"
onPress={() => setIsOpen(!isOpen)}
style={{
borderRadius: !isOpen ? '7px' : undefined,
@@ -12,10 +12,17 @@ import { useWaitingParticipants } from '@/features/rooms/hooks/useWaitingPartici
import { Participant } from 'livekit-client'
import { WaitingParticipant } from '@/features/rooms/api/listWaitingParticipants'
import { MuteEveryoneButton } from './MuteEveryoneButton'
import { useSidePanel } from '../../../hooks/useSidePanel'
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
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.
export const ParticipantsList = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'participants' })
const { isParticipantsOpen } = useSidePanel()
const panelRef = useSidePanelRef()
const { getTrigger } = useSidePanelTriggers()
// 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
@@ -48,6 +55,31 @@ export const ParticipantsList = () => {
const { waitingParticipants, handleParticipantEntry } =
useWaitingParticipants()
// Restore focus to the element that opened the Participants panel
useRestoreFocus(isParticipantsOpen, {
resolveTrigger: (activeEl) => {
return getTrigger('participants') ?? activeEl
},
// Focus the first focusable element when the panel opens
onOpened: () => {
// Use setTimeout + RAF to ensure DOM is fully rendered and transition completed
setTimeout(() => {
requestAnimationFrame(() => {
const panel = panelRef.current
if (panel) {
// Find the first ToggleHeader (collapsable list header) in the participants panel
const firstListHeader = panel.querySelector<HTMLElement>(
'button[data-focus-target="list-header"]'
)
firstListHeader?.focus({ preventScroll: true })
}
})
}, 100) // Wait for panel slide-in animation to complete
},
restoreFocusRaf: true,
preventScroll: true,
})
// TODO - extract inline styling in a centralized styling file, and avoid magic numbers
return (
<Div overflowY="scroll">
@@ -1,3 +1,4 @@
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { RiGroupLine, RiInfinityLine } from '@remixicon/react'
import { ToggleButton } from '@/primitives'
@@ -6,6 +7,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'
export const ParticipantsToggle = ({
onPress,
@@ -24,8 +26,15 @@ export const ParticipantsToggle = ({
numParticipants && numParticipants > 0 ? numParticipants : 1
const { isParticipantsOpen, toggleParticipants } = useSidePanel()
const { setTrigger } = useSidePanelTriggers()
const tooltipLabel = isParticipantsOpen ? 'open' : 'closed'
const setParticipantsTriggerRef = useCallback(
(el: HTMLElement | null) => {
setTrigger('participants', el)
},
[setTrigger]
)
return (
<div
@@ -42,6 +51,7 @@ export const ParticipantsToggle = ({
count: announcedCount,
})}.`}
isSelected={isParticipantsOpen}
ref={setParticipantsTriggerRef}
onPress={(e) => {
toggleParticipants()
onPress?.(e)
@@ -14,6 +14,7 @@ import { getEmojiLabel } from '@/features/rooms/livekit/utils/reactionUtils'
import { Toolbar as RACToolbar } from 'react-aria-components'
import { Participant } from 'livekit-client'
import useRateLimiter from '@/hooks/useRateLimiter'
import { useEscapeKey } from '@/hooks/useEscapeKey'
// eslint-disable-next-line react-refresh/only-export-components
export enum Emoji {
@@ -37,6 +38,7 @@ export const ReactionsToggle = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
const [reactions, setReactions] = useState<Reaction[]>([])
const instanceIdRef = useRef(0)
const triggerRef = useRef<HTMLButtonElement | null>(null)
const room = useRoomContext()
const [isVisible, setIsVisible] = useState(false)
@@ -102,6 +104,22 @@ export const ReactionsToggle = () => {
}
}, [isVisible, isRendered])
useEscapeKey(
() => {
// Mirror the trigger button behavior (Enter toggles open/close)
triggerRef.current?.click()
requestAnimationFrame(() => {
triggerRef.current?.focus({ preventScroll: true })
})
},
{
isActive: isVisible,
capture: true,
preventDefault: true,
stopPropagation: true,
}
)
return (
<>
<div
@@ -114,6 +132,7 @@ export const ReactionsToggle = () => {
variant="primaryDark"
aria-label={t('button')}
tooltip={t('button')}
ref={triggerRef}
onPress={() => setIsVisible(!isVisible)}
>
<RiEmotionLine />
@@ -1,9 +1,11 @@
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'
export const ToolsToggle = ({
variant = 'primaryTextDark',
@@ -13,7 +15,14 @@ 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]
)
return (
<div
@@ -28,6 +37,7 @@ export const ToolsToggle = ({
aria-label={t(tooltipLabel)}
tooltip={t(tooltipLabel)}
isSelected={isToolsOpen}
ref={setToolsTriggerRef}
onPress={(e) => {
toggleTools()
onPress?.(e)
@@ -5,14 +5,48 @@ import { EffectsConfiguration } from './EffectsConfiguration'
import { usePersistentUserChoices } from '../../hooks/usePersistentUserChoices'
import { useCanPublishTrack } from '@/features/rooms/livekit/hooks/useCanPublishTrack'
import { TrackSource } from '@livekit/protocol'
import { useSidePanel } from '../../hooks/useSidePanel'
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
import { useSidePanelRef } from '../../hooks/useSidePanelRef'
import { useSidePanelTriggers } from '../../hooks/useSidePanelTriggers'
export const Effects = () => {
const { cameraTrack } = useLocalParticipant()
const localCameraTrack = cameraTrack?.track as LocalVideoTrack
const { saveProcessorSerialized } = usePersistentUserChoices()
const { isEffectsOpen } = useSidePanel()
const panelRef = useSidePanelRef()
const { getTrigger } = useSidePanelTriggers()
const canPublishCamera = useCanPublishTrack(TrackSource.CAMERA)
useRestoreFocus(isEffectsOpen, {
resolveTrigger: (activeEl) => {
if (activeEl?.tagName === 'DIV') {
return getTrigger('options') ?? activeEl
}
// For direct button clicks, use the active element as is
return activeEl
},
// Focus the first focusable element when the panel opens
onOpened: () => {
requestAnimationFrame(() => {
const panel = panelRef.current
if (panel) {
// Find the first toggle button (blur light button)
const firstButton = panel.querySelector<HTMLElement>(
'[data-attr="toggle-blur-light"]'
)
if (firstButton) {
firstButton.focus({ preventScroll: true })
}
}
})
},
restoreFocusRaf: true,
preventScroll: true,
})
return (
<div
className={css({
@@ -0,0 +1,29 @@
import { useRef, ReactNode } from 'react'
import { SidePanelContext, SidePanelTriggerKey } from './sidePanelContextValue'
export const SidePanelProvider = ({ children }: { children: ReactNode }) => {
const panelRef = useRef<HTMLElement>(null)
const triggersRef = useRef<Record<SidePanelTriggerKey, HTMLElement | null>>({
participants: null,
tools: null,
info: null,
admin: null,
options: null,
effects: null,
cameraMenu: null,
})
const setTrigger = (key: SidePanelTriggerKey, el: HTMLElement | null) => {
triggersRef.current[key] = el
}
const getTrigger = (key: SidePanelTriggerKey) => {
return triggersRef.current[key] ?? null
}
return (
<SidePanelContext.Provider value={{ panelRef, setTrigger, getTrigger }}>
{children}
</SidePanelContext.Provider>
)
}
@@ -0,0 +1,20 @@
import { createContext } from 'react'
export type SidePanelTriggerKey =
| 'participants'
| 'tools'
| 'info'
| 'admin'
| 'options'
| 'effects'
| 'cameraMenu'
export type SidePanelContextValue = {
panelRef: React.RefObject<HTMLElement>
setTrigger: (key: SidePanelTriggerKey, el: HTMLElement | null) => void
getTrigger: (key: SidePanelTriggerKey) => HTMLElement | null
}
export const SidePanelContext = createContext<SidePanelContextValue | null>(
null
)
@@ -1,5 +1,6 @@
import { useSnapshot } from 'valtio'
import { ref, useSnapshot } from 'valtio'
import { layoutStore } from '@/stores/layout'
import { useEffect } from 'react'
export enum PanelId {
PARTICIPANTS = 'participants',
@@ -56,8 +57,13 @@ export const useSidePanel = () => {
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
}
const toggleInfo = () => {
const toggleInfo = (wip) => {
layoutStore.activePanelId = isInfoOpen ? null : PanelId.INFO
if (!isInfoOpen) {
layoutStore.genericRef = ref(wip)
}
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
}
@@ -71,6 +77,18 @@ export const useSidePanel = () => {
layoutStore.activePanelId = PanelId.TOOLS
}
useEffect(() => {
if (!layoutSnap.activePanelId) {
console.log('$$prout closing')
const trigger = layoutSnap?.genericRef?.current
console.log(trigger)
if (trigger) {
trigger.focus({ preventScroll: true })
trigger.setAttribute('data-restore-focus-visible', '')
}
}
}, [layoutSnap.activePanelId, layoutSnap?.genericRef])
return {
activePanelId,
activeSubPanelId,
@@ -0,0 +1,10 @@
import { useContext } from 'react'
import { SidePanelContext } from '../contexts/sidePanelContextValue'
export const useSidePanelRef = () => {
const context = useContext(SidePanelContext)
if (!context) {
throw new Error('useSidePanelRef must be used within SidePanelProvider')
}
return context.panelRef
}
@@ -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,
}
}
@@ -26,6 +26,7 @@ import { FocusLayout } from '../components/FocusLayout'
import { ParticipantTile } from '../components/ParticipantTile'
import { SidePanel } from '../components/SidePanel'
import { useSidePanel } from '../hooks/useSidePanel'
import { SidePanelProvider } from '../contexts/SidePanelContext'
import { RecordingProvider } from '@/features/recording'
import { ScreenShareErrorModal } from '../components/ScreenShareErrorModal'
import { useConnectionObserver } from '../hooks/useConnectionObserver'
@@ -188,75 +189,77 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
value={layoutContext}
// onPinChange={handleFocusStateChange}
>
<ScreenShareErrorModal
isOpen={isShareErrorVisible}
onClose={() => setIsShareErrorVisible(false)}
/>
<IsIdleDisconnectModal />
<div
// todo - extract these magic values into constant
style={{
position: 'absolute',
inset: isSidePanelOpen
? `var(--lk-grid-gap) calc(358px + 3rem) calc(80px + var(--lk-grid-gap)) 16px`
: `var(--lk-grid-gap) var(--lk-grid-gap) calc(80px + var(--lk-grid-gap))`,
transition: 'inset .5s cubic-bezier(0.4,0,0.2,1) 5ms',
maxHeight: '100%',
}}
>
<LayoutWrapper areSubtitlesOpen={areSubtitlesOpen}>
<div
style={{
display: 'flex',
position: 'relative',
width: '100%',
}}
>
{!focusTrack ? (
<div
className="lk-grid-layout-wrapper"
style={{ height: 'auto' }}
>
<GridLayout tracks={tracks} style={{ padding: 0 }}>
<ParticipantTile />
</GridLayout>
</div>
) : (
<div
className="lk-focus-layout-wrapper"
style={{ height: 'auto' }}
>
<FocusLayoutContainer style={{ padding: 0 }}>
<CarouselLayout
tracks={carouselTracks}
style={{
minWidth: '200px',
}}
>
<SidePanelProvider>
<ScreenShareErrorModal
isOpen={isShareErrorVisible}
onClose={() => setIsShareErrorVisible(false)}
/>
<IsIdleDisconnectModal />
<div
// todo - extract these magic values into constant
style={{
position: 'absolute',
inset: isSidePanelOpen
? `var(--lk-grid-gap) calc(358px + 3rem) calc(80px + var(--lk-grid-gap)) 16px`
: `var(--lk-grid-gap) var(--lk-grid-gap) calc(80px + var(--lk-grid-gap))`,
transition: 'inset .5s cubic-bezier(0.4,0,0.2,1) 5ms',
maxHeight: '100%',
}}
>
<LayoutWrapper areSubtitlesOpen={areSubtitlesOpen}>
<div
style={{
display: 'flex',
position: 'relative',
width: '100%',
}}
>
{!focusTrack ? (
<div
className="lk-grid-layout-wrapper"
style={{ height: 'auto' }}
>
<GridLayout tracks={tracks} style={{ padding: 0 }}>
<ParticipantTile />
</CarouselLayout>
{focusTrack && <FocusLayout trackRef={focusTrack} />}
</FocusLayoutContainer>
</div>
)}
</div>
</LayoutWrapper>
<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 />
</GridLayout>
</div>
) : (
<div
className="lk-focus-layout-wrapper"
style={{ height: 'auto' }}
>
<FocusLayoutContainer style={{ padding: 0 }}>
<CarouselLayout
tracks={carouselTracks}
style={{
minWidth: '200px',
}}
>
<ParticipantTile />
</CarouselLayout>
{focusTrack && <FocusLayout trackRef={focusTrack} />}
</FocusLayoutContainer>
</div>
)}
</div>
</LayoutWrapper>
<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>
)}
<RoomAudioRenderer />
+36
View File
@@ -0,0 +1,36 @@
import { useEffect, useRef } from 'react'
type UseEscapeKeyOptions = {
isActive: boolean
capture?: boolean
preventDefault?: boolean
stopPropagation?: boolean
}
export const useEscapeKey = (
handler: () => void,
{
isActive,
capture = false,
preventDefault = false,
stopPropagation = false,
}: UseEscapeKeyOptions
) => {
const handleRef = useRef(handler)
handleRef.current = handler
useEffect(() => {
if (!isActive) return
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return
if (preventDefault) event.preventDefault()
if (stopPropagation) event.stopPropagation()
handleRef.current()
}
document.addEventListener('keydown', onKeyDown, capture)
return () => {
document.removeEventListener('keydown', onKeyDown, capture)
}
}, [capture, isActive, preventDefault, stopPropagation])
}
+24
View File
@@ -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])
}
+48 -1
View File
@@ -26,6 +26,26 @@ export function useRestoreFocus(
const prevIsOpenRef = useRef(false)
const triggerRef = useRef<HTMLElement | null>(null)
const cleanupRef = useRef<(() => void) | null>(null)
const lastInteractionRef = useRef<'keyboard' | 'mouse' | null>(null)
useEffect(() => {
// Track last interaction type (like native :focus-visible behavior)
const handleKeyDown = () => {
lastInteractionRef.current = 'keyboard'
}
const handleMouseDown = () => {
lastInteractionRef.current = 'mouse'
}
document.addEventListener('keydown', handleKeyDown)
document.addEventListener('mousedown', handleMouseDown)
return () => {
document.removeEventListener('keydown', handleKeyDown)
document.removeEventListener('mousedown', handleMouseDown)
}
}, [])
useEffect(() => {
const wasOpen = prevIsOpenRef.current
@@ -41,7 +61,28 @@ export function useRestoreFocus(
if (wasOpen && !isOpen) {
const trigger = triggerRef.current
if (trigger && document.contains(trigger)) {
const focus = () => trigger.focus({ preventScroll })
const focus = () => {
trigger.focus({ preventScroll })
// Only show focus ring if last interaction was keyboard (like native :focus-visible)
if (lastInteractionRef.current === 'keyboard') {
trigger.setAttribute('data-restore-focus-visible', '')
// Remove focus ring only when the trigger loses focus
const handleBlur = () => {
if (document.contains(trigger)) {
trigger.removeAttribute('data-restore-focus-visible')
}
}
trigger.addEventListener('blur', handleBlur, { once: true })
// Store cleanup for unmount case
cleanupRef.current?.()
cleanupRef.current = () => {
trigger.removeEventListener('blur', handleBlur)
if (document.contains(trigger)) {
trigger.removeAttribute('data-restore-focus-visible')
}
}
}
}
if (restoreFocusRaf) requestAnimationFrame(focus)
else focus()
}
@@ -50,6 +91,12 @@ export function useRestoreFocus(
}
prevIsOpenRef.current = isOpen
// Cleanup: remove focus ring if component unmounts before focus changes
return () => {
cleanupRef.current?.()
cleanupRef.current = null
}
}, [
isOpen,
onClosed,
+11 -1
View File
@@ -70,6 +70,9 @@ export const Popover = ({
children,
variant = 'light',
withArrow = true,
isOpen,
defaultOpen,
onOpenChange,
...dialogProps
}: {
children: [
@@ -80,10 +83,17 @@ export const Popover = ({
]
variant?: 'dark' | 'light'
withArrow?: boolean
isOpen?: boolean
defaultOpen?: boolean
onOpenChange?: (isOpen: boolean) => void
} & Omit<DialogProps, 'children'>) => {
const [trigger, popoverContent] = children
return (
<DialogTrigger>
<DialogTrigger
isOpen={isOpen}
defaultOpen={defaultOpen}
onOpenChange={onOpenChange}
>
{trigger}
<StyledPopover>
{withArrow && (
+2
View File
@@ -10,6 +10,7 @@ type State = {
showSubtitles: boolean
activePanelId: PanelId | null
activeSubPanelId: SubPanelId | null
genericRef: HTMLElement | null
}
export const layoutStore = proxy<State>({
@@ -18,4 +19,5 @@ export const layoutStore = proxy<State>({
showSubtitles: false,
activePanelId: null,
activeSubPanelId: null,
genericRef: null,
})
+12 -1
View File
@@ -23,9 +23,20 @@ body,
}
[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(
[data-rac]
):focus-visible {
):focus-visible,
/* 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,
/* 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-offset: 1px;
}