Compare commits
2
Commits
2b41c27616
...
254646da5e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
254646da5e | ||
|
|
9d420cda0a |
@@ -135,3 +135,26 @@ test.describe('the app fits in its own window', () => {
|
|||||||
expect(reachable).toBe(true);
|
expect(reachable).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.describe('the title block fits its bar', () => {
|
||||||
|
test('the hgroup stays inside the 4em top bar', async ({ app }) => {
|
||||||
|
// The state a11y.29 landed in. The pair is flex-centred and a UA
|
||||||
|
// gives an `h1` a 0.67em top margin, so the block measured 67px
|
||||||
|
// inside 64 — pre-existing, and invisible until dropping the h3's
|
||||||
|
// bottom margin shortened the block and shifted it down into the
|
||||||
|
// clip. The descenders of "meant to bee." were cut.
|
||||||
|
const fits = await app.locator('hgroup').evaluate((el) => {
|
||||||
|
const bar = el.closest('.top-bar')!.getBoundingClientRect();
|
||||||
|
const group = el.getBoundingClientRect();
|
||||||
|
|
||||||
|
return {
|
||||||
|
top: group.top >= Math.floor(bar.top),
|
||||||
|
bottom: group.bottom <= Math.ceil(bar.bottom),
|
||||||
|
height: Math.round(group.height),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fits.top).toBe(true);
|
||||||
|
expect(fits.bottom).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -141,3 +141,38 @@ test.describe('queue', () => {
|
|||||||
await expect(shuffle).toHaveAttribute('aria-pressed', 'false');
|
await expect(shuffle).toHaveAttribute('aria-pressed', 'false');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.describe('the playing row is findable without colour vision', () => {
|
||||||
|
test('the queue marks its current row with a shape and aria-current', async ({
|
||||||
|
app,
|
||||||
|
}) => {
|
||||||
|
await app.getByTestId('nav-tracks').click();
|
||||||
|
await app.getByTestId('track-row').first().dblclick();
|
||||||
|
|
||||||
|
const queueToggle = app.locator('#queue-button');
|
||||||
|
|
||||||
|
await queueToggle.click();
|
||||||
|
|
||||||
|
const row = app.getByTestId('queue-row').first();
|
||||||
|
|
||||||
|
await expect(row).toBeVisible();
|
||||||
|
|
||||||
|
// Played *from the queue*, because a track started from the list
|
||||||
|
// leaves `currentIndex` at -1 — so the panel has no current row at
|
||||||
|
// all in that flow, which is what made this marker look broken the
|
||||||
|
// first time it was checked.
|
||||||
|
await row.dblclick();
|
||||||
|
await expect(row).toHaveAttribute('aria-current', 'true');
|
||||||
|
|
||||||
|
const marker = await row.evaluate(
|
||||||
|
(el) => getComputedStyle(el, '::before').borderLeftWidth,
|
||||||
|
);
|
||||||
|
|
||||||
|
// `a11y.22`: a background tint and a text colour were the only two
|
||||||
|
// signals, and both are hue (WCAG 1.4.1).
|
||||||
|
expect(parseFloat(marker)).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
await queueToggle.click();
|
||||||
|
await expect(app.getByTestId('queue-row')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { test, expect } from '../support/fixtures.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plan 008 phase 3: `a11y.30` and `a11y.29`, the two findings that are
|
||||||
|
* about the document itself rather than about a component.
|
||||||
|
*
|
||||||
|
* `<main id="main-content">` existed and nothing linked to it, so a
|
||||||
|
* keyboard user walked the library filter, the search box, the job
|
||||||
|
* indicator and eleven nav items before reaching content — on every
|
||||||
|
* navigation. And `<h1>` was followed immediately by `<h3>`, using a
|
||||||
|
* heading level for type size.
|
||||||
|
*
|
||||||
|
* Two things in the fix are only checkable here, because the Vitest
|
||||||
|
* tier has no `index.html` at all:
|
||||||
|
*
|
||||||
|
* - **The link is out of flow in both states.** `body` is a grid with
|
||||||
|
* named areas, so an in-flow extra child is auto-placed into one of
|
||||||
|
* them and silently takes a row from the shell.
|
||||||
|
* - **`<main>` carries `tabindex="-1"`.** A fragment link to an element
|
||||||
|
* that cannot hold focus moves the *scroll* and leaves the tab
|
||||||
|
* sequence exactly where it was, which is the whole thing the link
|
||||||
|
* exists to change — and it looks like it worked.
|
||||||
|
*/
|
||||||
|
test.describe('skipping to the content', () => {
|
||||||
|
test('is the first thing Tab reaches', async ({ app }) => {
|
||||||
|
// From the very top of the document, not from a control part-way
|
||||||
|
// in: "first" is the claim.
|
||||||
|
await app.evaluate(() => {
|
||||||
|
(document.activeElement as HTMLElement | null)?.blur();
|
||||||
|
document.body.focus();
|
||||||
|
});
|
||||||
|
await app.keyboard.press('Tab');
|
||||||
|
|
||||||
|
const first = await app.evaluate(() => ({
|
||||||
|
tag: document.activeElement?.tagName ?? '',
|
||||||
|
text: document.activeElement?.textContent?.trim() ?? '',
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(first).toEqual({ tag: 'A', text: 'Skip to content' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('is off screen until focused, and never takes a grid cell', async ({
|
||||||
|
app,
|
||||||
|
}) => {
|
||||||
|
const offscreen = await app
|
||||||
|
.locator('.skip-link')
|
||||||
|
.evaluate((el) => el.getBoundingClientRect().left);
|
||||||
|
|
||||||
|
expect(offscreen).toBeLessThan(-100);
|
||||||
|
|
||||||
|
await app.locator('.skip-link').focus();
|
||||||
|
|
||||||
|
const box = await app
|
||||||
|
.locator('.skip-link')
|
||||||
|
.evaluate((el) => {
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
|
||||||
|
return { left: r.left, position: getComputedStyle(el).position };
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(box.left).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(box.position).toBe('absolute');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('moves focus into the main panel, not just the scroll', async ({
|
||||||
|
app,
|
||||||
|
}) => {
|
||||||
|
await app.locator('.skip-link').focus();
|
||||||
|
await app.keyboard.press('Enter');
|
||||||
|
|
||||||
|
const landed = await app.evaluate(
|
||||||
|
() => document.activeElement?.id ?? '',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(landed).toBe('main-content');
|
||||||
|
|
||||||
|
// Leave the page as it was found: the specs share one page in file
|
||||||
|
// order, and focus inside `main` changes which shortcut scope the
|
||||||
|
// service resolves.
|
||||||
|
await app.evaluate(() => {
|
||||||
|
(document.activeElement as HTMLElement | null)?.blur();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not use a heading level for type size', async ({ app }) => {
|
||||||
|
// `hgroup` takes one heading plus paragraphs, so this is also what
|
||||||
|
// the element was supposed to contain.
|
||||||
|
await expect(app.locator('hgroup h3')).toHaveCount(0);
|
||||||
|
await expect(app.locator('hgroup p.subtitle')).toHaveText(
|
||||||
|
'Music how it was meant to bee.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
+38
-1
@@ -24,6 +24,36 @@ p {
|
|||||||
/* I want to set paragraph margins myself */
|
/* I want to set paragraph margins myself */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* a11y.30. Out of flow in both states, because `body` is a grid with
|
||||||
|
named areas and an in-flow extra child is auto-placed into one of
|
||||||
|
them — the link would silently take a row from the shell. It is not
|
||||||
|
`display: none`: a skip link that is not focusable is not a skip
|
||||||
|
link. */
|
||||||
|
.skip-link {
|
||||||
|
position: absolute;
|
||||||
|
left: -9999px;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
padding: 0.5em 1em;
|
||||||
|
background-color: var(--yj-accent);
|
||||||
|
color: var(--yj-accent-fg);
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 0 0 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skip-link:focus {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The target of that link, so it can take focus at all. `main` is not
|
||||||
|
focusable by default, and a fragment link to an unfocusable element
|
||||||
|
moves the scroll and leaves the tab sequence exactly where it was —
|
||||||
|
which is the whole thing the link exists to change. */
|
||||||
|
.main-panel:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
.top-bar {
|
.top-bar {
|
||||||
grid-area: top-bar;
|
grid-area: top-bar;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -46,7 +76,14 @@ ul {
|
|||||||
|
|
||||||
.title {
|
.title {
|
||||||
font-size: 1.5em;
|
font-size: 1.5em;
|
||||||
margin-bottom: 0;
|
/* Both margins, not just the bottom one. The pair is flex-centred
|
||||||
|
in a 4em bar and the UA gives an h1 a 0.67em top margin, so the
|
||||||
|
hgroup measured 67px inside 64 and the subtitle's descenders were
|
||||||
|
clipped by the bar. That was pre-existing; a11y.29 made it
|
||||||
|
visible by taking the h3's bottom margin away with it, which
|
||||||
|
shortened the block and moved the whole pair down into the clip.
|
||||||
|
This is the state that fix landed in. */
|
||||||
|
margin-block: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.subtitle {
|
.subtitle {
|
||||||
|
|||||||
+8
-2
@@ -10,10 +10,15 @@
|
|||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
<!-- a11y.30. First focusable thing in the document, so a keyboard
|
||||||
|
user is not walked through the header, the library filter, the
|
||||||
|
search box and eleven nav items on every navigation. -->
|
||||||
|
<a class="skip-link" href="#main-content">Skip to content</a>
|
||||||
<header class="top-bar">
|
<header class="top-bar">
|
||||||
<hgroup>
|
<hgroup>
|
||||||
<h1 class="title">YellowJacket</h1>
|
<h1 class="title">YellowJacket</h1>
|
||||||
<h3 class="subtitle">Music how it was meant to bee.</h3>
|
<!-- a11y.29: a heading level was being used for type size. -->
|
||||||
|
<p class="subtitle">Music how it was meant to bee.</p>
|
||||||
</hgroup>
|
</hgroup>
|
||||||
<library-filter></library-filter>
|
<library-filter></library-filter>
|
||||||
<search-bar></search-bar>
|
<search-bar></search-bar>
|
||||||
@@ -23,7 +28,8 @@
|
|||||||
<app-sidebar></app-sidebar>
|
<app-sidebar></app-sidebar>
|
||||||
</div>
|
</div>
|
||||||
<div class="content-area">
|
<div class="content-area">
|
||||||
<main class="main-panel" data-active-view="tracks" data-testid="main-content" id="main-content">
|
<main class="main-panel" data-active-view="tracks" data-testid="main-content" id="main-content"
|
||||||
|
tabindex="-1">
|
||||||
<track-list></track-list>
|
<track-list></track-list>
|
||||||
</main>
|
</main>
|
||||||
<queue-panel id="queue-panel"></queue-panel>
|
<queue-panel id="queue-panel"></queue-panel>
|
||||||
|
|||||||
@@ -378,6 +378,22 @@ export class QueuePanel
|
|||||||
color: var(--yj-accent-text, #ffd43b);
|
color: var(--yj-accent-text, #ffd43b);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* a11y.22, the same rule as track-list one panel over: the
|
||||||
|
playing row was a tint and a text colour and nothing else.
|
||||||
|
The triangle lives in the row's own 16px left padding, so it
|
||||||
|
is a shape that is present or absent and the flex row does
|
||||||
|
not move. */
|
||||||
|
.track-item.active::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 5px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
border-left: 5px solid var(--yj-accent-text, #ffd43b);
|
||||||
|
border-top: 4px solid transparent;
|
||||||
|
border-bottom: 4px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
.track-art {
|
.track-art {
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
|
|||||||
@@ -943,7 +943,13 @@ export class TrackList
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sort-arrow {
|
.sort-arrow {
|
||||||
font-size: 10px; /* intentionally sub-token: tiny sort indicator */
|
/* a11y.34. Was 10px, below the type scale's own floor, with a
|
||||||
|
comment acknowledging it. The finding's stated harm — "the sort
|
||||||
|
direction is a 10px glyph or nothing" — is half closed already:
|
||||||
|
Phase 1 gave these cells an aria-sort, so it is announced. What
|
||||||
|
is left is a sighted user reading it, and nothing in the header
|
||||||
|
needs it to be smaller than the smallest text in the app. */
|
||||||
|
font-size: var(--yj-text-xs, 11px);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
color: var(--yj-accent-text, #ffd43b);
|
color: var(--yj-accent-text, #ffd43b);
|
||||||
}
|
}
|
||||||
@@ -996,6 +1002,7 @@ export class TrackList
|
|||||||
}
|
}
|
||||||
|
|
||||||
.track-row {
|
.track-row {
|
||||||
|
position: relative;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: var(--grid-cols);
|
grid-template-columns: var(--grid-cols);
|
||||||
font-size: var(--yj-text-sm);
|
font-size: var(--yj-text-sm);
|
||||||
@@ -1036,6 +1043,24 @@ export class TrackList
|
|||||||
color: var(--yj-accent-text, #ffd43b);
|
color: var(--yj-accent-text, #ffd43b);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* a11y.22: the playing row was a background tint and a text colour
|
||||||
|
and nothing else, so a colour-blind user could not find it
|
||||||
|
(WCAG 1.4.1). A triangle drawn in the row's own 8px left padding
|
||||||
|
is a *shape* that is present or absent, and it costs no layout:
|
||||||
|
the grid columns are computed from the host width and every one
|
||||||
|
of them would have had to move for a marker in the flow. Sized
|
||||||
|
to that padding rather than to the glyph a font would give. */
|
||||||
|
.track-row.active::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 1px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
border-left: 5px solid currentColor;
|
||||||
|
border-top: 4px solid transparent;
|
||||||
|
border-bottom: 4px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
.track-row.selected.active {
|
.track-row.selected.active {
|
||||||
background-color: var(--yj-selection-bg, rgba(100, 160, 255, 0.15));
|
background-color: var(--yj-selection-bg, rgba(100, 160, 255, 0.15));
|
||||||
}
|
}
|
||||||
@@ -1802,6 +1827,7 @@ export class TrackList
|
|||||||
role="row"
|
role="row"
|
||||||
aria-rowindex=${index + 1}
|
aria-rowindex=${index + 1}
|
||||||
aria-selected=${selected}
|
aria-selected=${selected}
|
||||||
|
aria-current=${active ? 'true' : 'false'}
|
||||||
tabindex=${index === this.focusedIndex ? 0 : -1}
|
tabindex=${index === this.focusedIndex ? 0 : -1}
|
||||||
draggable="true"
|
draggable="true"
|
||||||
data-index=${index}
|
data-index=${index}
|
||||||
|
|||||||
@@ -235,3 +235,87 @@ describe('a clipped value is readable somewhere', () => {
|
|||||||
expect(shadow(el, '.secondary')?.getAttribute('title')).toBeTruthy();
|
expect(shadow(el, '.secondary')?.getAttribute('title')).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('the playing row is more than a colour', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
resetHarness();
|
||||||
|
searchStore.setTerm('');
|
||||||
|
stub('library.Library.GetAllTracks', TRACKS);
|
||||||
|
stub('library.Library.GetAllAlbums', []);
|
||||||
|
emit(Events.LibraryScanComplete);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** The row the player says it is on, once the list has settled. */
|
||||||
|
async function listWithPlayingTrack(): Promise<LitElement> {
|
||||||
|
const el = await fixture<LitElement>('track-list');
|
||||||
|
|
||||||
|
sized(el);
|
||||||
|
await settle(el);
|
||||||
|
|
||||||
|
emit(Events.TrackChanged, {
|
||||||
|
fileName: 'a.mp3',
|
||||||
|
filePath: '/m/a.mp3',
|
||||||
|
trackLength: 4,
|
||||||
|
seekPosition: 0,
|
||||||
|
state: 'playing',
|
||||||
|
title: 'Departure',
|
||||||
|
artist: 'Aurora Fields',
|
||||||
|
album: 'Glass Harbour',
|
||||||
|
coverArt: '',
|
||||||
|
coverArtSmall: '',
|
||||||
|
coverArtMedium: '',
|
||||||
|
coverArtLarge: '',
|
||||||
|
trackChangeId: 1,
|
||||||
|
artistMbid: '',
|
||||||
|
releaseGroupMbid: '',
|
||||||
|
recordingMbid: '',
|
||||||
|
});
|
||||||
|
await settle(el);
|
||||||
|
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('marks exactly one row aria-current', async () => {
|
||||||
|
const el = await listWithPlayingTrack();
|
||||||
|
|
||||||
|
const current = shadowAll(el, '.track-row[aria-current="true"]');
|
||||||
|
|
||||||
|
// The positive case first: a guard that marks nothing passes
|
||||||
|
// "at most one" for free.
|
||||||
|
expect(current).toHaveLength(1);
|
||||||
|
expect(current[0]!.classList.contains('active')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('draws a marker that is a shape, not a hue', async () => {
|
||||||
|
const el = await listWithPlayingTrack();
|
||||||
|
|
||||||
|
const active = shadow(el, '.track-row.active');
|
||||||
|
const other = shadowAll(el, '.track-row:not(.active)')[0];
|
||||||
|
|
||||||
|
expect(active, 'no active row rendered').toBeTruthy();
|
||||||
|
|
||||||
|
// `::before` has no box unless it has content, so a width of zero
|
||||||
|
// on the inactive row is the assertion that the marker is *absent*
|
||||||
|
// there — which is the half that makes the present one mean
|
||||||
|
// something (a11y.22, WCAG 1.4.1).
|
||||||
|
const width = (el: Element) =>
|
||||||
|
parseFloat(getComputedStyle(el, '::before').borderLeftWidth) || 0;
|
||||||
|
|
||||||
|
expect(width(active!)).toBeGreaterThan(0);
|
||||||
|
expect(width(other!)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not move the row it marks', async () => {
|
||||||
|
const el = await listWithPlayingTrack();
|
||||||
|
|
||||||
|
const rows = shadowAll(el, '.track-row');
|
||||||
|
const lefts = rows.map(
|
||||||
|
(r) => r.querySelector('.cell')!.getBoundingClientRect().left,
|
||||||
|
);
|
||||||
|
|
||||||
|
// The marker lives in the row's own padding: the grid columns are
|
||||||
|
// computed from the host width, so anything in the flow would move
|
||||||
|
// every cell on the playing row and nothing else.
|
||||||
|
expect(new Set(lefts.map(Math.round)).size).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -194,6 +194,21 @@ describe('a queue row says which track its controls act on', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('marks the playing row with a shape, not only a colour', async () => {
|
||||||
|
const el = await panelWithQueue();
|
||||||
|
|
||||||
|
const rows = shadowAll(el, '.track-item');
|
||||||
|
const width = (r: Element) =>
|
||||||
|
parseFloat(getComputedStyle(r, '::before').borderLeftWidth) || 0;
|
||||||
|
|
||||||
|
// `a11y.22`, the same rule as track-list one panel over. Both
|
||||||
|
// directions: absent on the rows that are not playing is what
|
||||||
|
// makes the present one mean anything.
|
||||||
|
expect(rows[0]!.getAttribute('aria-current')).toBe('true');
|
||||||
|
expect(width(rows[0]!)).toBeGreaterThan(0);
|
||||||
|
expect(width(rows[1]!)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
it('gives the title and artist a tooltip, since the panel is resizable', async () => {
|
it('gives the title and artist a tooltip, since the panel is resizable', async () => {
|
||||||
const el = await panelWithQueue();
|
const el = await panelWithQueue();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user