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 *.iml
.devcontainer .devcontainer
# Personal rules/config files
rules.md
# Egress output # Egress output
docker/livekit/out 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). [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased] ## [Unreleased]
### Changed ### Changed
- ♿(frontend) improve menu focus management #869
- ♿️(frontend) add accessible back button in side panel #881 - ♿️(frontend) add accessible back button in side panel #881
- ♿️(frontend) improve participants toggle a11y label #880 - ♿️(frontend) improve participants toggle a11y label #880
- ♿️(frontend) make carousel image decorative #871 - ♿️(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` - 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` - 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 - 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 { useQuery } from '@tanstack/react-query'
import { useParams } from 'wouter' import { useParams } from 'wouter'
import { usePublishSourcesManager } from '@/features/rooms/livekit/hooks/usePublishSourcesManager' 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 = () => { export const Admin = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'admin' }) const { t } = useTranslation('rooms', { keyPrefix: 'admin' })
const { isAdminOpen } = useSidePanel()
const panelRef = useSidePanelRef()
const { getTrigger } = useSidePanelTriggers()
const { roomId } = useParams() const { roomId } = useParams()
@@ -38,6 +45,29 @@ export const Admin = () => {
isScreenShareEnabled, isScreenShareEnabled,
} = usePublishSourcesManager() } = 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 ( return (
<Div <Div
display="flex" display="flex"
@@ -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)
@@ -1,5 +1,5 @@
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useMemo } from 'react' import { useEffect, useMemo, useRef } from 'react'
import { VStack } from '@/styled-system/jsx' import { VStack } from '@/styled-system/jsx'
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
import { RiCheckLine, RiFileCopyLine } from '@remixicon/react' import { RiCheckLine, RiFileCopyLine } from '@remixicon/react'
@@ -10,8 +10,23 @@ import { formatPinCode } from '../../utils/telephony'
import { useTelephony } from '../hooks/useTelephony' import { useTelephony } from '../hooks/useTelephony'
import { useCopyRoomToClipboard } from '../hooks/useCopyRoomToClipboard' 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 = () => { export const Info = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'info' }) const { t } = useTranslation('rooms', { keyPrefix: 'info' })
const panelRef = useRef()
const data = useRoomData() const data = useRoomData()
const roomUrl = getRouteUrl('room', data?.slug) const roomUrl = getRouteUrl('room', data?.slug)
@@ -24,6 +39,8 @@ export const Info = () => {
const { isCopied, copyRoomToClipboard } = useCopyRoomToClipboard(data) const { isCopied, copyRoomToClipboard } = useCopyRoomToClipboard(data)
useFocusOnOpen({ ref: panelRef, key: '[data-attr="copy-info-sidepanel"]' })
return ( return (
<Div <Div
display="flex" display="flex"
@@ -32,6 +49,7 @@ export const Info = () => {
flexGrow={1} flexGrow={1}
flexDirection="column" flexDirection="column"
alignItems="start" alignItems="start"
ref={panelRef}
> >
<VStack alignItems="start"> <VStack alignItems="start">
<Text <Text
@@ -71,7 +89,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,6 +13,8 @@ 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 { useSidePanelRef } from '../hooks/useSidePanelRef'
import { useEscapeKey } from '@/hooks/useEscapeKey'
import { HStack } from '@/styled-system/jsx' import { HStack } from '@/styled-system/jsx'
type StyledSidePanelProps = { type StyledSidePanelProps = {
@@ -24,6 +26,7 @@ type StyledSidePanelProps = {
closeButtonTooltip: string closeButtonTooltip: string
isSubmenu: boolean isSubmenu: boolean
onBack: () => void onBack: () => void
panelRef: React.RefObject<HTMLElement>
backButtonLabel: string backButtonLabel: string
} }
@@ -36,9 +39,11 @@ const StyledSidePanel = ({
closeButtonTooltip, closeButtonTooltip,
isSubmenu = false, isSubmenu = false,
onBack, onBack,
panelRef,
backButtonLabel, backButtonLabel,
}: StyledSidePanelProps) => ( }: StyledSidePanelProps) => (
<aside <aside
ref={panelRef}
className={css({ className={css({
borderWidth: '1px', borderWidth: '1px',
borderStyle: 'solid', borderStyle: 'solid',
@@ -135,7 +140,7 @@ const Panel = ({ isOpen, keepAlive = false, children }: PanelProps) => (
{keepAlive || isOpen ? children : null} {keepAlive || isOpen ? children : null}
</div> </div>
) )
export const SidePanel = () => { const SidePanelContent = () => {
const { const {
activePanelId, activePanelId,
isParticipantsOpen, isParticipantsOpen,
@@ -149,6 +154,23 @@ export const SidePanel = () => {
activeSubPanelId, activeSubPanelId,
} = useSidePanel() } = useSidePanel()
const { t } = useTranslation('rooms', { keyPrefix: 'sidePanel' }) 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 ( return (
<StyledSidePanel <StyledSidePanel
@@ -165,11 +187,14 @@ export const SidePanel = () => {
isSubmenu={isSubPanelOpen} isSubmenu={isSubPanelOpen}
backButtonLabel={t('backToTools')} backButtonLabel={t('backToTools')}
onBack={() => (layoutStore.activeSubPanelId = null)} 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 /> <ParticipantsList />
</Panel> </Panel>
<Panel isOpen={isEffectsOpen}> <Panel isOpen={isEffectsOpen} keepAlive={true}>
<Effects /> <Effects />
</Panel> </Panel>
<Panel isOpen={isChatOpen} keepAlive={true}> <Panel isOpen={isChatOpen} keepAlive={true}>
@@ -178,7 +203,7 @@ export const SidePanel = () => {
<Panel isOpen={isToolsOpen} keepAlive={true}> <Panel isOpen={isToolsOpen} keepAlive={true}>
<Tools /> <Tools />
</Panel> </Panel>
<Panel isOpen={isAdminOpen}> <Panel isOpen={isAdminOpen} keepAlive={true}>
<Admin /> <Admin />
</Panel> </Panel>
<Panel isOpen={isInfoOpen}> <Panel isOpen={isInfoOpen}>
@@ -187,3 +212,7 @@ export const SidePanel = () => {
</StyledSidePanel> </StyledSidePanel>
) )
} }
export const SidePanel = () => {
return <SidePanelContent />
}
@@ -5,6 +5,8 @@ import { useTranslation } from 'react-i18next'
import { ReactNode } from 'react' 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 { useSidePanelTriggers } from '../hooks/useSidePanelTriggers'
import { import {
useIsRecordingModeEnabled, useIsRecordingModeEnabled,
RecordingMode, RecordingMode,
@@ -98,6 +100,8 @@ export const Tools = () => {
const { openTranscript, openScreenRecording, activeSubPanelId, isToolsOpen } = const { openTranscript, openScreenRecording, activeSubPanelId, isToolsOpen } =
useSidePanel() useSidePanel()
const { t } = useTranslation('rooms', { keyPrefix: 'moreTools' }) const { t } = useTranslation('rooms', { keyPrefix: 'moreTools' })
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.
@@ -106,10 +110,30 @@ 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
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, restoreFocusRaf: true,
preventScroll: true, preventScroll: true,
@@ -141,6 +165,7 @@ export const Tools = () => {
flexDirection="column" flexDirection="column"
alignItems="start" alignItems="start"
gap={0.5} gap={0.5}
data-attr="tools-list"
> >
<Text <Text
variant="note" variant="note"
@@ -1,3 +1,4 @@
import React from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useTrackToggle, UseTrackToggleProps } from '@livekit/components-react' import { useTrackToggle, UseTrackToggleProps } from '@livekit/components-react'
import { Button, Popover } from '@/primitives' import { Button, Popover } from '@/primitives'
@@ -9,13 +10,13 @@ import { css } from '@/styled-system/css'
import { usePersistentUserChoices } from '../../../hooks/usePersistentUserChoices' import { usePersistentUserChoices } from '../../../hooks/usePersistentUserChoices'
import { useCanPublishTrack } from '../../../hooks/useCanPublishTrack' import { useCanPublishTrack } from '../../../hooks/useCanPublishTrack'
import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice' import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
import * as React from 'react'
import { SelectDevice } from './SelectDevice' import { SelectDevice } from './SelectDevice'
import { SettingsButton } from './SettingsButton' import { SettingsButton } from './SettingsButton'
import { SettingsDialogExtendedKey } from '@/features/settings/type' import { SettingsDialogExtendedKey } from '@/features/settings/type'
import { TrackSource } from '@livekit/protocol' import { TrackSource } from '@livekit/protocol'
import Source = Track.Source import Source = Track.Source
import { isSafari } from '@/utils/livekit' import { isSafari } from '@/utils/livekit'
import { useFocusOnOpen } from '@/hooks/useFocusOnOpen'
type AudioDevicesControlProps = Omit< type AudioDevicesControlProps = Omit<
UseTrackToggleProps<Source.Microphone>, UseTrackToggleProps<Source.Microphone>,
@@ -29,6 +30,8 @@ export const AudioDevicesControl = ({
...props ...props
}: AudioDevicesControlProps) => { }: AudioDevicesControlProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' }) const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
const [isMenuOpen, setIsMenuOpen] = React.useState(false)
const popoverContentRef = React.useRef<HTMLDivElement>(null)
const { const {
userChoices: { audioDeviceId, audioOutputDeviceId }, userChoices: { audioDeviceId, audioOutputDeviceId },
@@ -55,6 +58,12 @@ export const AudioDevicesControl = ({
const canPublishTrack = useCanPublishTrack(TrackSource.MICROPHONE) const canPublishTrack = useCanPublishTrack(TrackSource.MICROPHONE)
useFocusOnOpen(isMenuOpen, popoverContentRef, {
selector:
'[data-attr="audio-input-select"] button, [data-attr="audio-input-select"] [role="combobox"]',
delayMs: 250,
})
return ( return (
<div <div
className={css({ className={css({
@@ -76,7 +85,12 @@ export const AudioDevicesControl = ({
}} }}
/> />
{!hideMenu && ( {!hideMenu && (
<Popover variant="dark" withArrow={false}> <Popover
variant="dark"
withArrow={false}
isOpen={isMenuOpen}
onOpenChange={setIsMenuOpen}
>
<Button <Button
tooltip={selectLabel} tooltip={selectLabel}
aria-label={selectLabel} aria-label={selectLabel}
@@ -92,6 +106,7 @@ export const AudioDevicesControl = ({
</Button> </Button>
{({ close }) => ( {({ close }) => (
<div <div
ref={popoverContentRef}
className={css({ className={css({
maxWidth: '36rem', maxWidth: '36rem',
padding: '0.15rem', padding: '0.15rem',
@@ -100,6 +115,7 @@ export const AudioDevicesControl = ({
})} })}
> >
<div <div
data-attr="audio-input-select"
style={{ style={{
flex: '1 1 0', flex: '1 1 0',
minWidth: 0, minWidth: 0,
@@ -10,6 +10,7 @@ import { usePersistentUserChoices } from '../../../hooks/usePersistentUserChoice
import { useCanPublishTrack } from '../../../hooks/useCanPublishTrack' import { useCanPublishTrack } from '../../../hooks/useCanPublishTrack'
import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice' import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
import { useSidePanel } from '../../../hooks/useSidePanel' import { useSidePanel } from '../../../hooks/useSidePanel'
import { useSidePanelTriggers } from '../../../hooks/useSidePanelTriggers'
import { BackgroundProcessorFactory } from '../../blur' import { BackgroundProcessorFactory } from '../../blur'
import Source = Track.Source import Source = Track.Source
import * as React from 'react' import * as React from 'react'
@@ -17,10 +18,12 @@ import { SelectDevice } from './SelectDevice'
import { SettingsButton } from './SettingsButton' import { SettingsButton } from './SettingsButton'
import { SettingsDialogExtendedKey } from '@/features/settings/type' import { SettingsDialogExtendedKey } from '@/features/settings/type'
import { TrackSource } from '@livekit/protocol' import { TrackSource } from '@livekit/protocol'
import { useFocusOnOpen } from '@/hooks/useFocusOnOpen'
const EffectsButton = ({ onPress }: { onPress: () => void }) => { const EffectsButton = ({ onPress }: { onPress: () => void }) => {
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' }) const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
const { isEffectsOpen, toggleEffects } = useSidePanel() const { isEffectsOpen, toggleEffects } = useSidePanel()
const { setTrigger } = useSidePanelTriggers()
return ( return (
<Button <Button
size="sm" size="sm"
@@ -28,6 +31,7 @@ const EffectsButton = ({ onPress }: { onPress: () => void }) => {
tooltip={t('effects')} tooltip={t('effects')}
aria-label={t('effects')} aria-label={t('effects')}
variant="primaryDark" variant="primaryDark"
ref={(el) => setTrigger('effects', el)}
onPress={() => { onPress={() => {
if (!isEffectsOpen) toggleEffects() if (!isEffectsOpen) toggleEffects()
onPress() onPress()
@@ -50,6 +54,9 @@ export const VideoDeviceControl = ({
...props ...props
}: VideoDeviceControlProps) => { }: VideoDeviceControlProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' }) 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 } = const { userChoices, saveVideoInputDeviceId, saveVideoInputEnabled } =
usePersistentUserChoices() usePersistentUserChoices()
@@ -99,6 +106,12 @@ export const VideoDeviceControl = ({
const selectLabel = t(`settings.${SettingsDialogExtendedKey.VIDEO}`) const selectLabel = t(`settings.${SettingsDialogExtendedKey.VIDEO}`)
const canPublishTrack = useCanPublishTrack(TrackSource.CAMERA) const canPublishTrack = useCanPublishTrack(TrackSource.CAMERA)
useFocusOnOpen(isMenuOpen, popoverContentRef, {
selector:
'[data-attr="video-input-select"] button, [data-attr="video-input-select"] [role="combobox"]',
delayMs: 250,
})
return ( return (
<div <div
className={css({ className={css({
@@ -120,12 +133,18 @@ export const VideoDeviceControl = ({
}} }}
/> />
{!hideMenu && ( {!hideMenu && (
<Popover variant="dark" withArrow={false}> <Popover
variant="dark"
withArrow={false}
isOpen={isMenuOpen}
onOpenChange={setIsMenuOpen}
>
<Button <Button
tooltip={selectLabel} tooltip={selectLabel}
aria-label={selectLabel} aria-label={selectLabel}
groupPosition="right" groupPosition="right"
square square
ref={(el) => setTrigger('cameraMenu', el)}
variant={ variant={
!canPublishTrack || !trackProps.enabled || cannotUseDevice !canPublishTrack || !trackProps.enabled || cannotUseDevice
? 'error2' ? 'error2'
@@ -136,6 +155,7 @@ export const VideoDeviceControl = ({
</Button> </Button>
{({ close }) => ( {({ close }) => (
<div <div
ref={popoverContentRef}
className={css({ className={css({
maxWidth: '36rem', maxWidth: '36rem',
padding: '0.15rem', padding: '0.15rem',
@@ -144,6 +164,7 @@ export const VideoDeviceControl = ({
})} })}
> >
<div <div
data-attr="video-input-select"
style={{ style={{
flex: '1 1 0', flex: '1 1 0',
minWidth: 0, minWidth: 0,
@@ -1,9 +1,11 @@
import { useCallback, useRef } 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,8 +14,18 @@ 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 wip = useRef<HTMLElement | null>()
// const setInfoTriggerRef = useCallback(
// (el: HTMLElement | null) => {
// setTrigger('info', el)
// },
// [setTrigger]
// )
return ( return (
<div <div
className={css({ className={css({
@@ -27,8 +39,9 @@ export const InfoToggle = ({
aria-label={t(tooltipLabel)} aria-label={t(tooltipLabel)}
tooltip={t(tooltipLabel)} tooltip={t(tooltipLabel)}
isSelected={isInfoOpen} isSelected={isInfoOpen}
ref={wip}
onPress={(e) => { onPress={(e) => {
toggleInfo() toggleInfo(wip)
onPress?.(e) onPress?.(e)
}} }}
data-attr={`controls-info-${tooltipLabel}`} data-attr={`controls-info-${tooltipLabel}`}
@@ -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>
@@ -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,
@@ -12,10 +12,17 @@ import { useWaitingParticipants } from '@/features/rooms/hooks/useWaitingPartici
import { Participant } from 'livekit-client' import { Participant } from 'livekit-client'
import { WaitingParticipant } from '@/features/rooms/api/listWaitingParticipants' import { WaitingParticipant } from '@/features/rooms/api/listWaitingParticipants'
import { MuteEveryoneButton } from './MuteEveryoneButton' 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. // 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 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
@@ -48,6 +55,31 @@ export const ParticipantsList = () => {
const { waitingParticipants, handleParticipantEntry } = const { waitingParticipants, handleParticipantEntry } =
useWaitingParticipants() 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 // TODO - extract inline styling in a centralized styling file, and avoid magic numbers
return ( return (
<Div overflowY="scroll"> <Div overflowY="scroll">
@@ -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)
@@ -14,6 +14,7 @@ import { getEmojiLabel } from '@/features/rooms/livekit/utils/reactionUtils'
import { Toolbar as RACToolbar } from 'react-aria-components' import { Toolbar as RACToolbar } from 'react-aria-components'
import { Participant } from 'livekit-client' import { Participant } from 'livekit-client'
import useRateLimiter from '@/hooks/useRateLimiter' import useRateLimiter from '@/hooks/useRateLimiter'
import { useEscapeKey } from '@/hooks/useEscapeKey'
// eslint-disable-next-line react-refresh/only-export-components // eslint-disable-next-line react-refresh/only-export-components
export enum Emoji { export enum Emoji {
@@ -37,6 +38,7 @@ export const ReactionsToggle = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' }) const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
const [reactions, setReactions] = useState<Reaction[]>([]) const [reactions, setReactions] = useState<Reaction[]>([])
const instanceIdRef = useRef(0) const instanceIdRef = useRef(0)
const triggerRef = useRef<HTMLButtonElement | null>(null)
const room = useRoomContext() const room = useRoomContext()
const [isVisible, setIsVisible] = useState(false) const [isVisible, setIsVisible] = useState(false)
@@ -102,6 +104,22 @@ export const ReactionsToggle = () => {
} }
}, [isVisible, isRendered]) }, [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 ( return (
<> <>
<div <div
@@ -114,6 +132,7 @@ export const ReactionsToggle = () => {
variant="primaryDark" variant="primaryDark"
aria-label={t('button')} aria-label={t('button')}
tooltip={t('button')} tooltip={t('button')}
ref={triggerRef}
onPress={() => setIsVisible(!isVisible)} onPress={() => setIsVisible(!isVisible)}
> >
<RiEmotionLine /> <RiEmotionLine />
@@ -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)
@@ -5,14 +5,48 @@ import { EffectsConfiguration } from './EffectsConfiguration'
import { usePersistentUserChoices } from '../../hooks/usePersistentUserChoices' import { usePersistentUserChoices } from '../../hooks/usePersistentUserChoices'
import { useCanPublishTrack } from '@/features/rooms/livekit/hooks/useCanPublishTrack' import { useCanPublishTrack } from '@/features/rooms/livekit/hooks/useCanPublishTrack'
import { TrackSource } from '@livekit/protocol' 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 = () => { export const Effects = () => {
const { cameraTrack } = useLocalParticipant() const { cameraTrack } = useLocalParticipant()
const localCameraTrack = cameraTrack?.track as LocalVideoTrack const localCameraTrack = cameraTrack?.track as LocalVideoTrack
const { saveProcessorSerialized } = usePersistentUserChoices() const { saveProcessorSerialized } = usePersistentUserChoices()
const { isEffectsOpen } = useSidePanel()
const panelRef = useSidePanelRef()
const { getTrigger } = useSidePanelTriggers()
const canPublishCamera = useCanPublishTrack(TrackSource.CAMERA) 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 ( return (
<div <div
className={css({ 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 { layoutStore } from '@/stores/layout'
import { useEffect } from 'react'
export enum PanelId { export enum PanelId {
PARTICIPANTS = 'participants', PARTICIPANTS = 'participants',
@@ -56,8 +57,13 @@ export const useSidePanel = () => {
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
} }
const toggleInfo = () => { const toggleInfo = (wip) => {
layoutStore.activePanelId = isInfoOpen ? null : PanelId.INFO layoutStore.activePanelId = isInfoOpen ? null : PanelId.INFO
if (!isInfoOpen) {
layoutStore.genericRef = ref(wip)
}
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
} }
@@ -71,6 +77,18 @@ export const useSidePanel = () => {
layoutStore.activePanelId = PanelId.TOOLS 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 { return {
activePanelId, activePanelId,
activeSubPanelId, 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 { 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'
@@ -188,75 +189,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 />
+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 prevIsOpenRef = useRef(false)
const triggerRef = useRef<HTMLElement | null>(null) 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(() => { useEffect(() => {
const wasOpen = prevIsOpenRef.current const wasOpen = prevIsOpenRef.current
@@ -41,7 +61,28 @@ export function useRestoreFocus(
if (wasOpen && !isOpen) { if (wasOpen && !isOpen) {
const trigger = triggerRef.current const trigger = triggerRef.current
if (trigger && document.contains(trigger)) { 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) if (restoreFocusRaf) requestAnimationFrame(focus)
else focus() else focus()
} }
@@ -50,6 +91,12 @@ export function useRestoreFocus(
} }
prevIsOpenRef.current = isOpen prevIsOpenRef.current = isOpen
// Cleanup: remove focus ring if component unmounts before focus changes
return () => {
cleanupRef.current?.()
cleanupRef.current = null
}
}, [ }, [
isOpen, isOpen,
onClosed, onClosed,
+11 -1
View File
@@ -70,6 +70,9 @@ export const Popover = ({
children, children,
variant = 'light', variant = 'light',
withArrow = true, withArrow = true,
isOpen,
defaultOpen,
onOpenChange,
...dialogProps ...dialogProps
}: { }: {
children: [ children: [
@@ -80,10 +83,17 @@ export const Popover = ({
] ]
variant?: 'dark' | 'light' variant?: 'dark' | 'light'
withArrow?: boolean withArrow?: boolean
isOpen?: boolean
defaultOpen?: boolean
onOpenChange?: (isOpen: boolean) => void
} & Omit<DialogProps, 'children'>) => { } & Omit<DialogProps, 'children'>) => {
const [trigger, popoverContent] = children const [trigger, popoverContent] = children
return ( return (
<DialogTrigger> <DialogTrigger
isOpen={isOpen}
defaultOpen={defaultOpen}
onOpenChange={onOpenChange}
>
{trigger} {trigger}
<StyledPopover> <StyledPopover>
{withArrow && ( {withArrow && (
+2
View File
@@ -10,6 +10,7 @@ type State = {
showSubtitles: boolean showSubtitles: boolean
activePanelId: PanelId | null activePanelId: PanelId | null
activeSubPanelId: SubPanelId | null activeSubPanelId: SubPanelId | null
genericRef: HTMLElement | null
} }
export const layoutStore = proxy<State>({ export const layoutStore = proxy<State>({
@@ -18,4 +19,5 @@ export const layoutStore = proxy<State>({
showSubtitles: false, showSubtitles: false,
activePanelId: null, activePanelId: null,
activeSubPanelId: 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-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) */
[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;
} }