️(frontend) adjust visual-only tooltip a11y labels

Ensure tooltips stay visual while exposing correct aria-labels.
This commit is contained in:
Cyril
2026-01-27 10:14:32 +01:00
parent e1aeec6053
commit db15c8b6cc
6 changed files with 83 additions and 40 deletions

View File

@@ -8,14 +8,15 @@ and this project adheres to
## [Unreleased] ## [Unreleased]
### Changed
- ♿️(frontend) adjust visual-only tooltip a11y labels #910
- ♿(frontend) adjust sr announcements for idle disconnect timer #908
### Fixed ### Fixed
- 🔒️(frontend) fix an XSS vulnerability on the recording page #911 - 🔒️(frontend) fix an XSS vulnerability on the recording page #911
### Changed
- ♿(frontend) adjust sr announcements for idle disconnect timer #908
## [1.4.0] - 2026-01-25 ## [1.4.0] - 2026-01-25
### Added ### Added

View File

@@ -10,6 +10,7 @@ import { FeedbackBanner } from '@/components/FeedbackBanner'
import { Menu } from '@/primitives/Menu' import { Menu } from '@/primitives/Menu'
import { MenuList } from '@/primitives/MenuList' import { MenuList } from '@/primitives/MenuList'
import { LoginButton } from '@/components/LoginButton' import { LoginButton } from '@/components/LoginButton'
import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip'
import { useLoginHint } from '@/hooks/useLoginHint' import { useLoginHint } from '@/hooks/useLoginHint'
@@ -90,6 +91,11 @@ export const Header = () => {
const isTermsOfService = useMatchesRoute('termsOfService') const isTermsOfService = useMatchesRoute('termsOfService')
const isRoom = useMatchesRoute('room') const isRoom = useMatchesRoute('room')
const { user, isLoggedIn, logout } = useUser() const { user, isLoggedIn, logout } = useUser()
const userLabel = user?.full_name || user?.email
const loggedInTooltip = t('loggedInUserTooltip')
const loggedInAriaLabel = userLabel
? `${loggedInTooltip} ${userLabel}`
: loggedInTooltip
return ( return (
<> <>
@@ -153,23 +159,24 @@ export const Header = () => {
)} )}
{!!user && ( {!!user && (
<Menu> <Menu>
<Button <Button size="sm" variant="secondaryText">
size="sm" <VisualOnlyTooltip
variant="secondaryText" tooltip={loggedInTooltip}
tooltip={t('loggedInUserTooltip')} ariaLabel={loggedInAriaLabel}
tooltipType="delayed" tooltipPosition="bottom"
>
<span
className={css({
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: '350px',
display: { base: 'none', xsm: 'block' },
})}
> >
{user?.full_name || user?.email} <span
</span> className={css({
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: '350px',
display: { base: 'none', xsm: 'block' },
})}
>
{user?.full_name || user?.email}
</span>
</VisualOnlyTooltip>
</Button> </Button>
<MenuList <MenuList
variant={'light'} variant={'light'}

View File

@@ -13,7 +13,7 @@
"heading": "You don't have the permission to view this page" "heading": "You don't have the permission to view this page"
}, },
"loading": "Loading…", "loading": "Loading…",
"loggedInUserTooltip": "Logged in as", "loggedInUserTooltip": "Logged in as ",
"login": { "login": {
"buttonLabel": "Login", "buttonLabel": "Login",
"proconnectButtonLabel": "Login with ProConnect", "proconnectButtonLabel": "Login with ProConnect",

View File

@@ -13,7 +13,7 @@
"heading": "Accès interdit" "heading": "Accès interdit"
}, },
"loading": "Chargement…", "loading": "Chargement…",
"loggedInUserTooltip": "Connecté en tant que", "loggedInUserTooltip": "Connecté en tant que ",
"login": { "login": {
"buttonLabel": "Se connecter", "buttonLabel": "Se connecter",
"proconnectButtonLabel": "S'identifier avec ProConnect", "proconnectButtonLabel": "S'identifier avec ProConnect",

View File

@@ -13,7 +13,7 @@
"heading": "U hebt geen toestemming om deze pagina te bekijken" "heading": "U hebt geen toestemming om deze pagina te bekijken"
}, },
"loading": "Laden ...", "loading": "Laden ...",
"loggedInUserTooltip": "Ingelogd als ...", "loggedInUserTooltip": "Ingelogd als ",
"login": { "login": {
"buttonLabel": "Log in", "buttonLabel": "Log in",
"proconnectButtonLabel": "Log in met Proconnect", "proconnectButtonLabel": "Log in met Proconnect",

View File

@@ -1,10 +1,18 @@
import { type ReactNode, useRef, useState } from 'react' import {
type ReactElement,
cloneElement,
isValidElement,
useRef,
useState,
} from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
export type VisualOnlyTooltipProps = { export type VisualOnlyTooltipProps = {
children: ReactNode children: ReactElement
tooltip: string tooltip: string
ariaLabel?: string
tooltipPosition?: 'top' | 'bottom'
} }
/** /**
@@ -20,32 +28,50 @@ export type VisualOnlyTooltipProps = {
export const VisualOnlyTooltip = ({ export const VisualOnlyTooltip = ({
children, children,
tooltip, tooltip,
ariaLabel,
tooltipPosition = 'top',
}: VisualOnlyTooltipProps) => { }: VisualOnlyTooltipProps) => {
const wrapperRef = useRef<HTMLDivElement>(null)
const [isVisible, setIsVisible] = useState(false) const [isVisible, setIsVisible] = useState(false)
const wrapperRef = useRef<HTMLDivElement>(null)
const [position, setPosition] = useState<{
top: number
left: number
} | null>(null)
const getPosition = () => { const isBottom = tooltipPosition === 'bottom'
if (!wrapperRef.current) return null
const showTooltip = () => {
if (!wrapperRef.current) return
const rect = wrapperRef.current.getBoundingClientRect() const rect = wrapperRef.current.getBoundingClientRect()
return { setPosition({
top: rect.top - 8, top: isBottom ? rect.bottom + 8 : rect.top - 8,
left: rect.left + rect.width / 2, left: rect.left + rect.width / 2,
} })
setIsVisible(true)
}
const hideTooltip = () => {
setIsVisible(false)
setPosition(null)
} }
const position = getPosition()
const tooltipData = isVisible && position ? { isVisible, position } : null const tooltipData = isVisible && position ? { isVisible, position } : null
const wrappedChild = isValidElement(children)
? cloneElement(children, {
...(ariaLabel ? { 'aria-label': ariaLabel } : {}),
})
: children
return ( return (
<> <>
<div <div
ref={wrapperRef} ref={wrapperRef}
onMouseEnter={() => setIsVisible(true)} onMouseEnter={showTooltip}
onMouseLeave={() => setIsVisible(false)} onMouseLeave={hideTooltip}
onFocus={() => setIsVisible(true)} onFocus={showTooltip}
onBlur={() => setIsVisible(false)} onBlur={hideTooltip}
> >
{children} {wrappedChild}
</div> </div>
{tooltipData && {tooltipData &&
createPortal( createPortal(
@@ -66,17 +92,26 @@ export const VisualOnlyTooltip = ({
'&::after': { '&::after': {
content: '""', content: '""',
position: 'absolute', position: 'absolute',
top: '100%',
left: '50%', left: '50%',
transform: 'translateX(-50%)', transform: 'translateX(-50%)',
border: '4px solid transparent', border: '4px solid transparent',
borderTopColor: 'primaryDark.100', ...(isBottom
? {
bottom: '100%',
borderBottomColor: 'primaryDark.100',
}
: {
top: '100%',
borderTopColor: 'primaryDark.100',
}),
}, },
})} })}
style={{ style={{
top: `${tooltipData.position.top}px`, top: `${tooltipData.position.top}px`,
left: `${tooltipData.position.left}px`, left: `${tooltipData.position.left}px`,
transform: 'translate(-50%, -100%)', transform: isBottom
? 'translate(-50%, 0)'
: 'translate(-50%, -100%)',
}} }}
> >
{tooltip} {tooltip}