♿️(frontend) improve background effects a11y and blur labels
Signed-off-by: Cyril <c.gromoff@gmail.com>
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
88
src/frontend/src/primitives/VisualOnlyTooltip.tsx
Normal file
88
src/frontend/src/primitives/VisualOnlyTooltip.tsx
Normal 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
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user