mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-28 12:49:34 +00:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ed39758ab4 | |||
| cb2af9da96 | |||
| ed97f09aad | |||
| c803bd9170 | |||
| daa3d6bcc6 | |||
| 096968c222 | |||
| 71637dc199 | |||
| 8c60cfeccd | |||
| f711346073 | |||
| 5fa287c2b0 | |||
| f5cc76861d | |||
| 87bc5ff6ed | |||
| 17602ab548 | |||
| 4fbcf8d64e | |||
| d4d484b9b1 | |||
| cfc6b90bd7 |
@@ -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
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -11,6 +11,7 @@ and this project adheres to
|
|||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- ♿️(frontend) fix form labels and autocomplete wiring #932
|
- ♿️(frontend) fix form labels and autocomplete wiring #932
|
||||||
|
- ♿(frontend) improve menu focus management #869
|
||||||
|
|
||||||
## [1.5.0] - 2026-01-28
|
## [1.5.0] - 2026-01-28
|
||||||
|
|
||||||
@@ -30,9 +31,9 @@ and this project adheres to
|
|||||||
### Added
|
### Added
|
||||||
|
|
||||||
- ✨(frontend) add configurable redirect for unauthenticated users #904
|
- ✨(frontend) add configurable redirect for unauthenticated users #904
|
||||||
|
|
||||||
### 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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { css } from '@/styled-system/css'
|
|||||||
import { ToggleButtonProps } from '@/primitives/ToggleButton'
|
import { ToggleButtonProps } from '@/primitives/ToggleButton'
|
||||||
import { useIsAdminOrOwner } from '../hooks/useIsAdminOrOwner'
|
import { useIsAdminOrOwner } from '../hooks/useIsAdminOrOwner'
|
||||||
import { useSidePanel } from '../hooks/useSidePanel'
|
import { useSidePanel } from '../hooks/useSidePanel'
|
||||||
|
import { useSidePanelTriggerRef } from '../hooks/useSidePanelTriggerRef'
|
||||||
|
|
||||||
export const AdminToggle = ({
|
export const AdminToggle = ({
|
||||||
variant = 'primaryTextDark',
|
variant = 'primaryTextDark',
|
||||||
@@ -15,9 +16,10 @@ export const AdminToggle = ({
|
|||||||
|
|
||||||
const { isAdminOpen, toggleAdmin } = useSidePanel()
|
const { isAdminOpen, toggleAdmin } = useSidePanel()
|
||||||
const tooltipLabel = isAdminOpen ? 'open' : 'closed'
|
const tooltipLabel = isAdminOpen ? 'open' : 'closed'
|
||||||
|
const setAdminTriggerRef = useSidePanelTriggerRef('admin')
|
||||||
|
|
||||||
const hasAdminAccess = useIsAdminOrOwner()
|
const hasAdminAccess = useIsAdminOrOwner()
|
||||||
if (!hasAdminAccess) return
|
if (!hasAdminAccess) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -32,6 +34,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)
|
||||||
|
|||||||
@@ -9,9 +9,16 @@ import { useRoomData } from '../hooks/useRoomData'
|
|||||||
import { formatPinCode } from '../../utils/telephony'
|
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'
|
||||||
|
import { useSidePanel } from '../hooks/useSidePanel'
|
||||||
|
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
|
||||||
|
import { useSidePanelRef } from '../hooks/useSidePanelRef'
|
||||||
|
import { useSidePanelTriggers } from '../hooks/useSidePanelTriggers'
|
||||||
|
|
||||||
export const Info = () => {
|
export const Info = () => {
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'info' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'info' })
|
||||||
|
const { isInfoOpen } = useSidePanel()
|
||||||
|
const panelRef = useSidePanelRef()
|
||||||
|
const { getTrigger } = useSidePanelTriggers()
|
||||||
|
|
||||||
const data = useRoomData()
|
const data = useRoomData()
|
||||||
const roomUrl = getRouteUrl('room', data?.slug)
|
const roomUrl = getRouteUrl('room', data?.slug)
|
||||||
@@ -24,6 +31,27 @@ export const Info = () => {
|
|||||||
|
|
||||||
const { isCopied, copyRoomToClipboard } = useCopyRoomToClipboard(data)
|
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 (
|
return (
|
||||||
<Div
|
<Div
|
||||||
display="flex"
|
display="flex"
|
||||||
@@ -71,7 +99,7 @@ export const Info = () => {
|
|||||||
variant={isCopied ? 'success' : 'tertiaryText'}
|
variant={isCopied ? 'success' : 'tertiaryText'}
|
||||||
aria-label={t('roomInformation.button.ariaLabel')}
|
aria-label={t('roomInformation.button.ariaLabel')}
|
||||||
onPress={copyRoomToClipboard}
|
onPress={copyRoomToClipboard}
|
||||||
data-attr="copy-info-sidepannel"
|
data-attr="copy-info-sidepanel"
|
||||||
style={{
|
style={{
|
||||||
marginLeft: '-8px',
|
marginLeft: '-8px',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -13,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,
|
||||||
@@ -147,17 +152,27 @@ export const SidePanel = () => {
|
|||||||
isInfoOpen,
|
isInfoOpen,
|
||||||
isSubPanelOpen,
|
isSubPanelOpen,
|
||||||
activeSubPanelId,
|
activeSubPanelId,
|
||||||
|
closeSidePanel,
|
||||||
} = 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
|
||||||
|
closeSidePanel()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
isActive: isSidePanelOpen,
|
||||||
|
capture: true,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledSidePanel
|
<StyledSidePanel
|
||||||
title={t(`heading.${activeSubPanelId || activePanelId}`)}
|
title={t(`heading.${activeSubPanelId || activePanelId}`)}
|
||||||
ariaLabel={t('ariaLabel')}
|
ariaLabel={t('ariaLabel')}
|
||||||
onClose={() => {
|
onClose={closeSidePanel}
|
||||||
layoutStore.activePanelId = null
|
|
||||||
layoutStore.activeSubPanelId = null
|
|
||||||
}}
|
|
||||||
closeButtonTooltip={t('closeButton', {
|
closeButtonTooltip={t('closeButton', {
|
||||||
content: t(`content.${activeSubPanelId || activePanelId}`),
|
content: t(`content.${activeSubPanelId || activePanelId}`),
|
||||||
})}
|
})}
|
||||||
@@ -165,7 +180,9 @@ export const SidePanel = () => {
|
|||||||
isSubmenu={isSubPanelOpen}
|
isSubmenu={isSubPanelOpen}
|
||||||
backButtonLabel={t('backToTools')}
|
backButtonLabel={t('backToTools')}
|
||||||
onBack={() => (layoutStore.activeSubPanelId = null)}
|
onBack={() => (layoutStore.activeSubPanelId = null)}
|
||||||
|
panelRef={panelRef}
|
||||||
>
|
>
|
||||||
|
{/* keepAlive stays only for Info to reduce memory footprint */}
|
||||||
<Panel isOpen={isParticipantsOpen}>
|
<Panel isOpen={isParticipantsOpen}>
|
||||||
<ParticipantsList />
|
<ParticipantsList />
|
||||||
</Panel>
|
</Panel>
|
||||||
@@ -187,3 +204,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"
|
||||||
|
|||||||
+14
-2
@@ -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 { AUDIO_INPUT_FOCUS_SELECTOR } from './deviceFocusSelectors'
|
||||||
|
|
||||||
type AudioDevicesControlProps = Omit<
|
type AudioDevicesControlProps = Omit<
|
||||||
UseTrackToggleProps<Source.Microphone>,
|
UseTrackToggleProps<Source.Microphone>,
|
||||||
@@ -29,6 +30,7 @@ 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 {
|
const {
|
||||||
userChoices: { audioDeviceId, audioOutputDeviceId },
|
userChoices: { audioDeviceId, audioOutputDeviceId },
|
||||||
@@ -76,7 +78,16 @@ export const AudioDevicesControl = ({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{!hideMenu && (
|
{!hideMenu && (
|
||||||
<Popover variant="dark" withArrow={false}>
|
<Popover
|
||||||
|
variant="dark"
|
||||||
|
withArrow={false}
|
||||||
|
isOpen={isMenuOpen}
|
||||||
|
onOpenChange={setIsMenuOpen}
|
||||||
|
focusOnOpen={{
|
||||||
|
selector: AUDIO_INPUT_FOCUS_SELECTOR,
|
||||||
|
delayMs: 250,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
tooltip={selectLabel}
|
tooltip={selectLabel}
|
||||||
aria-label={selectLabel}
|
aria-label={selectLabel}
|
||||||
@@ -100,6 +111,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,
|
||||||
|
|||||||
+19
-1
@@ -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 { useSidePanelTriggerRef } from '../../../hooks/useSidePanelTriggerRef'
|
||||||
import { BackgroundProcessorFactory } from '../../blur'
|
import { BackgroundProcessorFactory } from '../../blur'
|
||||||
import Source = Track.Source
|
import Source = Track.Source
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
@@ -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 { VIDEO_INPUT_FOCUS_SELECTOR } from './deviceFocusSelectors'
|
||||||
|
|
||||||
const EffectsButton = ({ onPress }: { onPress: () => void }) => {
|
const EffectsButton = ({ onPress }: { onPress: () => void }) => {
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
|
||||||
const { isEffectsOpen, toggleEffects } = useSidePanel()
|
const { isEffectsOpen, toggleEffects } = useSidePanel()
|
||||||
|
const setEffectsTriggerRef = useSidePanelTriggerRef('effects')
|
||||||
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={setEffectsTriggerRef}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
if (!isEffectsOpen) toggleEffects()
|
if (!isEffectsOpen) toggleEffects()
|
||||||
onPress()
|
onPress()
|
||||||
@@ -50,6 +54,8 @@ 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 setCameraMenuTriggerRef = useSidePanelTriggerRef('cameraMenu')
|
||||||
|
|
||||||
const { userChoices, saveVideoInputDeviceId, saveVideoInputEnabled } =
|
const { userChoices, saveVideoInputDeviceId, saveVideoInputEnabled } =
|
||||||
usePersistentUserChoices()
|
usePersistentUserChoices()
|
||||||
@@ -120,12 +126,22 @@ export const VideoDeviceControl = ({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{!hideMenu && (
|
{!hideMenu && (
|
||||||
<Popover variant="dark" withArrow={false}>
|
<Popover
|
||||||
|
variant="dark"
|
||||||
|
withArrow={false}
|
||||||
|
isOpen={isMenuOpen}
|
||||||
|
onOpenChange={setIsMenuOpen}
|
||||||
|
focusOnOpen={{
|
||||||
|
selector: VIDEO_INPUT_FOCUS_SELECTOR,
|
||||||
|
delayMs: 250,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
tooltip={selectLabel}
|
tooltip={selectLabel}
|
||||||
aria-label={selectLabel}
|
aria-label={selectLabel}
|
||||||
groupPosition="right"
|
groupPosition="right"
|
||||||
square
|
square
|
||||||
|
ref={setCameraMenuTriggerRef}
|
||||||
variant={
|
variant={
|
||||||
!canPublishTrack || !trackProps.enabled || cannotUseDevice
|
!canPublishTrack || !trackProps.enabled || cannotUseDevice
|
||||||
? 'error2'
|
? 'error2'
|
||||||
@@ -136,6 +152,7 @@ export const VideoDeviceControl = ({
|
|||||||
</Button>
|
</Button>
|
||||||
{({ close }) => (
|
{({ close }) => (
|
||||||
<div
|
<div
|
||||||
|
data-attr="camera-menu-popover"
|
||||||
className={css({
|
className={css({
|
||||||
maxWidth: '36rem',
|
maxWidth: '36rem',
|
||||||
padding: '0.15rem',
|
padding: '0.15rem',
|
||||||
@@ -144,6 +161,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,
|
||||||
|
|||||||
+5
@@ -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 { ToggleButton } from '@/primitives'
|
||||||
import { useSidePanel } from '../../hooks/useSidePanel'
|
import { useSidePanel } from '../../hooks/useSidePanel'
|
||||||
import { ToggleButtonProps } from '@/primitives/ToggleButton'
|
import { ToggleButtonProps } from '@/primitives/ToggleButton'
|
||||||
|
import { useSidePanelTriggerRef } from '../../hooks/useSidePanelTriggerRef'
|
||||||
|
|
||||||
export const InfoToggle = ({
|
export const InfoToggle = ({
|
||||||
onPress,
|
onPress,
|
||||||
@@ -13,6 +14,7 @@ export const InfoToggle = ({
|
|||||||
|
|
||||||
const { isInfoOpen, toggleInfo } = useSidePanel()
|
const { isInfoOpen, toggleInfo } = useSidePanel()
|
||||||
const tooltipLabel = isInfoOpen ? 'open' : 'closed'
|
const tooltipLabel = isInfoOpen ? 'open' : 'closed'
|
||||||
|
const setInfoTriggerRef = useSidePanelTriggerRef('info')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -27,6 +29,7 @@ export const InfoToggle = ({
|
|||||||
aria-label={t(tooltipLabel)}
|
aria-label={t(tooltipLabel)}
|
||||||
tooltip={t(tooltipLabel)}
|
tooltip={t(tooltipLabel)}
|
||||||
isSelected={isInfoOpen}
|
isSelected={isInfoOpen}
|
||||||
|
ref={setInfoTriggerRef}
|
||||||
onPress={(e) => {
|
onPress={(e) => {
|
||||||
toggleInfo()
|
toggleInfo()
|
||||||
onPress?.(e)
|
onPress?.(e)
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ 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 { useSidePanelTriggerRef } from '../../../hooks/useSidePanelTriggerRef'
|
||||||
|
|
||||||
export const OptionsButton = () => {
|
export const OptionsButton = () => {
|
||||||
const { t } = useTranslation('rooms')
|
const { t } = useTranslation('rooms')
|
||||||
|
const setOptionsTriggerRef = useSidePanelTriggerRef('options')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu variant="dark">
|
<Menu variant="dark">
|
||||||
@@ -14,6 +16,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>
|
||||||
|
|||||||
+4
-1
@@ -11,7 +11,10 @@ export const SettingsMenuItem = () => {
|
|||||||
return (
|
return (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
className={menuRecipe({ icon: true, variant: 'dark' }).item}
|
className={menuRecipe({ icon: true, variant: 'dark' }).item}
|
||||||
onAction={() => openSettingsDialog()}
|
onAction={() => {
|
||||||
|
// Let MenuTrigger close first to avoid stacked overlays (menu + dialog).
|
||||||
|
window.setTimeout(() => openSettingsDialog(), 0)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<RiSettings3Line size={20} />
|
<RiSettings3Line size={20} />
|
||||||
{t('settings')}
|
{t('settings')}
|
||||||
|
|||||||
+1
@@ -70,6 +70,7 @@ export function ParticipantsCollapsableList<T>({
|
|||||||
<ToggleHeader
|
<ToggleHeader
|
||||||
isSelected={isOpen}
|
isSelected={isOpen}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
|
data-focus-target="list-header"
|
||||||
onPress={() => setIsOpen(!isOpen)}
|
onPress={() => setIsOpen(!isOpen)}
|
||||||
style={{
|
style={{
|
||||||
borderRadius: !isOpen ? '7px' : undefined,
|
borderRadius: !isOpen ? '7px' : undefined,
|
||||||
|
|||||||
+32
@@ -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">
|
||||||
|
|||||||
+3
@@ -6,6 +6,7 @@ import { css } from '@/styled-system/css'
|
|||||||
import { useParticipants } from '@livekit/components-react'
|
import { useParticipants } from '@livekit/components-react'
|
||||||
import { useSidePanel } from '../../../hooks/useSidePanel'
|
import { useSidePanel } from '../../../hooks/useSidePanel'
|
||||||
import { ToggleButtonProps } from '@/primitives/ToggleButton'
|
import { ToggleButtonProps } from '@/primitives/ToggleButton'
|
||||||
|
import { useSidePanelTriggerRef } from '../../../hooks/useSidePanelTriggerRef'
|
||||||
|
|
||||||
export const ParticipantsToggle = ({
|
export const ParticipantsToggle = ({
|
||||||
onPress,
|
onPress,
|
||||||
@@ -24,6 +25,7 @@ export const ParticipantsToggle = ({
|
|||||||
numParticipants && numParticipants > 0 ? numParticipants : 1
|
numParticipants && numParticipants > 0 ? numParticipants : 1
|
||||||
|
|
||||||
const { isParticipantsOpen, toggleParticipants } = useSidePanel()
|
const { isParticipantsOpen, toggleParticipants } = useSidePanel()
|
||||||
|
const setParticipantsTriggerRef = useSidePanelTriggerRef('participants')
|
||||||
|
|
||||||
const tooltipLabel = isParticipantsOpen ? 'open' : 'closed'
|
const tooltipLabel = isParticipantsOpen ? 'open' : 'closed'
|
||||||
|
|
||||||
@@ -42,6 +44,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 />
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ 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 { useSidePanelTriggerRef } from '../../hooks/useSidePanelTriggerRef'
|
||||||
|
|
||||||
export const ToolsToggle = ({
|
export const ToolsToggle = ({
|
||||||
variant = 'primaryTextDark',
|
variant = 'primaryTextDark',
|
||||||
@@ -14,6 +15,7 @@ export const ToolsToggle = ({
|
|||||||
|
|
||||||
const { isToolsOpen, toggleTools } = useSidePanel()
|
const { isToolsOpen, toggleTools } = useSidePanel()
|
||||||
const tooltipLabel = isToolsOpen ? 'open' : 'closed'
|
const tooltipLabel = isToolsOpen ? 'open' : 'closed'
|
||||||
|
const setToolsTriggerRef = useSidePanelTriggerRef('tools')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -28,6 +30,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,51 @@ 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
|
||||||
|
}
|
||||||
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={css({
|
className={css({
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useSnapshot } from 'valtio'
|
import { useSnapshot } from 'valtio'
|
||||||
import { layoutStore } from '@/stores/layout'
|
import { layoutStore } from '@/stores/layout'
|
||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import type { SidePanelTriggerKey } from '../types/sidePanelTypes'
|
||||||
|
|
||||||
export enum PanelId {
|
export enum PanelId {
|
||||||
PARTICIPANTS = 'participants',
|
PARTICIPANTS = 'participants',
|
||||||
@@ -19,6 +21,41 @@ export const useSidePanel = () => {
|
|||||||
const layoutSnap = useSnapshot(layoutStore)
|
const layoutSnap = useSnapshot(layoutStore)
|
||||||
const activePanelId = layoutSnap.activePanelId
|
const activePanelId = layoutSnap.activePanelId
|
||||||
const activeSubPanelId = layoutSnap.activeSubPanelId
|
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 isParticipantsOpen = activePanelId == PanelId.PARTICIPANTS
|
||||||
const isEffectsOpen = activePanelId == PanelId.EFFECTS
|
const isEffectsOpen = activePanelId == PanelId.EFFECTS
|
||||||
@@ -32,45 +69,101 @@ export const useSidePanel = () => {
|
|||||||
const isSubPanelOpen = !!activeSubPanelId
|
const isSubPanelOpen = !!activeSubPanelId
|
||||||
|
|
||||||
const toggleAdmin = () => {
|
const toggleAdmin = () => {
|
||||||
|
if (!isAdminOpen) storeLastTrigger(PanelId.ADMIN)
|
||||||
layoutStore.activePanelId = isAdminOpen ? null : PanelId.ADMIN
|
layoutStore.activePanelId = isAdminOpen ? null : PanelId.ADMIN
|
||||||
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
|
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleParticipants = () => {
|
const toggleParticipants = () => {
|
||||||
|
if (!isParticipantsOpen) storeLastTrigger(PanelId.PARTICIPANTS)
|
||||||
layoutStore.activePanelId = isParticipantsOpen ? null : PanelId.PARTICIPANTS
|
layoutStore.activePanelId = isParticipantsOpen ? null : PanelId.PARTICIPANTS
|
||||||
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
|
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleChat = () => {
|
const toggleChat = () => {
|
||||||
|
if (!isChatOpen) storeLastTrigger(PanelId.CHAT)
|
||||||
layoutStore.activePanelId = isChatOpen ? null : PanelId.CHAT
|
layoutStore.activePanelId = isChatOpen ? null : PanelId.CHAT
|
||||||
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
|
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleEffects = () => {
|
const toggleEffects = () => {
|
||||||
|
if (!isEffectsOpen) storeLastTrigger(PanelId.EFFECTS)
|
||||||
layoutStore.activePanelId = isEffectsOpen ? null : PanelId.EFFECTS
|
layoutStore.activePanelId = isEffectsOpen ? null : PanelId.EFFECTS
|
||||||
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
|
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleTools = () => {
|
const toggleTools = () => {
|
||||||
|
if (!isToolsOpen) storeLastTrigger(PanelId.TOOLS)
|
||||||
layoutStore.activePanelId = isToolsOpen ? null : PanelId.TOOLS
|
layoutStore.activePanelId = isToolsOpen ? null : PanelId.TOOLS
|
||||||
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
|
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleInfo = () => {
|
const toggleInfo = () => {
|
||||||
|
if (!isInfoOpen) storeLastTrigger(PanelId.INFO)
|
||||||
layoutStore.activePanelId = isInfoOpen ? null : PanelId.INFO
|
layoutStore.activePanelId = isInfoOpen ? null : PanelId.INFO
|
||||||
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
|
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
|
||||||
}
|
}
|
||||||
|
|
||||||
const openTranscript = () => {
|
const openTranscript = () => {
|
||||||
|
storeLastTrigger(PanelId.TOOLS)
|
||||||
layoutStore.activeSubPanelId = SubPanelId.TRANSCRIPT
|
layoutStore.activeSubPanelId = SubPanelId.TRANSCRIPT
|
||||||
layoutStore.activePanelId = PanelId.TOOLS
|
layoutStore.activePanelId = PanelId.TOOLS
|
||||||
}
|
}
|
||||||
|
|
||||||
const openScreenRecording = () => {
|
const openScreenRecording = () => {
|
||||||
|
storeLastTrigger(PanelId.TOOLS)
|
||||||
layoutStore.activeSubPanelId = SubPanelId.SCREEN_RECORDING
|
layoutStore.activeSubPanelId = SubPanelId.SCREEN_RECORDING
|
||||||
layoutStore.activePanelId = PanelId.TOOLS
|
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 {
|
return {
|
||||||
activePanelId,
|
activePanelId,
|
||||||
activeSubPanelId,
|
activeSubPanelId,
|
||||||
@@ -82,6 +175,7 @@ export const useSidePanel = () => {
|
|||||||
toggleInfo,
|
toggleInfo,
|
||||||
openTranscript,
|
openTranscript,
|
||||||
openScreenRecording,
|
openScreenRecording,
|
||||||
|
closeSidePanel,
|
||||||
isSubPanelOpen,
|
isSubPanelOpen,
|
||||||
isChatOpen,
|
isChatOpen,
|
||||||
isParticipantsOpen,
|
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'
|
||||||
@@ -19,7 +19,7 @@ import { GeneralTab } from './tabs/GeneralTab'
|
|||||||
import { AudioTab } from './tabs/AudioTab'
|
import { AudioTab } from './tabs/AudioTab'
|
||||||
import { VideoTab } from './tabs/VideoTab'
|
import { VideoTab } from './tabs/VideoTab'
|
||||||
import { TranscriptionTab } from './tabs/TranscriptionTab'
|
import { TranscriptionTab } from './tabs/TranscriptionTab'
|
||||||
import { useRef } from 'react'
|
import { useLayoutEffect, useRef } from 'react'
|
||||||
import { useMediaQuery } from '@/features/rooms/livekit/hooks/useMediaQuery'
|
import { useMediaQuery } from '@/features/rooms/livekit/hooks/useMediaQuery'
|
||||||
import { SettingsDialogExtendedKey } from '@/features/settings/type'
|
import { SettingsDialogExtendedKey } from '@/features/settings/type'
|
||||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
||||||
@@ -61,8 +61,20 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
|
|||||||
const { t } = useTranslation('settings')
|
const { t } = useTranslation('settings')
|
||||||
|
|
||||||
const dialogEl = useRef<HTMLDivElement>(null)
|
const dialogEl = useRef<HTMLDivElement>(null)
|
||||||
const isWideScreen = useMediaQuery('(min-width: 800px)') // fixme - hardcoded 50rem in pixel
|
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!props.isOpen) return
|
||||||
|
console.warn('[a11y] useLayoutEffect fired, dialogEl:', dialogEl.current)
|
||||||
|
console.warn('[a11y] tabs found:', dialogEl.current?.querySelectorAll('[role="tab"]').length)
|
||||||
|
const selected = dialogEl.current?.querySelector<HTMLElement>(
|
||||||
|
'[role="tab"][aria-selected="true"]',
|
||||||
|
)
|
||||||
|
console.warn('[a11y] selected tab:', selected?.textContent)
|
||||||
|
selected?.focus({ preventScroll: true })
|
||||||
|
console.warn('[a11y] activeElement after focus:', document.activeElement?.tagName, document.activeElement?.getAttribute('role'))
|
||||||
|
}, [props.isOpen])
|
||||||
|
|
||||||
|
const isWideScreen = useMediaQuery('(min-width: 800px)') // fixme - hardcoded 50rem in pixel
|
||||||
const isAdminOrOwner = useIsAdminOrOwner()
|
const isAdminOrOwner = useIsAdminOrOwner()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -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])
|
||||||
|
}
|
||||||
@@ -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])
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ const StyledRACDialog = styled(RACDialog, {
|
|||||||
width: 'full',
|
width: 'full',
|
||||||
height: 'full',
|
height: 'full',
|
||||||
pointerEvents: 'none',
|
pointerEvents: 'none',
|
||||||
|
outline: 'none', // focus moves to tab, avoid outline on container
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ReactNode } from 'react'
|
import type { RefObject } from 'react'
|
||||||
|
import { ReactNode, useEffect, useRef, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
DialogProps,
|
DialogProps,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
@@ -60,6 +61,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
|
* 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,
|
children,
|
||||||
variant = 'light',
|
variant = 'light',
|
||||||
withArrow = true,
|
withArrow = true,
|
||||||
|
isOpen,
|
||||||
|
defaultOpen,
|
||||||
|
onOpenChange,
|
||||||
|
focusOnOpen,
|
||||||
|
focusOnClose,
|
||||||
...dialogProps
|
...dialogProps
|
||||||
}: {
|
}: {
|
||||||
children: [
|
children: [
|
||||||
@@ -80,10 +120,52 @@ export const Popover = ({
|
|||||||
]
|
]
|
||||||
variant?: 'dark' | 'light'
|
variant?: 'dark' | 'light'
|
||||||
withArrow?: boolean
|
withArrow?: boolean
|
||||||
|
isOpen?: boolean
|
||||||
|
defaultOpen?: boolean
|
||||||
|
onOpenChange?: (isOpen: boolean) => void
|
||||||
|
focusOnOpen?: FocusOnOpenOptions
|
||||||
|
focusOnClose?: FocusOnCloseOptions
|
||||||
} & Omit<DialogProps, 'children'>) => {
|
} & Omit<DialogProps, 'children'>) => {
|
||||||
const [trigger, popoverContent] = children
|
const [trigger, popoverContent] = children
|
||||||
|
const popoverContentRef = useRef<HTMLDivElement>(null)
|
||||||
|
const isControlled = isOpen !== undefined
|
||||||
|
const [internalOpen, setInternalOpen] = useState(!!defaultOpen)
|
||||||
|
const effectiveOpen = isControlled ? isOpen : internalOpen
|
||||||
|
const prevOpenRef = useRef(effectiveOpen)
|
||||||
|
|
||||||
|
const handleOpenChange = (nextOpen: boolean) => {
|
||||||
|
if (!isControlled) {
|
||||||
|
setInternalOpen(nextOpen)
|
||||||
|
}
|
||||||
|
onOpenChange?.(nextOpen)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
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 (
|
return (
|
||||||
<DialogTrigger>
|
<DialogTrigger
|
||||||
|
isOpen={effectiveOpen}
|
||||||
|
defaultOpen={defaultOpen}
|
||||||
|
onOpenChange={handleOpenChange}
|
||||||
|
>
|
||||||
{trigger}
|
{trigger}
|
||||||
<StyledPopover>
|
<StyledPopover>
|
||||||
{withArrow && (
|
{withArrow && (
|
||||||
@@ -95,7 +177,12 @@ export const Popover = ({
|
|||||||
)}
|
)}
|
||||||
<Dialog {...dialogProps}>
|
<Dialog {...dialogProps}>
|
||||||
{({ close }) => (
|
{({ close }) => (
|
||||||
<Box size="sm" type="popover" variant={variant}>
|
<Box
|
||||||
|
size="sm"
|
||||||
|
type="popover"
|
||||||
|
variant={variant}
|
||||||
|
ref={popoverContentRef}
|
||||||
|
>
|
||||||
{typeof popoverContent === 'function'
|
{typeof popoverContent === 'function'
|
||||||
? popoverContent({ close })
|
? popoverContent({ close })
|
||||||
: popoverContent}
|
: popoverContent}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { proxy } from 'valtio'
|
import { createRef } from 'react'
|
||||||
|
import { proxy, ref } from 'valtio'
|
||||||
import {
|
import {
|
||||||
PanelId,
|
PanelId,
|
||||||
SubPanelId,
|
SubPanelId,
|
||||||
} from '@/features/rooms/livekit/hooks/useSidePanel'
|
} from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||||
|
import type { SidePanelTriggerKey } from '@/features/rooms/livekit/types/sidePanelTypes'
|
||||||
|
import type { MutableRefObject, RefObject } from 'react'
|
||||||
|
|
||||||
type State = {
|
type State = {
|
||||||
showHeader: boolean
|
showHeader: boolean
|
||||||
@@ -10,12 +13,32 @@ type State = {
|
|||||||
showSubtitles: boolean
|
showSubtitles: boolean
|
||||||
activePanelId: PanelId | null
|
activePanelId: PanelId | null
|
||||||
activeSubPanelId: SubPanelId | 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>({
|
export const layoutStore = proxy<State>({
|
||||||
showHeader: false,
|
showHeader: false,
|
||||||
showFooter: false,
|
showFooter: false,
|
||||||
showSubtitles: false,
|
showSubtitles: false,
|
||||||
activePanelId: null,
|
activePanelId: null,
|
||||||
activeSubPanelId: null,
|
activeSubPanelId: null,
|
||||||
|
sidePanelRef,
|
||||||
|
sidePanelTriggers,
|
||||||
|
lastSidePanelTriggerRef,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -22,10 +22,33 @@ body,
|
|||||||
outline: 2px solid transparent;
|
outline: 2px solid transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Suppress focus ring on dialog/tabpanel containers - focus moves to tab */
|
||||||
|
[role='dialog']:focus,
|
||||||
|
[role='dialog'][data-focus-visible],
|
||||||
|
[role='tabpanel'][data-focus-visible],
|
||||||
|
[role='tablist'][data-focus-visible],
|
||||||
|
[role='dialog']
|
||||||
|
[data-rac][data-focus-visible]:not([role='tab']):not(button):not(a):not(
|
||||||
|
input
|
||||||
|
):not(select):not(textarea) {
|
||||||
|
outline: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
[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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user