diff --git a/CHANGELOG.md b/CHANGELOG.md index 878197ce..3829d1bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to ## [Unreleased] + ### Added - ✨(summary) add dutch and german languages @@ -16,6 +17,7 @@ and this project adheres to ### Changed - 📈(frontend) track new recording's modes +- ♿️(frontend) improve accessibility of the background and effects menu - ♿️(frontend) improve SR and focus for transcript and recording #810 - 💄(frontend) adjust spacing in the recording side panels - 🚸(frontend) remove the default comma delimiter in humanized durations diff --git a/src/frontend/src/features/rooms/livekit/components/effects/EffectsConfiguration.tsx b/src/frontend/src/features/rooms/livekit/components/effects/EffectsConfiguration.tsx index 42588372..30ef964d 100644 --- a/src/frontend/src/features/rooms/livekit/components/effects/EffectsConfiguration.tsx +++ b/src/frontend/src/features/rooms/livekit/components/effects/EffectsConfiguration.tsx @@ -9,6 +9,7 @@ import { } from '../blur' import { css } from '@/styled-system/css' import { H, P, Text, ToggleButton } from '@/primitives' +import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip' import { styled } from '@/styled-system/jsx' import { BlurOn } from '@/components/icons/BlurOn' import { BlurOnStrong } from '@/components/icons/BlurOnStrong' @@ -49,11 +50,17 @@ export const EffectsConfiguration = ({ layout = 'horizontal', }: EffectsConfigurationProps) => { const videoRef = useRef(null) + const blurLightRef = useRef(null) const { t } = useTranslation('rooms', { keyPrefix: 'effects' }) const { toggle, enabled } = useTrackToggle({ source: Track.Source.Camera }) const [processorPending, setProcessorPending] = useState(false) const processorPendingReveal = useSyncAfterDelay(processorPending) const hasFunnyEffectsAccess = useHasFunnyEffectsAccess() + const [blurStatusMessage, setBlurStatusMessage] = useState('') + const blurAnnouncementTimeout = useRef | null>( + null + ) + const blurAnnouncementId = useRef(0) useEffect(() => { const videoElement = videoRef.current @@ -68,16 +75,78 @@ export const EffectsConfiguration = ({ } }, [videoTrack, videoTrack?.isMuted]) + useEffect(() => { + if (!blurLightRef.current) return + + const rafId = requestAnimationFrame(() => { + blurLightRef.current?.focus({ preventScroll: true }) + }) + + return () => { + cancelAnimationFrame(rafId) + } + }, []) + + useEffect( + () => () => { + if (blurAnnouncementTimeout.current) { + clearTimeout(blurAnnouncementTimeout.current) + } + }, + [] + ) + + const announceBlurStatusMessage = (message: string) => { + blurAnnouncementId.current += 1 + const currentId = blurAnnouncementId.current + + if (blurAnnouncementTimeout.current) { + clearTimeout(blurAnnouncementTimeout.current) + } + + // Clear the region first so screen readers drop queued announcements. + setBlurStatusMessage('') + + blurAnnouncementTimeout.current = setTimeout(() => { + if (currentId !== blurAnnouncementId.current) return + setBlurStatusMessage(message) + }, 80) + } + const clearEffect = async () => { await videoTrack.stopProcessor() onSubmit?.(undefined) } + const updateBlurStatusMessage = ( + type: ProcessorType, + options: BackgroundOptions, + wasSelectedBeforeToggle: boolean + ) => { + if (type !== ProcessorType.BLUR) return + + let message = '' + + if (wasSelectedBeforeToggle) { + message = t('blur.status.none') + } else if (options.blurRadius === BlurRadius.LIGHT) { + message = t('blur.status.light') + } else if (options.blurRadius === BlurRadius.NORMAL) { + message = t('blur.status.strong') + } + + if (message) { + announceBlurStatusMessage(message) + } + } + const toggleEffect = async ( type: ProcessorType, options: BackgroundOptions ) => { setProcessorPending(true) + const wasSelectedBeforeToggle = isSelected(type, options) + if (!videoTrack) { /** * Special case: if no video track is available, then we must pass directly the processor into the @@ -103,7 +172,7 @@ export const EffectsConfiguration = ({ const processor = getProcessor() try { - if (isSelected(type, options)) { + if (wasSelectedBeforeToggle) { // Stop processor. await clearEffect() } else if ( @@ -130,6 +199,8 @@ export const EffectsConfiguration = ({ // We want to trigger onSubmit when options changes so the parent component is aware of it. onSubmit?.(processor) } + + updateBlurStatusMessage(type, options, wasSelectedBeforeToggle) } catch (error) { console.error('Error applying effect:', error) } finally { @@ -153,9 +224,23 @@ export const EffectsConfiguration = ({ } const tooltipBlur = (type: ProcessorType, options: BackgroundOptions) => { - return t( - `${type}.${options.blurRadius == BlurRadius.LIGHT ? 'light' : 'normal'}.${isSelected(type, options) ? 'clear' : 'apply'}` - ) + const strength = + options.blurRadius === BlurRadius.LIGHT ? 'light' : 'normal' + const action = isSelected(type, options) ? 'clear' : 'apply' + + return t(`${type}.${strength}.${action}`) + } + + const ariaLabelVirtualBackground = ( + index: number, + imagePath: string + ): string => { + const isSelectedBackground = isSelected(ProcessorType.VIRTUAL, { + imagePath, + }) + const prefix = isSelectedBackground ? 'selectedLabel' : 'apply' + const backgroundName = t(`virtual.descriptions.${index}`) + return `${t(`virtual.${prefix}`)} ${backgroundName}` } const tooltipVirtualBackground = (index: number): string => { @@ -270,48 +355,60 @@ export const EffectsConfiguration = ({ > {t('blur.title')} -
- +
- await toggleEffect(ProcessorType.BLUR, { + > + - - - - await toggleEffect(ProcessorType.BLUR, { + })} + tooltip={tooltipBlur(ProcessorType.BLUR, { + blurRadius: BlurRadius.LIGHT, + })} + isDisabled={processorPendingReveal || isDisabled} + onChange={async () => + await toggleEffect(ProcessorType.BLUR, { + blurRadius: BlurRadius.LIGHT, + }) + } + isSelected={isSelected(ProcessorType.BLUR, { + blurRadius: BlurRadius.LIGHT, + })} + data-attr="toggle-blur-light" + > + + + - - + })} + tooltip={tooltipBlur(ProcessorType.BLUR, { + blurRadius: BlurRadius.NORMAL, + })} + isDisabled={processorPendingReveal || isDisabled} + onChange={async () => + await toggleEffect(ProcessorType.BLUR, { + blurRadius: BlurRadius.NORMAL, + }) + } + isSelected={isSelected(ProcessorType.BLUR, { + blurRadius: BlurRadius.NORMAL, + })} + data-attr="toggle-blur-normal" + > + + +
+
+ {blurStatusMessage} +
{ const imagePath = `/assets/backgrounds/${i + 1}.jpg` const thumbnailPath = `/assets/backgrounds/thumbnails/${i + 1}.jpg` + const tooltipText = tooltipVirtualBackground(i) return ( - + + await toggleEffect(ProcessorType.VIRTUAL, { imagePath, }) - ? 'selectedLabel' - : 'apply' - }` - )} - isDisabled={processorPendingReveal || isDisabled} - onChange={async () => - await toggleEffect(ProcessorType.VIRTUAL, { + } + isSelected={isSelected(ProcessorType.VIRTUAL, { imagePath, - }) - } - isSelected={isSelected(ProcessorType.VIRTUAL, { - imagePath, - })} - className={css({ - bgSize: 'cover', - })} - style={{ - backgroundImage: `url(${thumbnailPath})`, - }} - data-attr={`toggle-virtual-${i}`} - /> + })} + className={css({ + bgSize: 'cover', + })} + style={{ + backgroundImage: `url(${thumbnailPath})`, + }} + data-attr={`toggle-virtual-${i}`} + /> + ) })}
diff --git a/src/frontend/src/locales/fr/rooms.json b/src/frontend/src/locales/fr/rooms.json index 4f102435..0f19a2aa 100644 --- a/src/frontend/src/locales/fr/rooms.json +++ b/src/frontend/src/locales/fr/rooms.json @@ -230,15 +230,15 @@ "cameraDisabled": "Votre caméra est désactivée.", "notAvailable": "Les effets vidéo seront bientôt disponibles sur votre navigateur. Nous y travaillons ! En attendant, vous pouvez utiliser Google Chrome pour de meilleures performances ou Firefox :(", "heading": "Flou", - "clear": "Désactiver l'effect", + "clear": "Désactiver l'effet", "blur": { "title": "Flou d'arrière-plan", "light": { - "apply": "Flouter légèrement votre arrière-plan", + "apply": "Flouter l'arrière-plan", "clear": "Désactiver le flou" }, "normal": { - "apply": "Flouter votre arrière-plan", + "apply": "Flouter encore plus l'arrière-plan", "clear": "Désactiver le flou" } }, diff --git a/src/frontend/src/primitives/VisualOnlyTooltip.tsx b/src/frontend/src/primitives/VisualOnlyTooltip.tsx new file mode 100644 index 00000000..8e99c9d1 --- /dev/null +++ b/src/frontend/src/primitives/VisualOnlyTooltip.tsx @@ -0,0 +1,88 @@ +import { type ReactNode, useRef, useState } from 'react' +import { createPortal } from 'react-dom' +import { css } from '@/styled-system/css' + +export type VisualOnlyTooltipProps = { + children: ReactNode + tooltip: string +} + +/** + * Wrapper component that displays a tooltip visually only (not announced by screen readers). + * + * This is necessary because TooltipTrigger from react-aria-components automatically adds + * aria-describedby on the button, which links the tooltip for accessibility. + * Even with aria-hidden="true" on the tooltip, screen readers still announce its content → duplication. + * This CSS wrapper avoids TooltipTrigger → no automatic aria-describedby → no duplication. + * + * Uses a portal to avoid being clipped by parent containers with overflow: hidden. + */ +export const VisualOnlyTooltip = ({ + children, + tooltip, +}: VisualOnlyTooltipProps) => { + const wrapperRef = useRef(null) + const [isVisible, setIsVisible] = useState(false) + + const getPosition = () => { + if (!wrapperRef.current) return null + const rect = wrapperRef.current.getBoundingClientRect() + return { + top: rect.top - 8, + left: rect.left + rect.width / 2, + } + } + + const position = getPosition() + const tooltipData = isVisible && position ? { isVisible, position } : null + + return ( + <> +
setIsVisible(true)} + onMouseLeave={() => setIsVisible(false)} + onFocus={() => setIsVisible(true)} + onBlur={() => setIsVisible(false)} + > + {children} +
+ {tooltipData && + createPortal( + , + document.body + )} + + ) +}