fix(ui): keep word-timed lyric text readable by screen readers

The painted wave copy is aria-hidden and the token spans relied on
aria-label, which assistive tech ignores on role-less spans in browse
mode. Render the token text in a visually hidden node instead.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016L1sBqsygMkdhf583gTEnE
This commit is contained in:
Claude 2026-10-15 10:07:35 +00:00
parent a205f52bc7
commit 99e5cdc430
No known key found for this signature in database
2 changed files with 70 additions and 35 deletions

View File

@ -51,6 +51,20 @@ const waveTextRootStyle = {
const waveTextMeasureStyle = { visibility: 'hidden' }
// Keeps timed token text readable by screen readers, since the painted
// wave copy is aria-hidden and aria-label is ignored on role-less spans.
const visuallyHiddenStyle = {
border: 0,
clip: 'rect(0 0 0 0)',
height: 1,
margin: -1,
overflow: 'hidden',
padding: 0,
position: 'absolute',
whiteSpace: 'nowrap',
width: 1,
}
const waveTextOverlayStyle = {
display: 'block',
inset: 0,
@ -62,36 +76,39 @@ const renderWaveText = (text, eligible, enabled, emphasized, className) => {
if (!eligible) return text
return (
<span
aria-hidden="true"
data-lyrics-wave-text="true"
style={waveTextRootStyle}
>
{enabled ? (
<>
<span data-lyrics-wave-measure="true" style={waveTextMeasureStyle}>
{text}
</span>
<span data-lyrics-wave-overlay="true" style={waveTextOverlayStyle}>
{splitGraphemes(text).map((character, index) => {
if (/^\s+$/.test(character)) return character
return (
<span
key={`${index}-${character}`}
className={className}
data-lyrics-character="true"
style={emphasized ? emphasisPaintOverhangStyle : undefined}
>
{character}
</span>
)
})}
</span>
</>
) : (
text
)}
</span>
<>
<span style={visuallyHiddenStyle}>{text}</span>
<span
aria-hidden="true"
data-lyrics-wave-text="true"
style={waveTextRootStyle}
>
{enabled ? (
<>
<span data-lyrics-wave-measure="true" style={waveTextMeasureStyle}>
{text}
</span>
<span data-lyrics-wave-overlay="true" style={waveTextOverlayStyle}>
{splitGraphemes(text).map((character, index) => {
if (/^\s+$/.test(character)) return character
return (
<span
key={`${index}-${character}`}
className={className}
data-lyrics-character="true"
style={emphasized ? emphasisPaintOverhangStyle : undefined}
>
{character}
</span>
)
})}
</span>
</>
) : (
text
)}
</span>
</>
)
}
@ -270,7 +287,6 @@ export const KaraokeLineRow = memo(
: undefined
}
style={tokenData.style}
aria-label={segment.text}
>
{renderWaveText(
segment.text,
@ -516,7 +532,6 @@ export const KaraokeStackedLineRow = memo(
: undefined
}
style={mainTokenData.style}
aria-label={segment.text}
>
{renderWaveText(
segment.text,
@ -579,9 +594,6 @@ export const KaraokeStackedLineRow = memo(
data-testid="lyrics-pronunciation-token"
data-lyrics-state="future"
data-timed={pronunciationWindow ? 'true' : 'false'}
aria-label={
pronunciationWindow ? segment.pronunciation : undefined
}
ref={
pronunciationWindow
? getTokenRef({

View File

@ -254,6 +254,29 @@ describe('<LyricsPanel />', () => {
})
})
it('exposes timed token text outside the aria-hidden wave copy', () => {
renderPanel({
mainLyric: tokenizedMainLyric,
pronunciationLyric: tokenizedPronunciationLyric,
showPronunciation: true,
})
const tokens = [
...screen.getAllByTestId('lyrics-token'),
...screen.getAllByTestId('lyrics-pronunciation-token'),
]
expect(tokens).toHaveLength(4)
tokens.forEach((token) => {
expect(token).not.toHaveAttribute('aria-label')
const readable = Array.from(token.childNodes)
.filter((node) => node.getAttribute?.('aria-hidden') !== 'true')
.map((node) => node.textContent)
.join('')
expect(readable).not.toBe('')
expect(token.textContent).toContain(readable)
})
})
it('keeps token ref callbacks stable across equivalent rerenders', () => {
const registerToken = vi.fn()
const line = {