fix(a11y): add a skip link, demote the subtitle, and size the sort arrow
a11y.30: `<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. Two things in it are load-bearing and only checkable against the running document: the link is 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; and `<main>` needs tabindex="-1", or the fragment link moves the scroll, leaves the tab sequence where it was, and looks like it worked. a11y.29: `<h1>` followed by `<h3>` for type size. An `hgroup` takes one heading plus paragraphs, so a `<p>` is also what it was meant to hold. a11y.34: the sort arrow was 10px, below the type scale's own floor, with a comment acknowledging it. Half of that finding was closed by Phase 1 — the direction is announced now, via aria-sort — and the other half is one declaration. And the state that landed in: the hgroup measured 67px inside a 64px bar, so dropping the h3's bottom margin shortened the block, moved the flex-centred pair down, and clipped the subtitle's descenders. The overflow was pre-existing; `margin-block: 0` on the title is the fix, pinned by a new layout-overflow case.
This commit is contained in:
@@ -135,3 +135,26 @@ test.describe('the app fits in its own window', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 */
|
||||
}
|
||||
|
||||
/* 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 {
|
||||
grid-area: top-bar;
|
||||
height: 100%;
|
||||
@@ -46,7 +76,14 @@ ul {
|
||||
|
||||
.title {
|
||||
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 {
|
||||
|
||||
+8
-2
@@ -10,10 +10,15 @@
|
||||
</head>
|
||||
|
||||
<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">
|
||||
<hgroup>
|
||||
<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>
|
||||
<library-filter></library-filter>
|
||||
<search-bar></search-bar>
|
||||
@@ -23,7 +28,8 @@
|
||||
<app-sidebar></app-sidebar>
|
||||
</div>
|
||||
<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>
|
||||
</main>
|
||||
<queue-panel id="queue-panel"></queue-panel>
|
||||
|
||||
@@ -943,7 +943,13 @@ export class TrackList
|
||||
}
|
||||
|
||||
.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;
|
||||
color: var(--yj-accent-text, #ffd43b);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user