️(frontend) improve background effects a11y and blur labels

Signed-off-by: Cyril <c.gromoff@gmail.com>
This commit is contained in:
Cyril
2025-12-10 13:44:22 +01:00
committed by aleb_the_flash
parent 3cd5c77f42
commit 1ab3ce6d47
4 changed files with 253 additions and 73 deletions

View File

@@ -8,6 +8,7 @@ and this project adheres to
## [Unreleased] ## [Unreleased]
### Added ### Added
- ✨(summary) add dutch and german languages - ✨(summary) add dutch and german languages
@@ -16,6 +17,7 @@ and this project adheres to
### Changed ### Changed
- 📈(frontend) track new recording's modes - 📈(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) improve SR and focus for transcript and recording #810
- 💄(frontend) adjust spacing in the recording side panels - 💄(frontend) adjust spacing in the recording side panels
- 🚸(frontend) remove the default comma delimiter in humanized durations - 🚸(frontend) remove the default comma delimiter in humanized durations

View File

@@ -9,6 +9,7 @@ import {
} from '../blur' } from '../blur'
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
import { H, P, Text, ToggleButton } from '@/primitives' import { H, P, Text, ToggleButton } from '@/primitives'
import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip'
import { styled } from '@/styled-system/jsx' import { styled } from '@/styled-system/jsx'
import { BlurOn } from '@/components/icons/BlurOn' import { BlurOn } from '@/components/icons/BlurOn'
import { BlurOnStrong } from '@/components/icons/BlurOnStrong' import { BlurOnStrong } from '@/components/icons/BlurOnStrong'
@@ -49,11 +50,17 @@ export const EffectsConfiguration = ({
layout = 'horizontal', layout = 'horizontal',
}: EffectsConfigurationProps) => { }: EffectsConfigurationProps) => {
const videoRef = useRef<HTMLVideoElement>(null) const videoRef = useRef<HTMLVideoElement>(null)
const blurLightRef = useRef<HTMLButtonElement | null>(null)
const { t } = useTranslation('rooms', { keyPrefix: 'effects' }) const { t } = useTranslation('rooms', { keyPrefix: 'effects' })
const { toggle, enabled } = useTrackToggle({ source: Track.Source.Camera }) const { toggle, enabled } = useTrackToggle({ source: Track.Source.Camera })
const [processorPending, setProcessorPending] = useState(false) const [processorPending, setProcessorPending] = useState(false)
const processorPendingReveal = useSyncAfterDelay(processorPending) const processorPendingReveal = useSyncAfterDelay(processorPending)
const hasFunnyEffectsAccess = useHasFunnyEffectsAccess() const hasFunnyEffectsAccess = useHasFunnyEffectsAccess()
const [blurStatusMessage, setBlurStatusMessage] = useState('')
const blurAnnouncementTimeout = useRef<ReturnType<typeof setTimeout> | null>(
null
)
const blurAnnouncementId = useRef(0)
useEffect(() => { useEffect(() => {
const videoElement = videoRef.current const videoElement = videoRef.current
@@ -68,16 +75,78 @@ export const EffectsConfiguration = ({
} }
}, [videoTrack, videoTrack?.isMuted]) }, [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 () => { const clearEffect = async () => {
await videoTrack.stopProcessor() await videoTrack.stopProcessor()
onSubmit?.(undefined) 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 ( const toggleEffect = async (
type: ProcessorType, type: ProcessorType,
options: BackgroundOptions options: BackgroundOptions
) => { ) => {
setProcessorPending(true) setProcessorPending(true)
const wasSelectedBeforeToggle = isSelected(type, options)
if (!videoTrack) { if (!videoTrack) {
/** /**
* Special case: if no video track is available, then we must pass directly the processor into the * 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() const processor = getProcessor()
try { try {
if (isSelected(type, options)) { if (wasSelectedBeforeToggle) {
// Stop processor. // Stop processor.
await clearEffect() await clearEffect()
} else if ( } 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. // We want to trigger onSubmit when options changes so the parent component is aware of it.
onSubmit?.(processor) onSubmit?.(processor)
} }
updateBlurStatusMessage(type, options, wasSelectedBeforeToggle)
} catch (error) { } catch (error) {
console.error('Error applying effect:', error) console.error('Error applying effect:', error)
} finally { } finally {
@@ -153,9 +224,23 @@ export const EffectsConfiguration = ({
} }
const tooltipBlur = (type: ProcessorType, options: BackgroundOptions) => { const tooltipBlur = (type: ProcessorType, options: BackgroundOptions) => {
return t( const strength =
`${type}.${options.blurRadius == BlurRadius.LIGHT ? 'light' : 'normal'}.${isSelected(type, options) ? 'clear' : 'apply'}` 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 => { const tooltipVirtualBackground = (index: number): string => {
@@ -270,48 +355,60 @@ export const EffectsConfiguration = ({
> >
{t('blur.title')} {t('blur.title')}
</H> </H>
<div <div>
className={css({ <div
display: 'flex', className={css({
gap: '1.25rem', display: 'flex',
})} gap: '1.25rem',
>
<ToggleButton
variant="bigSquare"
tooltip={tooltipBlur(ProcessorType.BLUR, {
blurRadius: BlurRadius.LIGHT,
})} })}
isDisabled={processorPendingReveal || isDisabled} >
onChange={async () => <ToggleButton
await toggleEffect(ProcessorType.BLUR, { ref={blurLightRef}
variant="bigSquare"
aria-label={tooltipBlur(ProcessorType.BLUR, {
blurRadius: BlurRadius.LIGHT, blurRadius: BlurRadius.LIGHT,
}) })}
} tooltip={tooltipBlur(ProcessorType.BLUR, {
isSelected={isSelected(ProcessorType.BLUR, { blurRadius: BlurRadius.LIGHT,
blurRadius: BlurRadius.LIGHT, })}
})} isDisabled={processorPendingReveal || isDisabled}
data-attr="toggle-blur-light" onChange={async () =>
> await toggleEffect(ProcessorType.BLUR, {
<BlurOn /> blurRadius: BlurRadius.LIGHT,
</ToggleButton> })
<ToggleButton }
variant="bigSquare" isSelected={isSelected(ProcessorType.BLUR, {
tooltip={tooltipBlur(ProcessorType.BLUR, { blurRadius: BlurRadius.LIGHT,
blurRadius: BlurRadius.NORMAL, })}
})} data-attr="toggle-blur-light"
isDisabled={processorPendingReveal || isDisabled} >
onChange={async () => <BlurOn />
await toggleEffect(ProcessorType.BLUR, { </ToggleButton>
<ToggleButton
variant="bigSquare"
aria-label={tooltipBlur(ProcessorType.BLUR, {
blurRadius: BlurRadius.NORMAL, blurRadius: BlurRadius.NORMAL,
}) })}
} tooltip={tooltipBlur(ProcessorType.BLUR, {
isSelected={isSelected(ProcessorType.BLUR, { blurRadius: BlurRadius.NORMAL,
blurRadius: BlurRadius.NORMAL, })}
})} isDisabled={processorPendingReveal || isDisabled}
data-attr="toggle-blur-normal" onChange={async () =>
> await toggleEffect(ProcessorType.BLUR, {
<BlurOnStrong /> blurRadius: BlurRadius.NORMAL,
</ToggleButton> })
}
isSelected={isSelected(ProcessorType.BLUR, {
blurRadius: BlurRadius.NORMAL,
})}
data-attr="toggle-blur-normal"
>
<BlurOnStrong />
</ToggleButton>
</div>
<div aria-live="polite" className="sr-only">
{blurStatusMessage}
</div>
</div> </div>
<div <div
className={css({ className={css({
@@ -338,37 +435,30 @@ export const EffectsConfiguration = ({
{[...Array(8).keys()].map((i) => { {[...Array(8).keys()].map((i) => {
const imagePath = `/assets/backgrounds/${i + 1}.jpg` const imagePath = `/assets/backgrounds/${i + 1}.jpg`
const thumbnailPath = `/assets/backgrounds/thumbnails/${i + 1}.jpg` const thumbnailPath = `/assets/backgrounds/thumbnails/${i + 1}.jpg`
const tooltipText = tooltipVirtualBackground(i)
return ( return (
<ToggleButton <VisualOnlyTooltip key={i} tooltip={tooltipText}>
key={i} <ToggleButton
variant="bigSquare" variant="bigSquare"
tooltip={tooltipVirtualBackground(i)} aria-label={ariaLabelVirtualBackground(i, imagePath)}
aria-label={t( isDisabled={processorPendingReveal || isDisabled}
`virtual.${ onChange={async () =>
isSelected(ProcessorType.VIRTUAL, { await toggleEffect(ProcessorType.VIRTUAL, {
imagePath, imagePath,
}) })
? 'selectedLabel' }
: 'apply' isSelected={isSelected(ProcessorType.VIRTUAL, {
}`
)}
isDisabled={processorPendingReveal || isDisabled}
onChange={async () =>
await toggleEffect(ProcessorType.VIRTUAL, {
imagePath, imagePath,
}) })}
} className={css({
isSelected={isSelected(ProcessorType.VIRTUAL, { bgSize: 'cover',
imagePath, })}
})} style={{
className={css({ backgroundImage: `url(${thumbnailPath})`,
bgSize: 'cover', }}
})} data-attr={`toggle-virtual-${i}`}
style={{ />
backgroundImage: `url(${thumbnailPath})`, </VisualOnlyTooltip>
}}
data-attr={`toggle-virtual-${i}`}
/>
) )
})} })}
</div> </div>

View File

@@ -230,15 +230,15 @@
"cameraDisabled": "Votre caméra est désactivée.", "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 :(", "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", "heading": "Flou",
"clear": "Désactiver l'effect", "clear": "Désactiver l'effet",
"blur": { "blur": {
"title": "Flou d'arrière-plan", "title": "Flou d'arrière-plan",
"light": { "light": {
"apply": "Flouter légèrement votre arrière-plan", "apply": "Flouter l'arrière-plan",
"clear": "Désactiver le flou" "clear": "Désactiver le flou"
}, },
"normal": { "normal": {
"apply": "Flouter votre arrière-plan", "apply": "Flouter encore plus l'arrière-plan",
"clear": "Désactiver le flou" "clear": "Désactiver le flou"
} }
}, },

View File

@@ -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<HTMLDivElement>(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 (
<>
<div
ref={wrapperRef}
onMouseEnter={() => setIsVisible(true)}
onMouseLeave={() => setIsVisible(false)}
onFocus={() => setIsVisible(true)}
onBlur={() => setIsVisible(false)}
>
{children}
</div>
{tooltipData &&
createPortal(
<div
aria-hidden="true"
role="presentation"
className={css({
position: 'fixed',
padding: '2px 8px',
backgroundColor: 'primaryDark.100',
color: 'gray.100',
borderRadius: '4px',
fontSize: 14,
whiteSpace: 'nowrap',
pointerEvents: 'none',
zIndex: 9999,
boxShadow: '0 8px 20px rgba(0 0 0 / 0.1)',
'&::after': {
content: '""',
position: 'absolute',
top: '100%',
left: '50%',
transform: 'translateX(-50%)',
border: '4px solid transparent',
borderTopColor: 'primaryDark.100',
},
})}
style={{
top: `${tooltipData.position.top}px`,
left: `${tooltipData.position.left}px`,
transform: 'translate(-50%, -100%)',
}}
>
{tooltip}
</div>,
document.body
)}
</>
)
}