Compare commits

...

15 Commits

Author SHA1 Message Date
Cyril cb2af9da96 🎨(frontend) fix prettier formatting for room ui components
align formatting with prettier checks to keep ci passing consistently.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-18 14:52:04 +01:00
Cyril ed97f09aad 🐛(frontend) fix focus restore for chevron menus
Ensure focus returns to camera chevron after effects panel closes.
2026-02-05 10:31:24 +01:00
Cyril c803bd9170 fixup! ♻️(frontend) replace side panel context with valtio store 2026-02-05 10:05:53 +01:00
Cyril daa3d6bcc6 ️(frontend) centralize popover focus and reuse side panel close
move popover focus handling into primitive and reuse close helper
2026-02-05 10:05:52 +01:00
Cyril 096968c222 ♻️(frontend) replace side panel context with valtio store
centralize refs/triggers in layout store to keep focus behavior
2026-02-05 10:05:52 +01:00
Cyril 71637dc199 📝(docs) update changelog
Document focus management pattern entry
2026-02-05 10:05:52 +01:00
Cyril 8c60cfeccd 📝(docs) update changelog
Document focus management pattern entry
2026-02-05 10:05:51 +01:00
Cyril f711346073 ️(frontend) focus device menu on open
Add focus hook and trigger refs for device menus
2026-02-05 10:03:28 +01:00
Cyril 5fa287c2b0 ️(frontend) add escape key hook for overlays
Use shared hook to close side panel and reactions
2026-02-05 10:03:27 +01:00
Cyril f5cc76861d 📝(docs) add side panel focus pattern
Document trigger ref and panel ref focus workflow
2026-02-05 10:03:27 +01:00
Cyril 87bc5ff6ed ️(frontend) use trigger refs for side panel focus
Replace global DOM queries with scoped refs for focus management
2026-02-05 10:03:26 +01:00
Cyril 17602ab548 ♻️(frontend) use refs instead of queryselector for panels
Replace document.querySelector with panelRef for robustness
2026-02-05 10:03:26 +01:00
Cyril 4fbcf8d64e (frontend) add auto refocus for effects menu
Restore focus to options button on close, focus first element on open
2026-02-05 10:03:25 +01:00
Cyril d4d484b9b1 🔧(git) ignore personal rules.md file
Allow developers to keep personal rules.md files without committing them
2026-02-05 10:03:25 +01:00
Cyril cfc6b90bd7 ️(frontend) add visible focus ring on restore focus
Show focus ring when restoring focus, keyboard navigation only
2026-02-05 10:03:25 +01:00
30 changed files with 661 additions and 19 deletions
+3
View File
@@ -77,6 +77,9 @@ db.sqlite3
*.iml
.devcontainer
# Personal rules/config files
rules.md
# Egress output
docker/livekit/out
+2 -1
View File
@@ -11,6 +11,7 @@ and this project adheres to
### Changed
- ♿️(frontend) fix form labels and autocomplete wiring #932
- ♿(frontend) improve menu focus management #869
## [1.5.0] - 2026-01-28
@@ -30,9 +31,9 @@ and this project adheres to
### Added
- ✨(frontend) add configurable redirect for unauthenticated users #904
### 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"
@@ -5,6 +5,7 @@ import { css } from '@/styled-system/css'
import { ToggleButtonProps } from '@/primitives/ToggleButton'
import { useIsAdminOrOwner } from '../hooks/useIsAdminOrOwner'
import { useSidePanel } from '../hooks/useSidePanel'
import { useSidePanelTriggerRef } from '../hooks/useSidePanelTriggerRef'
export const AdminToggle = ({
variant = 'primaryTextDark',
@@ -15,9 +16,10 @@ export const AdminToggle = ({
const { isAdminOpen, toggleAdmin } = useSidePanel()
const tooltipLabel = isAdminOpen ? 'open' : 'closed'
const setAdminTriggerRef = useSidePanelTriggerRef('admin')
const hasAdminAccess = useIsAdminOrOwner()
if (!hasAdminAccess) return
if (!hasAdminAccess) return null
return (
<div
@@ -32,6 +34,7 @@ export const AdminToggle = ({
aria-label={t(tooltipLabel)}
tooltip={t(tooltipLabel)}
isSelected={isAdminOpen}
ref={setAdminTriggerRef}
onPress={(e) => {
toggleAdmin()
onPress?.(e)
@@ -9,9 +9,16 @@ import { useRoomData } from '../hooks/useRoomData'
import { formatPinCode } from '../../utils/telephony'
import { useTelephony } from '../hooks/useTelephony'
import { useCopyRoomToClipboard } from '../hooks/useCopyRoomToClipboard'
import { useSidePanel } from '../hooks/useSidePanel'
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
import { useSidePanelRef } from '../hooks/useSidePanelRef'
import { useSidePanelTriggers } from '../hooks/useSidePanelTriggers'
export const Info = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'info' })
const { isInfoOpen } = useSidePanel()
const panelRef = useSidePanelRef()
const { getTrigger } = useSidePanelTriggers()
const data = useRoomData()
const roomUrl = getRouteUrl('room', data?.slug)
@@ -24,6 +31,27 @@ export const Info = () => {
const { isCopied, copyRoomToClipboard } = useCopyRoomToClipboard(data)
// Restore focus to the element that opened the Info panel
useRestoreFocus(isInfoOpen, {
resolveTrigger: (activeEl) => getTrigger('info') ?? activeEl,
// Focus the first focusable element when the panel opens
onOpened: () => {
requestAnimationFrame(() => {
const panel = panelRef.current
if (panel) {
const firstButton = panel.querySelector<HTMLElement>(
'[data-attr="copy-info-sidepanel"]'
)
if (firstButton) {
firstButton.focus({ preventScroll: true })
}
}
})
},
restoreFocusRaf: true,
preventScroll: true,
})
return (
<Div
display="flex"
@@ -71,7 +99,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,
@@ -147,17 +152,27 @@ export const SidePanel = () => {
isInfoOpen,
isSubPanelOpen,
activeSubPanelId,
closeSidePanel,
} = useSidePanel()
const { t } = useTranslation('rooms', { keyPrefix: 'sidePanel' })
const panelRef = useSidePanelRef()
useEscapeKey(
() => {
// Close subpanel + panel together for a consistent Escape behavior
closeSidePanel()
},
{
isActive: isSidePanelOpen,
capture: true,
}
)
return (
<StyledSidePanel
title={t(`heading.${activeSubPanelId || activePanelId}`)}
ariaLabel={t('ariaLabel')}
onClose={() => {
layoutStore.activePanelId = null
layoutStore.activeSubPanelId = null
}}
onClose={closeSidePanel}
closeButtonTooltip={t('closeButton', {
content: t(`content.${activeSubPanelId || activePanelId}`),
})}
@@ -165,7 +180,9 @@ export const SidePanel = () => {
isSubmenu={isSubPanelOpen}
backButtonLabel={t('backToTools')}
onBack={() => (layoutStore.activeSubPanelId = null)}
panelRef={panelRef}
>
{/* keepAlive stays only for Info to reduce memory footprint */}
<Panel isOpen={isParticipantsOpen}>
<ParticipantsList />
</Panel>
@@ -187,3 +204,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 { AUDIO_INPUT_FOCUS_SELECTOR } from './deviceFocusSelectors'
type AudioDevicesControlProps = Omit<
UseTrackToggleProps<Source.Microphone>,
@@ -29,6 +30,7 @@ export const AudioDevicesControl = ({
...props
}: AudioDevicesControlProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
const [isMenuOpen, setIsMenuOpen] = React.useState(false)
const {
userChoices: { audioDeviceId, audioOutputDeviceId },
@@ -76,7 +78,16 @@ export const AudioDevicesControl = ({
}}
/>
{!hideMenu && (
<Popover variant="dark" withArrow={false}>
<Popover
variant="dark"
withArrow={false}
isOpen={isMenuOpen}
onOpenChange={setIsMenuOpen}
focusOnOpen={{
selector: AUDIO_INPUT_FOCUS_SELECTOR,
delayMs: 250,
}}
>
<Button
tooltip={selectLabel}
aria-label={selectLabel}
@@ -100,6 +111,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 { useSidePanelTriggerRef } from '../../../hooks/useSidePanelTriggerRef'
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 { VIDEO_INPUT_FOCUS_SELECTOR } from './deviceFocusSelectors'
const EffectsButton = ({ onPress }: { onPress: () => void }) => {
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
const { isEffectsOpen, toggleEffects } = useSidePanel()
const setEffectsTriggerRef = useSidePanelTriggerRef('effects')
return (
<Button
size="sm"
@@ -28,6 +31,7 @@ const EffectsButton = ({ onPress }: { onPress: () => void }) => {
tooltip={t('effects')}
aria-label={t('effects')}
variant="primaryDark"
ref={setEffectsTriggerRef}
onPress={() => {
if (!isEffectsOpen) toggleEffects()
onPress()
@@ -50,6 +54,8 @@ export const VideoDeviceControl = ({
...props
}: VideoDeviceControlProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
const [isMenuOpen, setIsMenuOpen] = React.useState(false)
const setCameraMenuTriggerRef = useSidePanelTriggerRef('cameraMenu')
const { userChoices, saveVideoInputDeviceId, saveVideoInputEnabled } =
usePersistentUserChoices()
@@ -120,12 +126,22 @@ export const VideoDeviceControl = ({
}}
/>
{!hideMenu && (
<Popover variant="dark" withArrow={false}>
<Popover
variant="dark"
withArrow={false}
isOpen={isMenuOpen}
onOpenChange={setIsMenuOpen}
focusOnOpen={{
selector: VIDEO_INPUT_FOCUS_SELECTOR,
delayMs: 250,
}}
>
<Button
tooltip={selectLabel}
aria-label={selectLabel}
groupPosition="right"
square
ref={setCameraMenuTriggerRef}
variant={
!canPublishTrack || !trackProps.enabled || cannotUseDevice
? 'error2'
@@ -136,6 +152,7 @@ export const VideoDeviceControl = ({
</Button>
{({ close }) => (
<div
data-attr="camera-menu-popover"
className={css({
maxWidth: '36rem',
padding: '0.15rem',
@@ -144,6 +161,7 @@ export const VideoDeviceControl = ({
})}
>
<div
data-attr="video-input-select"
style={{
flex: '1 1 0',
minWidth: 0,
@@ -0,0 +1,5 @@
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"]'
@@ -4,6 +4,7 @@ import { css } from '@/styled-system/css'
import { ToggleButton } from '@/primitives'
import { useSidePanel } from '../../hooks/useSidePanel'
import { ToggleButtonProps } from '@/primitives/ToggleButton'
import { useSidePanelTriggerRef } from '../../hooks/useSidePanelTriggerRef'
export const InfoToggle = ({
onPress,
@@ -13,6 +14,7 @@ export const InfoToggle = ({
const { isInfoOpen, toggleInfo } = useSidePanel()
const tooltipLabel = isInfoOpen ? 'open' : 'closed'
const setInfoTriggerRef = useSidePanelTriggerRef('info')
return (
<div
@@ -27,6 +29,7 @@ export const InfoToggle = ({
aria-label={t(tooltipLabel)}
tooltip={t(tooltipLabel)}
isSelected={isInfoOpen}
ref={setInfoTriggerRef}
onPress={(e) => {
toggleInfo()
onPress?.(e)
@@ -2,9 +2,11 @@ import { useTranslation } from 'react-i18next'
import { RiMoreFill } from '@remixicon/react'
import { Button, Menu } from '@/primitives'
import { OptionsMenuItems } from './OptionsMenuItems'
import { useSidePanelTriggerRef } from '../../../hooks/useSidePanelTriggerRef'
export const OptionsButton = () => {
const { t } = useTranslation('rooms')
const setOptionsTriggerRef = useSidePanelTriggerRef('options')
return (
<Menu variant="dark">
@@ -14,6 +16,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">
@@ -6,6 +6,7 @@ import { css } from '@/styled-system/css'
import { useParticipants } from '@livekit/components-react'
import { useSidePanel } from '../../../hooks/useSidePanel'
import { ToggleButtonProps } from '@/primitives/ToggleButton'
import { useSidePanelTriggerRef } from '../../../hooks/useSidePanelTriggerRef'
export const ParticipantsToggle = ({
onPress,
@@ -24,6 +25,7 @@ export const ParticipantsToggle = ({
numParticipants && numParticipants > 0 ? numParticipants : 1
const { isParticipantsOpen, toggleParticipants } = useSidePanel()
const setParticipantsTriggerRef = useSidePanelTriggerRef('participants')
const tooltipLabel = isParticipantsOpen ? 'open' : 'closed'
@@ -42,6 +44,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 />
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'
import { useSidePanel } from '../../hooks/useSidePanel'
import { css } from '@/styled-system/css'
import { ToggleButtonProps } from '@/primitives/ToggleButton'
import { useSidePanelTriggerRef } from '../../hooks/useSidePanelTriggerRef'
export const ToolsToggle = ({
variant = 'primaryTextDark',
@@ -14,6 +15,7 @@ export const ToolsToggle = ({
const { isToolsOpen, toggleTools } = useSidePanel()
const tooltipLabel = isToolsOpen ? 'open' : 'closed'
const setToolsTriggerRef = useSidePanelTriggerRef('tools')
return (
<div
@@ -28,6 +30,7 @@ export const ToolsToggle = ({
aria-label={t(tooltipLabel)}
tooltip={t(tooltipLabel)}
isSelected={isToolsOpen}
ref={setToolsTriggerRef}
onPress={(e) => {
toggleTools()
onPress?.(e)
@@ -5,14 +5,51 @@ 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
}
if (activeEl?.closest('[data-attr="camera-menu-popover"]')) {
return getTrigger('cameraMenu') ?? 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({
@@ -1,5 +1,7 @@
import { useSnapshot } from 'valtio'
import { layoutStore } from '@/stores/layout'
import { useEffect, useRef } from 'react'
import type { SidePanelTriggerKey } from '../types/sidePanelTypes'
export enum PanelId {
PARTICIPANTS = 'participants',
@@ -19,6 +21,41 @@ export const useSidePanel = () => {
const layoutSnap = useSnapshot(layoutStore)
const activePanelId = layoutSnap.activePanelId
const activeSubPanelId = layoutSnap.activeSubPanelId
const lastInteractionRef = useRef<'keyboard' | 'mouse' | null>(null)
const prevPanelIdRef = useRef<PanelId | null>(activePanelId)
const resolveTrigger = (panelId: PanelId, activeEl: HTMLElement | null) => {
if (activeEl?.tagName === 'DIV') {
if (panelId === PanelId.TOOLS || panelId === PanelId.EFFECTS) {
return layoutStore.sidePanelTriggers.options ?? activeEl
}
}
if (
panelId === PanelId.EFFECTS &&
activeEl?.closest('[data-attr="camera-menu-popover"]')
) {
return layoutStore.sidePanelTriggers.cameraMenu ?? activeEl
}
const triggerKeyByPanel: Partial<Record<PanelId, SidePanelTriggerKey>> = {
[PanelId.PARTICIPANTS]: 'participants',
[PanelId.TOOLS]: 'tools',
[PanelId.INFO]: 'info',
[PanelId.ADMIN]: 'admin',
[PanelId.EFFECTS]: 'effects',
}
const triggerKey = triggerKeyByPanel[panelId]
return triggerKey
? (layoutStore.sidePanelTriggers[triggerKey] ?? activeEl)
: activeEl
}
const storeLastTrigger = (panelId: PanelId) => {
const activeEl = document.activeElement as HTMLElement | null
layoutStore.lastSidePanelTriggerRef.current = resolveTrigger(
panelId,
activeEl
)
}
const isParticipantsOpen = activePanelId == PanelId.PARTICIPANTS
const isEffectsOpen = activePanelId == PanelId.EFFECTS
@@ -32,45 +69,101 @@ export const useSidePanel = () => {
const isSubPanelOpen = !!activeSubPanelId
const toggleAdmin = () => {
if (!isAdminOpen) storeLastTrigger(PanelId.ADMIN)
layoutStore.activePanelId = isAdminOpen ? null : PanelId.ADMIN
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
}
const toggleParticipants = () => {
if (!isParticipantsOpen) storeLastTrigger(PanelId.PARTICIPANTS)
layoutStore.activePanelId = isParticipantsOpen ? null : PanelId.PARTICIPANTS
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
}
const toggleChat = () => {
if (!isChatOpen) storeLastTrigger(PanelId.CHAT)
layoutStore.activePanelId = isChatOpen ? null : PanelId.CHAT
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
}
const toggleEffects = () => {
if (!isEffectsOpen) storeLastTrigger(PanelId.EFFECTS)
layoutStore.activePanelId = isEffectsOpen ? null : PanelId.EFFECTS
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
}
const toggleTools = () => {
if (!isToolsOpen) storeLastTrigger(PanelId.TOOLS)
layoutStore.activePanelId = isToolsOpen ? null : PanelId.TOOLS
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
}
const toggleInfo = () => {
if (!isInfoOpen) storeLastTrigger(PanelId.INFO)
layoutStore.activePanelId = isInfoOpen ? null : PanelId.INFO
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
}
const openTranscript = () => {
storeLastTrigger(PanelId.TOOLS)
layoutStore.activeSubPanelId = SubPanelId.TRANSCRIPT
layoutStore.activePanelId = PanelId.TOOLS
}
const openScreenRecording = () => {
storeLastTrigger(PanelId.TOOLS)
layoutStore.activeSubPanelId = SubPanelId.SCREEN_RECORDING
layoutStore.activePanelId = PanelId.TOOLS
}
const closeSidePanel = () => {
if (isSubPanelOpen) {
layoutStore.activeSubPanelId = null
layoutStore.activePanelId = null
return
}
layoutStore.activePanelId = null
}
useEffect(() => {
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 = prevPanelIdRef.current
if (wasOpen && !activePanelId) {
const trigger = layoutStore.lastSidePanelTriggerRef.current
if (trigger && document.contains(trigger)) {
trigger.focus({ preventScroll: true })
if (lastInteractionRef.current === 'keyboard') {
trigger.setAttribute('data-restore-focus-visible', '')
const handleBlur = () => {
if (document.contains(trigger)) {
trigger.removeAttribute('data-restore-focus-visible')
}
}
trigger.addEventListener('blur', handleBlur, { once: true })
}
}
}
prevPanelIdRef.current = activePanelId
}, [activePanelId])
return {
activePanelId,
activeSubPanelId,
@@ -82,6 +175,7 @@ export const useSidePanel = () => {
toggleInfo,
openTranscript,
openScreenRecording,
closeSidePanel,
isSubPanelOpen,
isChatOpen,
isParticipantsOpen,
@@ -0,0 +1,5 @@
import { layoutStore } from '@/stores/layout'
export const useSidePanelRef = () => {
return layoutStore.sidePanelRef
}
@@ -0,0 +1,13 @@
import { useCallback } from 'react'
import type { SidePanelTriggerKey } from '../types/sidePanelTypes'
import { useSidePanelTriggers } from './useSidePanelTriggers'
export const useSidePanelTriggerRef = (key: SidePanelTriggerKey) => {
const { setTrigger } = useSidePanelTriggers()
return useCallback(
(el: HTMLElement | null) => {
setTrigger(key, el)
},
[key, setTrigger]
)
}
@@ -0,0 +1,13 @@
import { layoutStore } from '@/stores/layout'
import type { SidePanelTriggerKey } from '../types/sidePanelTypes'
export const useSidePanelTriggers = () => {
return {
setTrigger: (key: SidePanelTriggerKey, el: HTMLElement | null) => {
layoutStore.sidePanelTriggers[key] = el
},
getTrigger: (key: SidePanelTriggerKey) => {
return layoutStore.sidePanelTriggers[key] ?? null
},
}
}
@@ -0,0 +1,8 @@
export type SidePanelTriggerKey =
| 'participants'
| 'tools'
| 'info'
| 'admin'
| 'options'
| 'effects'
| 'cameraMenu'
+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,
+90 -3
View File
@@ -1,4 +1,5 @@
import { ReactNode } from 'react'
import type { RefObject } from 'react'
import { ReactNode, useEffect, useRef, useState } from 'react'
import {
DialogProps,
DialogTrigger,
@@ -60,6 +61,40 @@ 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
*
@@ -70,6 +105,11 @@ export const Popover = ({
children,
variant = 'light',
withArrow = true,
isOpen,
defaultOpen,
onOpenChange,
focusOnOpen,
focusOnClose,
...dialogProps
}: {
children: [
@@ -80,10 +120,52 @@ export const Popover = ({
]
variant?: 'dark' | 'light'
withArrow?: boolean
isOpen?: boolean
defaultOpen?: boolean
onOpenChange?: (isOpen: boolean) => void
focusOnOpen?: FocusOnOpenOptions
focusOnClose?: FocusOnCloseOptions
} & Omit<DialogProps, 'children'>) => {
const [trigger, popoverContent] = children
const popoverContentRef = useRef<HTMLDivElement>(null)
const isControlled = isOpen !== undefined
const [internalOpen, setInternalOpen] = useState(!!defaultOpen)
const effectiveOpen = isControlled ? isOpen : internalOpen
const prevOpenRef = useRef(effectiveOpen)
const handleOpenChange = (nextOpen: boolean) => {
if (!isControlled) {
setInternalOpen(nextOpen)
}
onOpenChange?.(nextOpen)
}
useEffect(() => {
const wasOpen = prevOpenRef.current
if (wasOpen || !effectiveOpen || !focusOnOpen) return
const first = popoverContentRef.current?.querySelector<HTMLElement>(
focusOnOpen.selector
)
if (!first) return
return scheduleFocus(first, focusOnOpen)
}, [effectiveOpen, focusOnOpen])
useEffect(() => {
const wasOpen = prevOpenRef.current
let cleanup: (() => void) | undefined
if (wasOpen && !effectiveOpen && focusOnClose) {
const target = resolveFocusTarget(focusOnClose)
if (target) cleanup = scheduleFocus(target, focusOnClose)
}
prevOpenRef.current = effectiveOpen
return cleanup
}, [effectiveOpen, focusOnClose])
return (
<DialogTrigger>
<DialogTrigger
isOpen={effectiveOpen}
defaultOpen={defaultOpen}
onOpenChange={handleOpenChange}
>
{trigger}
<StyledPopover>
{withArrow && (
@@ -95,7 +177,12 @@ export const Popover = ({
)}
<Dialog {...dialogProps}>
{({ close }) => (
<Box size="sm" type="popover" variant={variant}>
<Box
size="sm"
type="popover"
variant={variant}
ref={popoverContentRef}
>
{typeof popoverContent === 'function'
? popoverContent({ close })
: popoverContent}
+24 -1
View File
@@ -1,8 +1,11 @@
import { proxy } from 'valtio'
import { createRef } from 'react'
import { proxy, ref } from 'valtio'
import {
PanelId,
SubPanelId,
} from '@/features/rooms/livekit/hooks/useSidePanel'
import type { SidePanelTriggerKey } from '@/features/rooms/livekit/types/sidePanelTypes'
import type { MutableRefObject, RefObject } from 'react'
type State = {
showHeader: boolean
@@ -10,12 +13,32 @@ type State = {
showSubtitles: boolean
activePanelId: PanelId | null
activeSubPanelId: SubPanelId | null
sidePanelRef: RefObject<HTMLElement>
sidePanelTriggers: Record<SidePanelTriggerKey, HTMLElement | null>
lastSidePanelTriggerRef: MutableRefObject<HTMLElement | null>
}
const sidePanelRef = ref(createRef<HTMLElement>())
const lastSidePanelTriggerRef = ref({
current: null,
} as MutableRefObject<HTMLElement | null>)
const sidePanelTriggers = ref<Record<SidePanelTriggerKey, HTMLElement | null>>({
participants: null,
tools: null,
info: null,
admin: null,
options: null,
effects: null,
cameraMenu: null,
})
export const layoutStore = proxy<State>({
showHeader: false,
showFooter: false,
showSubtitles: false,
activePanelId: null,
activeSubPanelId: null,
sidePanelRef,
sidePanelTriggers,
lastSidePanelTriggerRef,
})
+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;
}