Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7a7c966b3f | |||
| fadf12626d | |||
| 186723a8c3 | |||
| c6c8a84c41 | |||
| 3a42a859f4 |
+2
-1
@@ -10,9 +10,10 @@ and this project adheres to
|
||||
|
||||
## [1.0.1] - 2025-12-17
|
||||
|
||||
### Changed
|
||||
### Changes
|
||||
|
||||
- ♿(frontend) improve accessibility:
|
||||
- ♿️(frontend) Improve SR and focus for transcript and recording #810
|
||||
- ♿️(frontend) hover controls, focus, SR #803
|
||||
- ♿️(frontend) change ptt keybinding from space to v #813
|
||||
- ♿(frontend) indicate external link opens in new window on feedback #816
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { useRoomContext } from '@livekit/components-react'
|
||||
import { Spinner } from '@/primitives/Spinner'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Text } from '@/primitives'
|
||||
import { RoomEvent } from 'livekit-client'
|
||||
import { decodeNotificationDataReceived } from '@/features/notifications/utils'
|
||||
@@ -31,6 +31,9 @@ export const RecordingStateToast = () => {
|
||||
const { openTranscript, openScreenRecording } = useSidePanel()
|
||||
const [isAlertOpen, setIsAlertOpen] = useState(false)
|
||||
|
||||
const [srMessage, setSrMessage] = useState('')
|
||||
const lastKeyRef = useRef('')
|
||||
|
||||
const recordingSnap = useSnapshot(recordingStore)
|
||||
|
||||
const hasTranscriptAccess = useHasRecordingAccess(
|
||||
@@ -128,6 +131,23 @@ export const RecordingStateToast = () => {
|
||||
}
|
||||
}, [recordingSnap])
|
||||
|
||||
// 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 isAdminOrOwner ? (
|
||||
<LimitReachedAlertDialog
|
||||
@@ -144,67 +164,81 @@ 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',
|
||||
})}
|
||||
>
|
||||
{isStarted ? (
|
||||
<RiRecordCircleLine
|
||||
size={20}
|
||||
className={css({
|
||||
animation: 'pulse_background 1s infinite',
|
||||
})}
|
||||
/>
|
||||
) : (
|
||||
<Spinner size={20} variant="dark" />
|
||||
)}
|
||||
<>
|
||||
{/* Screen reader only message to announce state changes once */}
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
className="sr-only"
|
||||
>
|
||||
{srMessage}
|
||||
</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>
|
||||
{/* 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',
|
||||
})}
|
||||
>
|
||||
{isStarted ? (
|
||||
<RiRecordCircleLine
|
||||
size={20}
|
||||
className={css({
|
||||
animation: 'pulse_background 1s infinite',
|
||||
})}
|
||||
/>
|
||||
) : (
|
||||
<Spinner size={20} variant="dark" />
|
||||
)}
|
||||
|
||||
{!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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
useStartRecording,
|
||||
useStopRecording,
|
||||
} from '@/features/recording'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { ConnectionState, RoomEvent } from 'livekit-client'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RecordingStatus, recordingStore } from '@/stores/recording'
|
||||
@@ -64,6 +64,17 @@ export const ScreenRecordingSidePanel = () => {
|
||||
const isRoomConnected = room.state == ConnectionState.Connected
|
||||
const isRecordingTransitioning = useIsRecordingTransitioning()
|
||||
|
||||
// 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 })
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const handleRecordingStatusChanged = () => {
|
||||
setIsLoading(false)
|
||||
@@ -153,6 +164,7 @@ export const ScreenRecordingSidePanel = () => {
|
||||
{t('stop.body')}
|
||||
</Text>
|
||||
<Button
|
||||
ref={primaryActionRef}
|
||||
isDisabled={isDisabled}
|
||||
onPress={() => handleScreenRecording()}
|
||||
data-attr="stop-screen-recording"
|
||||
@@ -219,6 +231,7 @@ export const ScreenRecordingSidePanel = () => {
|
||||
)}
|
||||
</Text>
|
||||
<Button
|
||||
ref={primaryActionRef}
|
||||
isDisabled={isDisabled}
|
||||
onPress={() => handleScreenRecording()}
|
||||
data-attr="start-screen-recording"
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
useStopRecording,
|
||||
useHasFeatureWithoutAdminRights,
|
||||
} from '../index'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { ConnectionState, RoomEvent } from 'livekit-client'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RecordingStatus, recordingStore } from '@/stores/recording'
|
||||
@@ -77,6 +77,17 @@ export const TranscriptSidePanel = () => {
|
||||
const room = useRoomContext()
|
||||
const isRoomConnected = room.state == ConnectionState.Connected
|
||||
|
||||
// Focus management: focus the primary action button when this side panel opens.
|
||||
const primaryActionRef = useRef<HTMLButtonElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (primaryActionRef.current) {
|
||||
requestAnimationFrame(() => {
|
||||
primaryActionRef.current?.focus({ preventScroll: true })
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const handleRecordingStatusChanged = () => {
|
||||
setIsLoading(false)
|
||||
@@ -227,6 +238,7 @@ export const TranscriptSidePanel = () => {
|
||||
{t('stop.body')}
|
||||
</Text>
|
||||
<Button
|
||||
ref={primaryActionRef}
|
||||
isDisabled={isDisabled}
|
||||
onPress={() => handleTranscript()}
|
||||
data-attr="stop-transcript"
|
||||
@@ -296,6 +308,7 @@ export const TranscriptSidePanel = () => {
|
||||
)}
|
||||
</Text>
|
||||
<Button
|
||||
ref={primaryActionRef}
|
||||
isDisabled={isDisabled}
|
||||
onPress={() => handleTranscript()}
|
||||
data-attr="start-transcript"
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { A, Div, Text } from '@/primitives'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { Button as RACButton } from 'react-aria-components'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ReactNode } from 'react'
|
||||
import { ReactNode, useEffect, useRef } from 'react'
|
||||
import { RiFileTextFill, RiLiveFill } from '@remixicon/react'
|
||||
import { SubPanelId, useSidePanel } from '../hooks/useSidePanel'
|
||||
import {
|
||||
@@ -114,10 +114,49 @@ 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.
|
||||
const toolsTriggerRef = useRef<HTMLElement | null>(null)
|
||||
const prevIsToolsOpenRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
const wasOpen = prevIsToolsOpenRef.current
|
||||
const isOpen = isToolsOpen
|
||||
|
||||
// Tools just opened
|
||||
if (!wasOpen && isOpen) {
|
||||
const activeEl = document.activeElement as HTMLElement | null
|
||||
|
||||
// If the active element is a MenuItem (DIV) that will be unmounted when the menu closes,
|
||||
// find the "Plus d'options" button that opened the menu
|
||||
if (activeEl?.tagName === 'DIV') {
|
||||
toolsTriggerRef.current = document.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Plus d\'options"]'
|
||||
)
|
||||
} else {
|
||||
// For direct button clicks (e.g. "Plus d'outils"), use the active element as is
|
||||
toolsTriggerRef.current = activeEl
|
||||
}
|
||||
}
|
||||
|
||||
// Tools just closed
|
||||
if (wasOpen && !isOpen) {
|
||||
const trigger = toolsTriggerRef.current
|
||||
if (trigger && document.contains(trigger)) {
|
||||
requestAnimationFrame(() => {
|
||||
trigger.focus({ preventScroll: true })
|
||||
})
|
||||
}
|
||||
toolsTriggerRef.current = null
|
||||
}
|
||||
|
||||
prevIsToolsOpenRef.current = isOpen
|
||||
}, [isToolsOpen])
|
||||
|
||||
const isTranscriptEnabled = useIsRecordingModeEnabled(
|
||||
RecordingMode.Transcript
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -6,6 +6,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