mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-18 07:30:29 +00:00
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:
parent
a205f52bc7
commit
99e5cdc430
@ -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({
|
||||
|
||||
@ -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 = {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user