Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3a1de9f21f | |||
| 434baec983 | |||
| f602fe3905 | |||
| a4fc63b68c | |||
| 1e0f157f4f | |||
| 960fda9c24 |
@@ -11,6 +11,7 @@ and this project adheres to
|
||||
### Changed
|
||||
|
||||
- 📈(frontend) track new recording's modes
|
||||
- ♿️(frontend) improve SR and focus for transcript and recording #810
|
||||
|
||||
## [1.2.0] - 2026-01-05
|
||||
|
||||
|
||||
@@ -42,6 +42,17 @@ export const ControlsButton = ({
|
||||
}: ControlsButtonProps) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: i18nKeyPrefix })
|
||||
|
||||
// Focus management: focus the primary action button when this side panel opens.
|
||||
const primaryActionRef = useRef<HTMLButtonElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
requestAnimationFrame(() => {
|
||||
if (primaryActionRef.current) {
|
||||
primaryActionRef.current.focus({ preventScroll: true })
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
const room = useRoomContext()
|
||||
const isRoomConnected = room.state == ConnectionState.Connected
|
||||
|
||||
@@ -97,6 +108,7 @@ export const ControlsButton = ({
|
||||
fullWidth
|
||||
onPress={handle}
|
||||
isDisabled={isDisabled}
|
||||
ref={primaryActionRef}
|
||||
>
|
||||
{t('button.stop')}
|
||||
</Button>
|
||||
@@ -162,6 +174,7 @@ export const ControlsButton = ({
|
||||
onPress={handle}
|
||||
isDisabled={isDisabled}
|
||||
size="compact"
|
||||
ref={primaryActionRef}
|
||||
>
|
||||
{t('button.start')}
|
||||
</Button>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useMemo } from 'react'
|
||||
import { useMemo, useRef, useState, useEffect } from 'react'
|
||||
import { Text } from '@/primitives'
|
||||
import {
|
||||
RecordingMode,
|
||||
@@ -21,6 +21,9 @@ export const RecordingStateToast = () => {
|
||||
|
||||
const { openTranscript, openScreenRecording } = useSidePanel()
|
||||
|
||||
const [srMessage, setSrMessage] = useState('')
|
||||
const lastKeyRef = useRef('')
|
||||
|
||||
const hasTranscriptAccess = useHasRecordingAccess(
|
||||
RecordingMode.Transcript,
|
||||
FeatureFlags.Transcript
|
||||
@@ -67,6 +70,23 @@ export const RecordingStateToast = () => {
|
||||
return `${metadata.recording_mode}.${status}`
|
||||
}, [metadata, isStarted, isStarting, isRecording])
|
||||
|
||||
// Update screen reader message only when the key actually changes
|
||||
// This prevents duplicate announcements caused by re-renders
|
||||
useEffect(() => {
|
||||
if (key && key !== lastKeyRef.current) {
|
||||
lastKeyRef.current = key
|
||||
const message = t(key)
|
||||
setSrMessage(message)
|
||||
|
||||
// Clear message after 3 seconds to prevent it from being announced again
|
||||
const timer = setTimeout(() => {
|
||||
setSrMessage('')
|
||||
}, 3000)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [key, t])
|
||||
|
||||
if (!key) return null
|
||||
|
||||
const hasScreenRecordingAccessAndActive =
|
||||
@@ -74,61 +94,74 @@ export const RecordingStateToast = () => {
|
||||
const hasTranscriptAccessAndActive = isTranscriptActive && hasTranscriptAccess
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
position: 'fixed',
|
||||
top: '10px',
|
||||
left: '10px',
|
||||
paddingY: '0.25rem',
|
||||
paddingX: '0.75rem 0.75rem',
|
||||
backgroundColor: 'danger.700',
|
||||
borderColor: 'white',
|
||||
border: '1px solid',
|
||||
color: 'white',
|
||||
borderRadius: '4px',
|
||||
gap: '0.5rem',
|
||||
})}
|
||||
>
|
||||
<RecordingStatusIcon
|
||||
isStarted={isStarted}
|
||||
isTranscriptActive={isTranscriptActive}
|
||||
/>
|
||||
<>
|
||||
{/* Screen reader only message to announce state changes once */}
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
className="sr-only"
|
||||
>
|
||||
{srMessage}
|
||||
</div>
|
||||
{/* Visual banner (without aria-live to avoid duplicate announcements) */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
position: 'fixed',
|
||||
top: '10px',
|
||||
left: '10px',
|
||||
paddingY: '0.25rem',
|
||||
paddingX: '0.75rem 0.75rem',
|
||||
backgroundColor: 'danger.700',
|
||||
borderColor: 'white',
|
||||
border: '1px solid',
|
||||
color: 'white',
|
||||
borderRadius: '4px',
|
||||
gap: '0.5rem',
|
||||
})}
|
||||
>
|
||||
<RecordingStatusIcon
|
||||
isStarted={isStarted}
|
||||
isTranscriptActive={isTranscriptActive}
|
||||
/>
|
||||
|
||||
{!hasScreenRecordingAccessAndActive && !hasTranscriptAccessAndActive && (
|
||||
<Text
|
||||
variant={'sm'}
|
||||
className={css({
|
||||
fontWeight: '500 !important',
|
||||
})}
|
||||
>
|
||||
{t(key)}
|
||||
</Text>
|
||||
)}
|
||||
{hasScreenRecordingAccessAndActive && (
|
||||
<RACButton
|
||||
onPress={openScreenRecording}
|
||||
className={css({
|
||||
textStyle: 'sm !important',
|
||||
fontWeight: '500 !important',
|
||||
cursor: 'pointer',
|
||||
})}
|
||||
>
|
||||
{t(key)}
|
||||
</RACButton>
|
||||
)}
|
||||
{hasTranscriptAccessAndActive && (
|
||||
<RACButton
|
||||
onPress={openTranscript}
|
||||
className={css({
|
||||
textStyle: 'sm !important',
|
||||
fontWeight: '500 !important',
|
||||
cursor: 'pointer',
|
||||
})}
|
||||
>
|
||||
{t(key)}
|
||||
</RACButton>
|
||||
)}
|
||||
</div>
|
||||
{!hasScreenRecordingAccessAndActive &&
|
||||
!hasTranscriptAccessAndActive && (
|
||||
<Text
|
||||
variant={'sm'}
|
||||
className={css({
|
||||
fontWeight: '500 !important',
|
||||
})}
|
||||
>
|
||||
{t(key)}
|
||||
</Text>
|
||||
)}
|
||||
{hasScreenRecordingAccessAndActive && (
|
||||
<RACButton
|
||||
onPress={openScreenRecording}
|
||||
className={css({
|
||||
textStyle: 'sm !important',
|
||||
fontWeight: '500 !important',
|
||||
cursor: 'pointer',
|
||||
})}
|
||||
>
|
||||
{t(key)}
|
||||
</RACButton>
|
||||
)}
|
||||
{hasTranscriptAccessAndActive && (
|
||||
<RACButton
|
||||
onPress={openTranscript}
|
||||
className={css({
|
||||
textStyle: 'sm !important',
|
||||
fontWeight: '500 !important',
|
||||
cursor: 'pointer',
|
||||
})}
|
||||
>
|
||||
{t(key)}
|
||||
</RACButton>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -164,7 +164,7 @@ export const SidePanel = () => {
|
||||
<Panel isOpen={isChatOpen} keepAlive={true}>
|
||||
<Chat />
|
||||
</Panel>
|
||||
<Panel isOpen={isToolsOpen}>
|
||||
<Panel isOpen={isToolsOpen} keepAlive={true}>
|
||||
<Tools />
|
||||
</Panel>
|
||||
<Panel isOpen={isAdminOpen}>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Button as RACButton } from 'react-aria-components'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ReactNode } from 'react'
|
||||
import { SubPanelId, useSidePanel } from '../hooks/useSidePanel'
|
||||
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
|
||||
import {
|
||||
useIsRecordingModeEnabled,
|
||||
RecordingMode,
|
||||
@@ -94,10 +95,26 @@ const ToolButton = ({
|
||||
|
||||
export const Tools = () => {
|
||||
const { data } = useConfig()
|
||||
const { openTranscript, openScreenRecording, activeSubPanelId } =
|
||||
const { openTranscript, openScreenRecording, activeSubPanelId, isToolsOpen } =
|
||||
useSidePanel()
|
||||
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(
|
||||
RecordingMode.Transcript
|
||||
)
|
||||
|
||||
@@ -9,6 +9,7 @@ export const OptionsButton = () => {
|
||||
return (
|
||||
<Menu variant="dark">
|
||||
<Button
|
||||
id="room-options-trigger"
|
||||
square
|
||||
variant="primaryDark"
|
||||
aria-label={t('options.buttonLabel')}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ChatMessage, ChatOptions } from '@livekit/components-core'
|
||||
import React, { useEffect } from 'react'
|
||||
import React from 'react'
|
||||
import {
|
||||
formatChatMessageLinks,
|
||||
useChat,
|
||||
@@ -15,6 +15,7 @@ import { ChatEntry } from '../components/chat/Entry'
|
||||
import { useSidePanel } from '../hooks/useSidePanel'
|
||||
import { LocalParticipant, RemoteParticipant, RoomEvent } from 'livekit-client'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
|
||||
|
||||
export interface ChatProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
@@ -38,33 +39,16 @@ export function Chat({ ...props }: ChatProps) {
|
||||
|
||||
// Keep track of the element that opened the chat so we can restore focus
|
||||
// when the chat panel is closed.
|
||||
const prevIsChatOpenRef = React.useRef(false)
|
||||
const chatTriggerRef = React.useRef<HTMLElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const wasChatOpen = prevIsChatOpenRef.current
|
||||
const isChatPanelOpen = isChatOpen
|
||||
|
||||
// Chat just opened
|
||||
if (!wasChatOpen && isChatPanelOpen) {
|
||||
chatTriggerRef.current = document.activeElement as HTMLElement | null
|
||||
// Avoid layout "jump" during the side panel slide-in animation.
|
||||
// Focusing can trigger scroll into view; preventScroll keeps the animation smooth.
|
||||
useRestoreFocus(isChatOpen, {
|
||||
// Avoid layout "jump" during the side panel slide-in animation.
|
||||
// Focusing can trigger scroll into view; preventScroll keeps the animation smooth.
|
||||
onOpened: () => {
|
||||
requestAnimationFrame(() => {
|
||||
inputRef.current?.focus({ preventScroll: true })
|
||||
})
|
||||
}
|
||||
// Chat just closed
|
||||
if (wasChatOpen && !isChatPanelOpen) {
|
||||
const trigger = chatTriggerRef.current
|
||||
if (trigger && document.contains(trigger)) {
|
||||
trigger.focus({ preventScroll: true })
|
||||
}
|
||||
chatTriggerRef.current = null
|
||||
}
|
||||
|
||||
prevIsChatOpenRef.current = isChatPanelOpen
|
||||
}, [isChatOpen])
|
||||
},
|
||||
preventScroll: true,
|
||||
})
|
||||
|
||||
// Use useParticipants hook to trigger a re-render when the participant list changes.
|
||||
const participants = useParticipants()
|
||||
|
||||
@@ -73,6 +73,7 @@ export function MobileControlBar({
|
||||
/>
|
||||
<HandToggle />
|
||||
<Button
|
||||
id="room-options-trigger"
|
||||
square
|
||||
variant="primaryDark"
|
||||
aria-label={t('options.buttonLabel')}
|
||||
|
||||
@@ -37,6 +37,7 @@ export const LateralMenu = () => {
|
||||
return (
|
||||
<DialogTrigger isOpen={isOpen} onOpenChange={setIsOpen}>
|
||||
<Button
|
||||
id="controlbar-more-options-trigger"
|
||||
square
|
||||
variant="secondaryDark"
|
||||
aria-label={t('controls.moreOptions')}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
export type RestoreFocusOptions = {
|
||||
resolveTrigger?: (activeEl: HTMLElement | null) => HTMLElement | null
|
||||
onOpened?: () => void
|
||||
onClosed?: () => void
|
||||
restoreFocusRaf?: boolean
|
||||
preventScroll?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture the element that opened a panel/menu (on open transition) and restore focus to it
|
||||
* when the panel/menu closes.
|
||||
*/
|
||||
export function useRestoreFocus(
|
||||
isOpen: boolean,
|
||||
options: RestoreFocusOptions = {}
|
||||
) {
|
||||
const {
|
||||
resolveTrigger,
|
||||
onOpened,
|
||||
onClosed,
|
||||
restoreFocusRaf = false,
|
||||
preventScroll = true,
|
||||
} = options
|
||||
|
||||
const prevIsOpenRef = useRef(false)
|
||||
const triggerRef = useRef<HTMLElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const wasOpen = prevIsOpenRef.current
|
||||
|
||||
// Just opened
|
||||
if (!wasOpen && isOpen) {
|
||||
const activeEl = document.activeElement as HTMLElement | null
|
||||
triggerRef.current = resolveTrigger ? resolveTrigger(activeEl) : activeEl
|
||||
onOpened?.()
|
||||
}
|
||||
|
||||
// Just closed
|
||||
if (wasOpen && !isOpen) {
|
||||
const trigger = triggerRef.current
|
||||
if (trigger && document.contains(trigger)) {
|
||||
const focus = () => trigger.focus({ preventScroll })
|
||||
if (restoreFocusRaf) requestAnimationFrame(focus)
|
||||
else focus()
|
||||
}
|
||||
triggerRef.current = null
|
||||
onClosed?.()
|
||||
}
|
||||
|
||||
prevIsOpenRef.current = isOpen
|
||||
}, [
|
||||
isOpen,
|
||||
onClosed,
|
||||
onOpened,
|
||||
preventScroll,
|
||||
resolveTrigger,
|
||||
restoreFocusRaf,
|
||||
])
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
import { type RecipeVariantProps } from '@/styled-system/css'
|
||||
import { buttonRecipe, type ButtonRecipe } from './buttonRecipe'
|
||||
import { TooltipWrapper, type TooltipWrapperProps } from './TooltipWrapper'
|
||||
import { ReactNode } from 'react'
|
||||
import { ReactNode, forwardRef } from 'react'
|
||||
import { Loader } from './Loader'
|
||||
|
||||
export type ButtonProps = RecipeVariantProps<ButtonRecipe> &
|
||||
@@ -17,25 +17,24 @@ export type ButtonProps = RecipeVariantProps<ButtonRecipe> &
|
||||
icon?: ReactNode
|
||||
}
|
||||
|
||||
export const Button = ({
|
||||
tooltip,
|
||||
tooltipType = 'instant',
|
||||
...props
|
||||
}: ButtonProps) => {
|
||||
const [variantProps, componentProps] = buttonRecipe.splitVariantProps(props)
|
||||
const { className, ...remainingComponentProps } = componentProps
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ tooltip, tooltipType = 'instant', ...props }, ref) => {
|
||||
const [variantProps, componentProps] = buttonRecipe.splitVariantProps(props)
|
||||
const { className, ...remainingComponentProps } = componentProps
|
||||
|
||||
return (
|
||||
<TooltipWrapper tooltip={tooltip} tooltipType={tooltipType}>
|
||||
<RACButton
|
||||
className={[buttonRecipe(variantProps), className].join(' ')}
|
||||
{...(remainingComponentProps as RACButtonsProps)}
|
||||
>
|
||||
{!props.loading && props.icon}
|
||||
{props.loading && <Loader />}
|
||||
{componentProps.children as ReactNode}
|
||||
{props.description && <span>{tooltip}</span>}
|
||||
</RACButton>
|
||||
</TooltipWrapper>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<TooltipWrapper tooltip={tooltip} tooltipType={tooltipType}>
|
||||
<RACButton
|
||||
ref={ref}
|
||||
className={[buttonRecipe(variantProps), className].join(' ')}
|
||||
{...(remainingComponentProps as RACButtonsProps)}
|
||||
>
|
||||
{!props.loading && props.icon}
|
||||
{props.loading && <Loader />}
|
||||
{componentProps.children as ReactNode}
|
||||
{props.description && <span>{tooltip}</span>}
|
||||
</RACButton>
|
||||
</TooltipWrapper>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -7,6 +7,18 @@ body,
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
* {
|
||||
outline: 2px solid transparent;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user