mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-26 20:08:24 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2861aa143e | |||
| 4748b55089 |
@@ -8,6 +8,10 @@ and this project adheres to
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- ♻️(frontend) Use React Aria FocusScope for side panel accessibility
|
||||||
|
|
||||||
## [1.7.0] - 2026-02-19
|
## [1.7.0] - 2026-02-19
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { RiArrowLeftLine, RiCloseLine } from '@remixicon/react'
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { ParticipantsList } from './controls/Participants/ParticipantsList'
|
import { ParticipantsList } from './controls/Participants/ParticipantsList'
|
||||||
import { useSidePanel } from '../hooks/useSidePanel'
|
import { useSidePanel } from '../hooks/useSidePanel'
|
||||||
import { ReactNode } from 'react'
|
import { ReactNode, useEffect, useRef } from 'react'
|
||||||
import { Chat } from '../prefabs/Chat'
|
import { Chat } from '../prefabs/Chat'
|
||||||
import { Effects } from './effects/Effects'
|
import { Effects } from './effects/Effects'
|
||||||
import { Admin } from './Admin'
|
import { Admin } from './Admin'
|
||||||
@@ -15,9 +15,11 @@ import { Tools } from './Tools'
|
|||||||
import { Info } from './Info'
|
import { Info } from './Info'
|
||||||
import { HStack } from '@/styled-system/jsx'
|
import { HStack } from '@/styled-system/jsx'
|
||||||
|
|
||||||
|
const SIDE_PANEL_HEADING_ID = 'side-panel-heading'
|
||||||
|
const SIDE_PANEL_CLOSE_ID = 'side-panel-close'
|
||||||
|
|
||||||
type StyledSidePanelProps = {
|
type StyledSidePanelProps = {
|
||||||
title: string
|
title: string
|
||||||
ariaLabel: string
|
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
isClosed: boolean
|
isClosed: boolean
|
||||||
@@ -29,7 +31,6 @@ type StyledSidePanelProps = {
|
|||||||
|
|
||||||
const StyledSidePanel = ({
|
const StyledSidePanel = ({
|
||||||
title,
|
title,
|
||||||
ariaLabel,
|
|
||||||
children,
|
children,
|
||||||
onClose,
|
onClose,
|
||||||
isClosed,
|
isClosed,
|
||||||
@@ -38,7 +39,14 @@ const StyledSidePanel = ({
|
|||||||
onBack,
|
onBack,
|
||||||
backButtonLabel,
|
backButtonLabel,
|
||||||
}: StyledSidePanelProps) => (
|
}: StyledSidePanelProps) => (
|
||||||
|
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- role="dialog" makes this interactive
|
||||||
<aside
|
<aside
|
||||||
|
role="dialog"
|
||||||
|
aria-labelledby={!isClosed ? SIDE_PANEL_HEADING_ID : undefined}
|
||||||
|
aria-hidden={isClosed || undefined}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Escape') onClose()
|
||||||
|
}}
|
||||||
className={css({
|
className={css({
|
||||||
borderWidth: '1px',
|
borderWidth: '1px',
|
||||||
borderStyle: 'solid',
|
borderStyle: 'solid',
|
||||||
@@ -63,57 +71,57 @@ const StyledSidePanel = ({
|
|||||||
style={{
|
style={{
|
||||||
transform: isClosed ? 'translateX(calc(360px + 1.5rem))' : 'none',
|
transform: isClosed ? 'translateX(calc(360px + 1.5rem))' : 'none',
|
||||||
}}
|
}}
|
||||||
aria-hidden={isClosed}
|
|
||||||
aria-label={ariaLabel}
|
|
||||||
>
|
>
|
||||||
<HStack alignItems="center">
|
<HStack alignItems="center">
|
||||||
{isSubmenu && (
|
{isSubmenu && (
|
||||||
<Button
|
<Button
|
||||||
variant="secondaryText"
|
variant="secondaryText"
|
||||||
size="sm"
|
size="sm"
|
||||||
square
|
square
|
||||||
className={css({ marginRight: '0.5rem', marginLeft: '1rem' })}
|
className={css({ marginRight: '0.5rem', marginLeft: '1rem' })}
|
||||||
aria-label={backButtonLabel}
|
aria-label={backButtonLabel}
|
||||||
onPress={onBack}
|
onPress={onBack}
|
||||||
|
>
|
||||||
|
<RiArrowLeftLine size={20} aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Heading
|
||||||
|
id={SIDE_PANEL_HEADING_ID}
|
||||||
|
slot="title"
|
||||||
|
level={1}
|
||||||
|
className={text({ variant: 'h2' })}
|
||||||
|
style={{
|
||||||
|
paddingLeft: isSubmenu ? 0 : '1.5rem',
|
||||||
|
paddingTop: '1rem',
|
||||||
|
display: isClosed ? 'none' : 'flex',
|
||||||
|
justifyContent: 'start',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<RiArrowLeftLine size={20} aria-hidden="true" />
|
{title}
|
||||||
</Button>
|
</Heading>
|
||||||
)}
|
</HStack>
|
||||||
<Heading
|
<Div
|
||||||
slot="title"
|
position="absolute"
|
||||||
level={1}
|
top="5"
|
||||||
className={text({ variant: 'h2' })}
|
right="5"
|
||||||
style={{
|
style={{
|
||||||
paddingLeft: isSubmenu ? 0 : '1.5rem',
|
display: isClosed ? 'none' : undefined,
|
||||||
paddingTop: '1rem',
|
|
||||||
display: isClosed ? 'none' : 'flex',
|
|
||||||
justifyContent: 'start',
|
|
||||||
alignItems: 'center',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{title}
|
<Button
|
||||||
</Heading>
|
id={SIDE_PANEL_CLOSE_ID}
|
||||||
</HStack>
|
invisible
|
||||||
<Div
|
variant="tertiaryText"
|
||||||
position="absolute"
|
size="xs"
|
||||||
top="5"
|
onPress={onClose}
|
||||||
right="5"
|
aria-label={closeButtonTooltip}
|
||||||
style={{
|
tooltip={closeButtonTooltip}
|
||||||
display: isClosed ? 'none' : undefined,
|
>
|
||||||
}}
|
<RiCloseLine />
|
||||||
>
|
</Button>
|
||||||
<Button
|
</Div>
|
||||||
invisible
|
{children}
|
||||||
variant="tertiaryText"
|
|
||||||
size="xs"
|
|
||||||
onPress={onClose}
|
|
||||||
aria-label={closeButtonTooltip}
|
|
||||||
tooltip={closeButtonTooltip}
|
|
||||||
>
|
|
||||||
<RiCloseLine />
|
|
||||||
</Button>
|
|
||||||
</Div>
|
|
||||||
{children}
|
|
||||||
</aside>
|
</aside>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -135,6 +143,7 @@ const Panel = ({ isOpen, keepAlive = false, children }: PanelProps) => (
|
|||||||
{keepAlive || isOpen ? children : null}
|
{keepAlive || isOpen ? children : null}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
export const SidePanel = () => {
|
export const SidePanel = () => {
|
||||||
const {
|
const {
|
||||||
activePanelId,
|
activePanelId,
|
||||||
@@ -150,14 +159,51 @@ export const SidePanel = () => {
|
|||||||
} = useSidePanel()
|
} = useSidePanel()
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'sidePanel' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'sidePanel' })
|
||||||
|
|
||||||
|
const triggerRef = useRef<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
// The aside stays mounted (CSS slide + keepAlive), so we manually handle
|
||||||
|
// auto-focus on open and restore focus on close (via handleClose).
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isSidePanelOpen) return
|
||||||
|
const active = document.activeElement as HTMLElement
|
||||||
|
// Menu items render as DIVs that unmount when the menu closes — resolve to the menu trigger
|
||||||
|
triggerRef.current =
|
||||||
|
active?.tagName === 'DIV'
|
||||||
|
? (document.querySelector<HTMLElement>('#room-options-trigger') ??
|
||||||
|
active)
|
||||||
|
: active
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const closeBtn = document.getElementById(SIDE_PANEL_CLOSE_ID)
|
||||||
|
// Skip if a child panel already moved focus inside (e.g. Chat input)
|
||||||
|
if (closeBtn?.closest('aside')?.contains(document.activeElement)) return
|
||||||
|
closeBtn?.focus({ preventScroll: true })
|
||||||
|
})
|
||||||
|
}, [isSidePanelOpen])
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
const trigger = triggerRef.current
|
||||||
|
triggerRef.current = null
|
||||||
|
layoutStore.activePanelId = null
|
||||||
|
layoutStore.activeSubPanelId = null
|
||||||
|
// Double RAF: first lets React re-render, second lets FocusScope release containment
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (trigger?.isConnected) {
|
||||||
|
trigger.focus({ preventScroll: true })
|
||||||
|
} else {
|
||||||
|
document
|
||||||
|
.querySelector<HTMLElement>('#room-options-trigger')
|
||||||
|
?.focus({ preventScroll: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledSidePanel
|
<StyledSidePanel
|
||||||
title={t(`heading.${activeSubPanelId || activePanelId}`)}
|
title={t(`heading.${activeSubPanelId || activePanelId}`)}
|
||||||
ariaLabel={t('ariaLabel')}
|
onClose={handleClose}
|
||||||
onClose={() => {
|
|
||||||
layoutStore.activePanelId = null
|
|
||||||
layoutStore.activeSubPanelId = null
|
|
||||||
}}
|
|
||||||
closeButtonTooltip={t('closeButton', {
|
closeButtonTooltip={t('closeButton', {
|
||||||
content: t(`content.${activeSubPanelId || activePanelId}`),
|
content: t(`content.${activeSubPanelId || activePanelId}`),
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { Button as RACButton } from 'react-aria-components'
|
|||||||
import { useTranslation } from 'react-i18next'
|
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 {
|
import {
|
||||||
useIsRecordingModeEnabled,
|
useIsRecordingModeEnabled,
|
||||||
RecordingMode,
|
RecordingMode,
|
||||||
@@ -95,26 +94,10 @@ const ToolButton = ({
|
|||||||
|
|
||||||
export const Tools = () => {
|
export const Tools = () => {
|
||||||
const { data } = useConfig()
|
const { data } = useConfig()
|
||||||
const { openTranscript, openScreenRecording, activeSubPanelId, isToolsOpen } =
|
const { openTranscript, openScreenRecording, activeSubPanelId } =
|
||||||
useSidePanel()
|
useSidePanel()
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'moreTools' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'moreTools' })
|
||||||
|
|
||||||
// Restore focus to the element that opened the Tools panel
|
|
||||||
// following the same pattern as Chat.
|
|
||||||
useRestoreFocus(isToolsOpen, {
|
|
||||||
// If the active element is a MenuItem (DIV) that will be unmounted when the menu closes,
|
|
||||||
// find the "more options" button ("Plus d'options") that opened the menu
|
|
||||||
resolveTrigger: (activeEl) => {
|
|
||||||
if (activeEl?.tagName === 'DIV') {
|
|
||||||
return document.querySelector<HTMLElement>('#room-options-trigger')
|
|
||||||
}
|
|
||||||
// For direct button clicks (e.g. "Plus d'outils"), use the active element as is
|
|
||||||
return activeEl
|
|
||||||
},
|
|
||||||
restoreFocusRaf: true,
|
|
||||||
preventScroll: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
const isTranscriptEnabled = useIsRecordingModeEnabled(
|
const isTranscriptEnabled = useIsRecordingModeEnabled(
|
||||||
RecordingMode.Transcript
|
RecordingMode.Transcript
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -80,10 +80,12 @@ export const ChatInput = ({
|
|||||||
<TextArea
|
<TextArea
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
e.stopPropagation()
|
if (e.key !== 'Escape') e.stopPropagation()
|
||||||
submitOnEnter(e)
|
submitOnEnter(e)
|
||||||
}}
|
}}
|
||||||
onKeyUp={(e) => e.stopPropagation()}
|
onKeyUp={(e) => {
|
||||||
|
if (e.key !== 'Escape') e.stopPropagation()
|
||||||
|
}}
|
||||||
placeholder={t('textArea.placeholder')}
|
placeholder={t('textArea.placeholder')}
|
||||||
value={text}
|
value={text}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import { ChatEntry } from '../components/chat/Entry'
|
|||||||
import { useSidePanel } from '../hooks/useSidePanel'
|
import { useSidePanel } from '../hooks/useSidePanel'
|
||||||
import { LocalParticipant, RemoteParticipant, RoomEvent } from 'livekit-client'
|
import { LocalParticipant, RemoteParticipant, RoomEvent } from 'livekit-client'
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
|
|
||||||
|
|
||||||
export interface ChatProps
|
export interface ChatProps
|
||||||
extends React.HTMLAttributes<HTMLDivElement>, ChatOptions {}
|
extends React.HTMLAttributes<HTMLDivElement>, ChatOptions {}
|
||||||
@@ -36,18 +35,12 @@ export function Chat({ ...props }: ChatProps) {
|
|||||||
const { isChatOpen } = useSidePanel()
|
const { isChatOpen } = useSidePanel()
|
||||||
const chatSnap = useSnapshot(chatStore)
|
const chatSnap = useSnapshot(chatStore)
|
||||||
|
|
||||||
// Keep track of the element that opened the chat so we can restore focus
|
React.useEffect(() => {
|
||||||
// when the chat panel is closed.
|
if (!isChatOpen) return
|
||||||
useRestoreFocus(isChatOpen, {
|
requestAnimationFrame(() => {
|
||||||
// Avoid layout "jump" during the side panel slide-in animation.
|
inputRef.current?.focus({ preventScroll: true })
|
||||||
// Focusing can trigger scroll into view; preventScroll keeps the animation smooth.
|
})
|
||||||
onOpened: () => {
|
}, [isChatOpen])
|
||||||
requestAnimationFrame(() => {
|
|
||||||
inputRef.current?.focus({ preventScroll: true })
|
|
||||||
})
|
|
||||||
},
|
|
||||||
preventScroll: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Use useParticipants hook to trigger a re-render when the participant list changes.
|
// Use useParticipants hook to trigger a re-render when the participant list changes.
|
||||||
const participants = useParticipants()
|
const participants = useParticipants()
|
||||||
|
|||||||
Reference in New Issue
Block a user