fix(a11y): make the library badge a badge, not an inert button
Build & publish Arch package / arch-package (push) Successful in 2m1s
CI / check (push) Successful in 2m13s
Search index maintenance / maintain-index (push) Successful in 6s
CI / e2e (push) Successful in 5m12s

`library-status-indicator` was a <button> whose click handler was a
stopPropagation() and a comment saying to wire up the download client
later. On an Explore results page that is 20 of 66 tab stops (measured
in the running app, before and after: 66/20 → 46/0) that announce
themselves as buttons and do nothing.

It is role="img" with its existing label now, and the label for an
unowned entity says "… is not in your library" rather than "Add … to
library" — the old copy was the button's promise written out. The day
there is a download client to call, the right change is a <button>
*with* a handler, not a handler bolted onto something already shaped
like one.

box-sizing: border-box is explicit because a <button> gets it from the
UA stylesheet and a <span> does not, so the badge grew 36px → 38px.
Caught by the stored screenshot.
This commit is contained in:
2026-08-12 17:30:33 -04:00
parent f5621bf7c5
commit dddf54ba0c
3 changed files with 88 additions and 86 deletions
+31 -31
View File
@@ -176,26 +176,43 @@ describe('<library-status-indicator>', () => {
label: 'Abbey Road',
});
expect(shadow(el, 'button')?.getAttribute('aria-label')).toBe(
expect(shadow(el, '.badge')?.getAttribute('aria-label')).toBe(
'Album "Abbey Road" is in your library',
);
});
it('phrases an unowned entity as an invitation', async () => {
it('states an unowned entity rather than offering to add it', async () => {
// The old copy was "Add artist “Eno” to library", which is the
// promise the inert button was making. Nothing here adds anything.
const el = await fixture('library-status-indicator', {
entityType: 'artist',
label: 'Eno',
});
expect(shadow(el, 'button')?.getAttribute('aria-label')).toBe(
'Add artist "Eno" to library',
expect(shadow(el, '.badge')?.getAttribute('aria-label')).toBe(
'Artist "Eno" is not in your library',
);
});
it('is a badge, not a keyboard stop', async () => {
// 20 of the 66 tab stops on an Explore results page were these,
// each announcing itself as a button and doing nothing.
const el = await fixture('library-status-indicator', {
status: 'in-library',
});
expect(shadow(el, 'button')).toBeNull();
const badge = shadow(el, '.badge');
expect(badge?.getAttribute('role')).toBe('img');
expect(badge?.hasAttribute('tabindex')).toBe(false);
});
it('drops the quoted name when it has none', async () => {
const el = await fixture('library-status-indicator', { status: 'queued' });
expect(shadow(el, 'button')?.getAttribute('aria-label')).toBe(
expect(shadow(el, '.badge')?.getAttribute('aria-label')).toBe(
'Track is queued for download',
);
});
@@ -205,14 +222,15 @@ describe('<library-status-indicator>', () => {
status: 'in-library',
});
const button = shadow(el, 'button');
const badge = shadow(el, '.badge');
expect(button?.getAttribute('title')).toBe(
button?.getAttribute('aria-label'),
);
expect(badge?.getAttribute('title')).toBe(badge?.getAttribute('aria-label'));
});
it('swallows the click, so it does not navigate the card it sits on', async () => {
it('lets a click reach the card it sits on, and calls nothing itself', async () => {
// It used to stopPropagation() so its own no-op click would not
// navigate the card. With no click of its own, the badge is part
// of the card and a click on it means what the card means.
const el = await fixture('library-status-indicator');
let bubbled = 0;
@@ -220,27 +238,9 @@ describe('<library-status-indicator>', () => {
bubbled += 1;
});
shadow<HTMLElement>(el, 'button')?.click();
shadow<HTMLElement>(el, '.badge')?.click();
expect([bubbled, calls()]).toEqual([0, []]);
});
it('swallows Enter and Space for the same reason', async () => {
const el = await fixture('library-status-indicator');
let bubbled = 0;
el.addEventListener('keydown', () => {
bubbled += 1;
});
for (const key of ['Enter', ' ', 'Tab']) {
shadow(el, 'button')?.dispatchEvent(
new KeyboardEvent('keydown', { key, bubbles: true, composed: true }),
);
}
// Tab still gets through: it is navigation, not activation.
expect(bubbled).toBe(1);
expect([bubbled, calls()]).toEqual([1, []]);
});
it('honours a non-default size', async () => {
@@ -256,7 +256,7 @@ describe('<library-status-indicator>', () => {
await update(el, { size: 40 });
await visual(el, 'library-status-indicator-in-library');
expect(shadow(el, 'button')).not.toBeNull();
expect(shadow(el, '.badge')).not.toBeNull();
});
});