Compare commits
3
Commits
7410109884
...
2b41c27616
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b41c27616 | ||
|
|
f00d0c4655 | ||
|
|
b7831e3f15 |
No files matched your search
@@ -0,0 +1,57 @@
|
|||||||
|
import { test, expect } from '../support/fixtures.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plan 008 phase 3: the two sliders the audit filed as exemplary have
|
||||||
|
* no accessible name.
|
||||||
|
*
|
||||||
|
* `a11y.md` lists `seek-bar` and `volume-control` under **what is
|
||||||
|
* already correct** — "`wa-slider` with `aria-label` and a
|
||||||
|
* `valueFormatter`, so the seek position is announced as `3:42` rather
|
||||||
|
* than `222`". The formatter is real. The name was not: `wa-slider`
|
||||||
|
* puts `role="slider"` on a `<div id="slider" aria-labelledby="label">`
|
||||||
|
* inside its own shadow root, and that IDREF — pointing at an empty
|
||||||
|
* internal `<label>` — outranks whatever `aria-label` the host carries.
|
||||||
|
* Measured with `Accessibility.getFullAXTree` on all eleven views:
|
||||||
|
* name `""`, every time. `volume-control` had no `aria-label` at all.
|
||||||
|
*
|
||||||
|
* It is here rather than only in the component tier for the reason
|
||||||
|
* `dialog-names.spec.ts` gives: **only Playwright computes an
|
||||||
|
* accessible name.** The Vitest tier can assert the internal label
|
||||||
|
* carries the text and the IDREF still resolves to it; it cannot say
|
||||||
|
* whether anything would announce it. `getByRole('slider', { name })`
|
||||||
|
* matched nothing in this app before the fix.
|
||||||
|
*/
|
||||||
|
test.describe('a control says what it controls', () => {
|
||||||
|
test('the seek bar is announced as Seek', async ({ app }) => {
|
||||||
|
await expect(
|
||||||
|
app.getByRole('slider', { name: 'Seek' }),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the volume slider is announced as Volume', async ({ app }) => {
|
||||||
|
// The popup renders no slider at all while closed, the same way the
|
||||||
|
// queue panel renders no list — so this has to open it first.
|
||||||
|
await app.getByRole('button', { name: /volume/i }).click();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
app.getByRole('slider', { name: 'Volume' }),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
// Leave the transport as it was found: the specs share one page in
|
||||||
|
// file order, and an open popup covers the buttons beneath it.
|
||||||
|
await app.keyboard.press('Escape');
|
||||||
|
await app.locator('body').click({ position: { x: 5, y: 5 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('naming the slider did not move the transport', async ({ app }) => {
|
||||||
|
// `#slider` takes an 8px margin-block-start the moment a label
|
||||||
|
// exists, so the fix that gives it a name also grows it from 6px to
|
||||||
|
// 14px unless the margin is put back. Nothing else in this app
|
||||||
|
// would fail if it did — the bar would simply sit lower.
|
||||||
|
const height = await app
|
||||||
|
.getByRole('slider', { name: 'Seek' })
|
||||||
|
.evaluate((el) => el.getBoundingClientRect().height);
|
||||||
|
|
||||||
|
expect(height).toBeLessThan(10);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,6 +5,7 @@ import WaSlider from '@awesome.me/webawesome/dist/components/slider/slider.js';
|
|||||||
import { formatSeconds } from '@utils/time';
|
import { formatSeconds } from '@utils/time';
|
||||||
import { PlayerController } from '@store/controllers/player-controller';
|
import { PlayerController } from '@store/controllers/player-controller';
|
||||||
import { designTokens } from '../../../styles/tokens.css';
|
import { designTokens } from '../../../styles/tokens.css';
|
||||||
|
import { waSliderLabel } from '../../../styles/wa-slider-label.css';
|
||||||
|
|
||||||
const ProgressIntervalMillis = 1000;
|
const ProgressIntervalMillis = 1000;
|
||||||
|
|
||||||
@@ -25,7 +26,7 @@ export class SeekBar extends LitElement {
|
|||||||
@state()
|
@state()
|
||||||
private showRemaining: boolean = true;
|
private showRemaining: boolean = true;
|
||||||
|
|
||||||
static override styles = [designTokens, css`
|
static override styles = [designTokens, waSliderLabel, css`
|
||||||
wa-slider {
|
wa-slider {
|
||||||
--track-size: 6px;
|
--track-size: 6px;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -210,7 +211,7 @@ export class SeekBar extends LitElement {
|
|||||||
<div id="seek-bar-container">
|
<div id="seek-bar-container">
|
||||||
<small data-testid="elapsed-time">${elapsedTime}</small>
|
<small data-testid="elapsed-time">${elapsedTime}</small>
|
||||||
<wa-slider
|
<wa-slider
|
||||||
aria-label="Seek"
|
label="Seek"
|
||||||
.value="${this.seekValue}"
|
.value="${this.seekValue}"
|
||||||
max="${this.trackLength}"
|
max="${this.trackLength}"
|
||||||
?with-tooltip="${this.hasTrack}"
|
?with-tooltip="${this.hasTrack}"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import '@awesome.me/webawesome/dist/components/slider/slider.js';
|
|||||||
import type WaSlider from '@awesome.me/webawesome/dist/components/slider/slider.js';
|
import type WaSlider from '@awesome.me/webawesome/dist/components/slider/slider.js';
|
||||||
import { PlayerController } from '@store/controllers/player-controller';
|
import { PlayerController } from '@store/controllers/player-controller';
|
||||||
import { designTokens } from '../../../styles/tokens.css';
|
import { designTokens } from '../../../styles/tokens.css';
|
||||||
|
import { waSliderLabel } from '../../../styles/wa-slider-label.css';
|
||||||
|
|
||||||
/** Volume change (0-100) applied per scroll-wheel tick. */
|
/** Volume change (0-100) applied per scroll-wheel tick. */
|
||||||
const WHEEL_STEP = 5;
|
const WHEEL_STEP = 5;
|
||||||
@@ -28,7 +29,7 @@ export class VolumeControl extends LitElement {
|
|||||||
@state()
|
@state()
|
||||||
private pendingVolume: number | null = null;
|
private pendingVolume: number | null = null;
|
||||||
|
|
||||||
static override styles = [designTokens, css`
|
static override styles = [designTokens, waSliderLabel, css`
|
||||||
:host {
|
:host {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -209,6 +210,7 @@ export class VolumeControl extends LitElement {
|
|||||||
@click="${this.handlePopupClick}"
|
@click="${this.handlePopupClick}"
|
||||||
>
|
>
|
||||||
<wa-slider
|
<wa-slider
|
||||||
|
label="Volume"
|
||||||
orientation="vertical"
|
orientation="vertical"
|
||||||
min="0"
|
min="0"
|
||||||
max="100"
|
max="100"
|
||||||
|
|||||||
@@ -1,6 +1,26 @@
|
|||||||
import { LitElement, html, css, nothing } from 'lit';
|
import { LitElement, html, css, nothing } from 'lit';
|
||||||
import { customElement, property } from 'lit/decorators.js';
|
import { customElement, property } from 'lit/decorators.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The id every field's control carries, so its `<label>` can name it.
|
||||||
|
*
|
||||||
|
* The label was a *sibling* of the control with no `for`, which names
|
||||||
|
* nothing — so every select, toggle and text field in Settings computed
|
||||||
|
* an empty accessible name. Measured on the expanded Settings page:
|
||||||
|
* **24 of 93 controls unnamed**, six of them here and the rest the
|
||||||
|
* column toggles in `config-page`. `a11y.6` is not wrong about this;
|
||||||
|
* it says in the same line that it scanned every `<button>`, and none
|
||||||
|
* of these is one.
|
||||||
|
*
|
||||||
|
* A fixed id is safe, and only because each `config-field` is its own
|
||||||
|
* shadow root — the whole page renders a dozen elements with
|
||||||
|
* `id="control"` and each `for` resolves within its own root. It is
|
||||||
|
* preferred over `aria-label` for what it buys beyond the name: a real
|
||||||
|
* label association also makes the label text a click target for the
|
||||||
|
* control, which is behaviour, not annotation.
|
||||||
|
*/
|
||||||
|
const CONTROL_ID = 'control';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Schema describing a single config field.
|
* Schema describing a single config field.
|
||||||
*
|
*
|
||||||
@@ -225,7 +245,9 @@ export class ConfigField extends LitElement {
|
|||||||
${this.schema.type === 'toggle'
|
${this.schema.type === 'toggle'
|
||||||
? this.renderToggle()
|
? this.renderToggle()
|
||||||
: html`
|
: html`
|
||||||
<label>${this.schema.label}</label>
|
<label for=${CONTROL_ID}>
|
||||||
|
${this.schema.label}
|
||||||
|
</label>
|
||||||
${this.renderInput()}
|
${this.renderInput()}
|
||||||
`}
|
`}
|
||||||
${this.schema.description
|
${this.schema.description
|
||||||
@@ -257,6 +279,7 @@ export class ConfigField extends LitElement {
|
|||||||
private renderText() {
|
private renderText() {
|
||||||
return html`
|
return html`
|
||||||
<input
|
<input
|
||||||
|
id=${CONTROL_ID}
|
||||||
type="text"
|
type="text"
|
||||||
.value=${String(this.value ?? '')}
|
.value=${String(this.value ?? '')}
|
||||||
?disabled=${this.schema.disabled}
|
?disabled=${this.schema.disabled}
|
||||||
@@ -268,6 +291,7 @@ export class ConfigField extends LitElement {
|
|||||||
private renderNumber() {
|
private renderNumber() {
|
||||||
return html`
|
return html`
|
||||||
<input
|
<input
|
||||||
|
id=${CONTROL_ID}
|
||||||
type="number"
|
type="number"
|
||||||
.value=${String(this.value ?? '')}
|
.value=${String(this.value ?? '')}
|
||||||
?disabled=${this.schema.disabled}
|
?disabled=${this.schema.disabled}
|
||||||
@@ -281,6 +305,7 @@ export class ConfigField extends LitElement {
|
|||||||
|
|
||||||
return html`
|
return html`
|
||||||
<select
|
<select
|
||||||
|
id=${CONTROL_ID}
|
||||||
?disabled=${this.schema.disabled}
|
?disabled=${this.schema.disabled}
|
||||||
@change=${this.onSelectChange}
|
@change=${this.onSelectChange}
|
||||||
>
|
>
|
||||||
@@ -304,6 +329,7 @@ export class ConfigField extends LitElement {
|
|||||||
return html`
|
return html`
|
||||||
<div class="color-wrapper">
|
<div class="color-wrapper">
|
||||||
<input
|
<input
|
||||||
|
id=${CONTROL_ID}
|
||||||
type="color"
|
type="color"
|
||||||
.value=${hex}
|
.value=${hex}
|
||||||
?disabled=${this.schema.disabled}
|
?disabled=${this.schema.disabled}
|
||||||
@@ -318,11 +344,13 @@ export class ConfigField extends LitElement {
|
|||||||
return html`
|
return html`
|
||||||
<div class="input-row">
|
<div class="input-row">
|
||||||
<input
|
<input
|
||||||
|
id=${CONTROL_ID}
|
||||||
type="text"
|
type="text"
|
||||||
.value=${String(this.value ?? '')}
|
.value=${String(this.value ?? '')}
|
||||||
readonly
|
readonly
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
|
aria-label="Browse for ${this.schema.label}"
|
||||||
?disabled=${this.schema.disabled}
|
?disabled=${this.schema.disabled}
|
||||||
@click=${this.onBrowseClick}
|
@click=${this.onBrowseClick}
|
||||||
>
|
>
|
||||||
@@ -337,9 +365,10 @@ export class ConfigField extends LitElement {
|
|||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="toggle-row">
|
<div class="toggle-row">
|
||||||
<label>${this.schema.label}</label>
|
<label for=${CONTROL_ID}>${this.schema.label}</label>
|
||||||
<label class="toggle-switch">
|
<label class="toggle-switch">
|
||||||
<input
|
<input
|
||||||
|
id=${CONTROL_ID}
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
?checked=${checked}
|
?checked=${checked}
|
||||||
?disabled=${this.schema.disabled}
|
?disabled=${this.schema.disabled}
|
||||||
|
|||||||
@@ -1693,6 +1693,9 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
|
|||||||
const isLast =
|
const isLast =
|
||||||
idx === order.length - 1;
|
idx === order.length - 1;
|
||||||
|
|
||||||
|
const columnLabel =
|
||||||
|
COLUMN_DEFS[id]?.label ?? id;
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<li
|
<li
|
||||||
class="column-item ${checked ? 'enabled' : 'disabled'}"
|
class="column-item ${checked ? 'enabled' : 'disabled'}"
|
||||||
@@ -1700,6 +1703,7 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
|
|||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
class="column-toggle"
|
class="column-toggle"
|
||||||
|
aria-label="Show the ${columnLabel} column"
|
||||||
.checked=${checked}
|
.checked=${checked}
|
||||||
?disabled=${onlyOne}
|
?disabled=${onlyOne}
|
||||||
@change=${() =>
|
@change=${() =>
|
||||||
@@ -1710,9 +1714,7 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
|
|||||||
<span
|
<span
|
||||||
class="column-label"
|
class="column-label"
|
||||||
>
|
>
|
||||||
${COLUMN_DEFS[id]
|
${columnLabel}
|
||||||
?.label ??
|
|
||||||
id}
|
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
class="column-arrows"
|
class="column-arrows"
|
||||||
@@ -1723,6 +1725,7 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
|
|||||||
<button
|
<button
|
||||||
class="column-arrow-btn"
|
class="column-arrow-btn"
|
||||||
title="Move up"
|
title="Move up"
|
||||||
|
aria-label="Move ${columnLabel} up"
|
||||||
@click=${() =>
|
@click=${() =>
|
||||||
this.handleColumnMove(
|
this.handleColumnMove(
|
||||||
id,
|
id,
|
||||||
@@ -1738,6 +1741,7 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
|
|||||||
<button
|
<button
|
||||||
class="column-arrow-btn"
|
class="column-arrow-btn"
|
||||||
title="Move down"
|
title="Move down"
|
||||||
|
aria-label="Move ${columnLabel} down"
|
||||||
@click=${() =>
|
@click=${() =>
|
||||||
this.handleColumnMove(
|
this.handleColumnMove(
|
||||||
id,
|
id,
|
||||||
@@ -1812,6 +1816,7 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
|
|||||||
</span>
|
</span>
|
||||||
<shortcut-capture
|
<shortcut-capture
|
||||||
.action=${action}
|
.action=${action}
|
||||||
|
.label=${meta.label}
|
||||||
.currentKey=${bindings.get(
|
.currentKey=${bindings.get(
|
||||||
action,
|
action,
|
||||||
) ?? ''}
|
) ?? ''}
|
||||||
|
|||||||
@@ -8,6 +8,17 @@ export class ShortcutCapture extends LitElement {
|
|||||||
@property() currentKey = '';
|
@property() currentKey = '';
|
||||||
@property() defaultKey = '';
|
@property() defaultKey = '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the binding does, for the name.
|
||||||
|
*
|
||||||
|
* The button's text is the *key* — so the shortcuts list rendered
|
||||||
|
* three buttons called "S", two called "Down" and one called "?",
|
||||||
|
* beside a visible label that named none of them (it is a sibling,
|
||||||
|
* in another shadow root, and nothing associated the two). The
|
||||||
|
* label is what a control is for; the key is its value.
|
||||||
|
*/
|
||||||
|
@property() label = '';
|
||||||
|
|
||||||
@state() private recording = false;
|
@state() private recording = false;
|
||||||
|
|
||||||
static override styles = css`
|
static override styles = css`
|
||||||
@@ -134,6 +145,9 @@ export class ShortcutCapture extends LitElement {
|
|||||||
: this.currentKey
|
: this.currentKey
|
||||||
? ''
|
? ''
|
||||||
: 'not-set'}
|
: 'not-set'}
|
||||||
|
aria-label=${this.label
|
||||||
|
? `${this.label} shortcut: ${this.currentKey || 'not set'}`
|
||||||
|
: ''}
|
||||||
@click=${this.handleClick}
|
@click=${this.handleClick}
|
||||||
@keydown=${this.handleKeydown}
|
@keydown=${this.handleKeydown}
|
||||||
@blur=${this.handleBlur}
|
@blur=${this.handleBlur}
|
||||||
@@ -148,6 +162,8 @@ export class ShortcutCapture extends LitElement {
|
|||||||
class="reset-btn"
|
class="reset-btn"
|
||||||
@click=${this.handleReset}
|
@click=${this.handleReset}
|
||||||
title="Reset to default (${this.defaultKey})"
|
title="Reset to default (${this.defaultKey})"
|
||||||
|
aria-label="Reset ${this.label ||
|
||||||
|
this.action} to ${this.defaultKey}"
|
||||||
>
|
>
|
||||||
\u21BA
|
\u21BA
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -268,6 +268,7 @@ export class JobRow extends LitElement {
|
|||||||
|
|
||||||
return html`
|
return html`
|
||||||
<wa-progress-bar
|
<wa-progress-bar
|
||||||
|
label=${job.title}
|
||||||
value=${progressPercent(job) ?? 0}
|
value=${progressPercent(job) ?? 0}
|
||||||
></wa-progress-bar>
|
></wa-progress-bar>
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -1786,6 +1786,7 @@ export class PlaylistView extends ViewLifecycleMixin(LitElement) {
|
|||||||
: html`
|
: html`
|
||||||
<span
|
<span
|
||||||
class="playlist-name"
|
class="playlist-name"
|
||||||
|
title=${entry.summary.Name}
|
||||||
>
|
>
|
||||||
${entry.summary
|
${entry.summary
|
||||||
.Name}
|
.Name}
|
||||||
|
|||||||
@@ -1595,6 +1595,13 @@ export class QueuePanel
|
|||||||
|
|
||||||
const artUrl = track.coverArtPath || '';
|
const artUrl = track.coverArtPath || '';
|
||||||
|
|
||||||
|
// The panel's width is user-resizable down to MIN_WIDTH, so
|
||||||
|
// both of these are routinely clipped (a11y.24) — and the
|
||||||
|
// remove button is one of every row, named identically
|
||||||
|
// (a11y.32).
|
||||||
|
const title = this.getDisplayTitle(track);
|
||||||
|
const artist = track.artist || 'Unknown Artist';
|
||||||
|
|
||||||
// No inline closures — all events delegated via data-index
|
// No inline closures — all events delegated via data-index
|
||||||
// on the virtualizer element (see firstUpdated).
|
// on the virtualizer element (see firstUpdated).
|
||||||
return html`
|
return html`
|
||||||
@@ -1620,16 +1627,17 @@ export class QueuePanel
|
|||||||
</span>
|
</span>
|
||||||
${artUrl ? html`<div class="track-art"><img src="${artUrl}" alt="" loading="lazy" /></div>` : nothing}
|
${artUrl ? html`<div class="track-art"><img src="${artUrl}" alt="" loading="lazy" /></div>` : nothing}
|
||||||
<div class="track-details">
|
<div class="track-details">
|
||||||
<span class="track-title">
|
<span class="track-title" title=${title}>
|
||||||
${trackLink(this.getDisplayTitle(track), track.album, track.releaseGroupMbid, track.recordingMbid, undefined, track.artist)}
|
${trackLink(title, track.album, track.releaseGroupMbid, track.recordingMbid, undefined, track.artist)}
|
||||||
</span>
|
</span>
|
||||||
<span class="track-artist">
|
<span class="track-artist" title=${artist}>
|
||||||
${artistLink(track.artist, track.artistMbid) || 'Unknown Artist'}
|
${artistLink(track.artist, track.artistMbid) || 'Unknown Artist'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
class="remove-button"
|
class="remove-button"
|
||||||
title="Remove from queue"
|
title="Remove from queue"
|
||||||
|
aria-label="Remove ${title} from queue"
|
||||||
>
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512" width="14" height="14">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512" width="14" height="14">
|
||||||
${svg`<path fill="currentColor" d="M342.6 150.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L192 210.7 86.6 105.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L146.7 256 41.4 361.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L192 301.3 297.4 406.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L237.3 256 342.6 150.6z"/>`}
|
${svg`<path fill="currentColor" d="M342.6 150.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L192 210.7 86.6 105.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L146.7 256 41.4 361.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L192 301.3 297.4 406.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L237.3 256 342.6 150.6z"/>`}
|
||||||
|
|||||||
@@ -118,10 +118,12 @@ export class TrackInfo extends LitElement {
|
|||||||
${showCover ? this.renderCoverArt() : nothing}
|
${showCover ? this.renderCoverArt() : nothing}
|
||||||
<div class="text">
|
<div class="text">
|
||||||
${displayTitle
|
${displayTitle
|
||||||
? html`<span class="title">${displayTitle}</span>`
|
? html`<span class="title" title=${displayTitle}
|
||||||
|
>${displayTitle}</span
|
||||||
|
>`
|
||||||
: nothing}
|
: nothing}
|
||||||
${secondaryParts
|
${secondaryParts
|
||||||
? html`<span class="secondary"
|
? html`<span class="secondary" title=${secondaryParts}
|
||||||
>${secondaryParts}</span
|
>${secondaryParts}</span
|
||||||
>`
|
>`
|
||||||
: nothing}
|
: nothing}
|
||||||
|
|||||||
@@ -1839,8 +1839,12 @@ export class TrackList
|
|||||||
display = albumLink(track.Album, track.ReleaseGroupMBID, display as any, track.ArtistName);
|
display = albumLink(track.Album, track.ReleaseGroupMBID, display as any, track.ArtistName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `title` on the cell rather than on whatever is inside
|
||||||
|
// it: the value may be a link, a highlighted match or
|
||||||
|
// plain text, and a tooltip is inherited by descendants
|
||||||
|
// either way (a11y.24).
|
||||||
return html`
|
return html`
|
||||||
<div role="gridcell" class=${classMap({
|
<div role="gridcell" title=${val} class=${classMap({
|
||||||
cell: true,
|
cell: true,
|
||||||
'cell-center': centered,
|
'cell-center': centered,
|
||||||
'cell-right': !centered && col.align === 'right',
|
'cell-right': !centered && col.align === 'right',
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { css } from 'lit';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A `wa-slider` is named by its `label`, and its `label` is visible.
|
||||||
|
*
|
||||||
|
* The same trap as `utils/name-dialog.ts`, one component over, and this
|
||||||
|
* one the audit filed under "what is already correct": `a11y.md` lists
|
||||||
|
* `seek-bar` and `volume-control` as exemplary because they pass
|
||||||
|
* `aria-label`. The role is not on the host. Web Awesome renders a
|
||||||
|
* `<div id="slider" role="slider" aria-labelledby="label">` inside its
|
||||||
|
* own shadow root, pointing at an internal `<label id="label">` that is
|
||||||
|
* empty unless the `label` property is set — and `aria-labelledby`
|
||||||
|
* outranks the host's `aria-label`, which the AX tree never sees. Both
|
||||||
|
* sliders in this app computed a name of `""`. Measured with
|
||||||
|
* `Accessibility.getFullAXTree` against the running app, on all eleven
|
||||||
|
* views; `volume-control` did not even have the `aria-label` the audit
|
||||||
|
* credits it with.
|
||||||
|
*
|
||||||
|
* So the name comes from `label`, which is the library's own API, and
|
||||||
|
* this hides it. That is preferred over reaching into the shadow root
|
||||||
|
* the way `name-dialog.ts` has to, for the failure mode: if Web Awesome
|
||||||
|
* renames these parts the label becomes *visible* — wrong-looking and
|
||||||
|
* correctly named — rather than silently nameless again.
|
||||||
|
*
|
||||||
|
* The second rule is not decoration. `#slider` takes an 8px
|
||||||
|
* `margin-block-start` as soon as a label exists, so hiding the label
|
||||||
|
* alone still grows the control from 6px to 14px and moves the transport
|
||||||
|
* bar. `display: none` on the label is deliberate and safe: an element
|
||||||
|
* referenced by `aria-labelledby` contributes its text even when it is
|
||||||
|
* hidden, which is exactly the accname rule this relies on (verified —
|
||||||
|
* the slider reports "Seek" with the label displaying nothing).
|
||||||
|
*/
|
||||||
|
export const waSliderLabel = css`
|
||||||
|
wa-slider::part(label) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
wa-slider::part(slider) {
|
||||||
|
margin-block-start: 0;
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -15,6 +15,7 @@ import type { LitElement } from 'lit';
|
|||||||
import '@components/track-list/track-list';
|
import '@components/track-list/track-list';
|
||||||
import '@components/artists-view/artists-view';
|
import '@components/artists-view/artists-view';
|
||||||
import '@components/genres-view/genres-view';
|
import '@components/genres-view/genres-view';
|
||||||
|
import '@components/track-info/track-info';
|
||||||
import { emit, stub, flush, resetHarness } from '@test/support/harness';
|
import { emit, stub, flush, resetHarness } from '@test/support/harness';
|
||||||
import { Events } from '../../src/events';
|
import { Events } from '../../src/events';
|
||||||
import { fixture, shadow, shadowAll } from '@test/support/render';
|
import { fixture, shadow, shadowAll } from '@test/support/render';
|
||||||
@@ -191,3 +192,46 @@ describe('a selectable grid is a listbox, not a row of buttons', () => {
|
|||||||
expect(list!.getAttribute('aria-multiselectable')).toBe('true');
|
expect(list!.getAttribute('aria-multiselectable')).toBe('true');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('a clipped value is readable somewhere', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
resetHarness();
|
||||||
|
searchStore.setTerm('');
|
||||||
|
stub('library.Library.GetAllTracks', TRACKS);
|
||||||
|
stub('library.Library.GetAllAlbums', []);
|
||||||
|
emit(Events.LibraryScanComplete);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives every track-list cell the value it may be clipping', async () => {
|
||||||
|
const el = await fixture<LitElement>('track-list');
|
||||||
|
|
||||||
|
sized(el);
|
||||||
|
await settle(el);
|
||||||
|
|
||||||
|
const titles = shadowAll(el, '.track-row [role="gridcell"].cell').map((c) =>
|
||||||
|
c.getAttribute('title'),
|
||||||
|
);
|
||||||
|
|
||||||
|
// `a11y.24`: `text-overflow: ellipsis` in 40+ places, and the
|
||||||
|
// highest-density lists were the ones without a `title`. The
|
||||||
|
// attribute is on the cell rather than on what is inside it,
|
||||||
|
// because the value may be a link or a highlighted match and a
|
||||||
|
// tooltip is inherited by descendants either way.
|
||||||
|
expect(titles.length).toBeGreaterThan(0);
|
||||||
|
expect(titles).toContain('Departure');
|
||||||
|
expect(titles.every((t) => t !== null && t !== '')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives track-info its own title and secondary line', async () => {
|
||||||
|
const el = await fixture<LitElement>('track-info', {
|
||||||
|
trackTitle: 'An Exhaustively Overlong Track Name',
|
||||||
|
artist: 'Aurora Fields',
|
||||||
|
});
|
||||||
|
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
expect(shadow(el, '.title')?.getAttribute('title'))
|
||||||
|
.toBe('An Exhaustively Overlong Track Name');
|
||||||
|
expect(shadow(el, '.secondary')?.getAttribute('title')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -19,7 +19,7 @@ import { describe, expect, it, beforeEach } from 'vitest';
|
|||||||
import '@components/queue-panel/queue-panel';
|
import '@components/queue-panel/queue-panel';
|
||||||
import type { QueuePanel } from '@components/queue-panel/queue-panel';
|
import type { QueuePanel } from '@components/queue-panel/queue-panel';
|
||||||
import { Events } from '../../src/events';
|
import { Events } from '../../src/events';
|
||||||
import { emit, calls, flush, lastArgs } from '@test/support/harness';
|
import { emit, calls, flush, lastArgs, resetHarness } from '@test/support/harness';
|
||||||
import { fixture, shadow, shadowAll } from '@test/support/render';
|
import { fixture, shadow, shadowAll } from '@test/support/render';
|
||||||
import type { QueueTrack } from '@store/queue-store';
|
import type { QueueTrack } from '@store/queue-store';
|
||||||
|
|
||||||
@@ -170,3 +170,36 @@ describe('<queue-panel> keyboard reorder', () => {
|
|||||||
expect(calls().some((c) => c.path.includes('Move'))).toBe(false);
|
expect(calls().some((c) => c.path.includes('Move'))).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('a queue row says which track its controls act on', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
resetHarness();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names each remove button after its own track', async () => {
|
||||||
|
const el = await panelWithQueue();
|
||||||
|
|
||||||
|
const labels = shadowAll(el, '.remove-button').map((b) =>
|
||||||
|
b.getAttribute('aria-label'),
|
||||||
|
);
|
||||||
|
|
||||||
|
// `a11y.32`: `title="Remove from queue"` on every row is a name
|
||||||
|
// that never identifies which track — four identical buttons in a
|
||||||
|
// list whose whole purpose is the order.
|
||||||
|
expect(labels.slice(0, 4)).toEqual([
|
||||||
|
'Remove First from queue',
|
||||||
|
'Remove Second from queue',
|
||||||
|
'Remove Third from queue',
|
||||||
|
'Remove Fourth from queue',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives the title and artist a tooltip, since the panel is resizable', async () => {
|
||||||
|
const el = await panelWithQueue();
|
||||||
|
|
||||||
|
// `a11y.24` calls this one acute: MIN_WIDTH is narrow enough that
|
||||||
|
// both lines clip routinely, and nothing else can show the value.
|
||||||
|
expect(shadow(el, '.track-title')?.getAttribute('title')).toBe('First');
|
||||||
|
expect(shadow(el, '.track-artist')?.getAttribute('title')).toBe('Artist');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
/**
|
||||||
|
* Every form control in Settings had a visible label and no name.
|
||||||
|
*
|
||||||
|
* Not in the audit, and `a11y.6` says why in its own text: it scanned
|
||||||
|
* every `<button>`, and a `<select>` is not one. Measured with
|
||||||
|
* `Accessibility.getFullAXTree` against the running app with all seven
|
||||||
|
* sections expanded — **24 of 93 controls unnamed**: every
|
||||||
|
* `config-field` select and toggle, and all eighteen track-list column
|
||||||
|
* checkboxes. In each case a `<label>` sat right beside the control
|
||||||
|
* with nothing associating the two.
|
||||||
|
*
|
||||||
|
* Two of the fixes here are about a name that exists and does not
|
||||||
|
* identify anything, which is `a11y.32`'s complaint one page over:
|
||||||
|
* three shortcut buttons announced themselves as "S", and thirty-six
|
||||||
|
* column arrows as "Move up" or "Move down".
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import '@components/config-page/config-field';
|
||||||
|
import '@components/config-page/shortcut-capture';
|
||||||
|
import { fixture, shadow } from '@test/support/render';
|
||||||
|
|
||||||
|
/** What the `<label>` in this shadow root actually points at. */
|
||||||
|
function labelledControl(host: Element): Element | null {
|
||||||
|
const forId = shadow(host, 'label[for]')?.getAttribute('for');
|
||||||
|
|
||||||
|
return forId ? shadow(host, `#${CSS.escape(forId)}`) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('a config field names its control', () => {
|
||||||
|
it('associates the label with a select', async () => {
|
||||||
|
const el = await fixture('config-field', {
|
||||||
|
schema: {
|
||||||
|
key: 'theme.shade',
|
||||||
|
label: 'Shade',
|
||||||
|
type: 'select',
|
||||||
|
options: [{ value: 'dark', label: 'Dark' }],
|
||||||
|
},
|
||||||
|
value: 'dark',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(labelledControl(el)?.tagName).toBe('SELECT');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('associates the label with a toggle, which is a second label deep', async () => {
|
||||||
|
const el = await fixture('config-field', {
|
||||||
|
schema: { key: 'x', label: 'Scan on startup', type: 'toggle' },
|
||||||
|
value: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// The checkbox is wrapped in a *styling* label carrying only a
|
||||||
|
// span, which contributes no text and so named nothing.
|
||||||
|
const control = labelledControl(el) as HTMLInputElement | null;
|
||||||
|
|
||||||
|
expect(control?.type).toBe('checkbox');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['text', 'number', 'color', 'directory'] as const)(
|
||||||
|
'associates the label with a %s field',
|
||||||
|
async (type) => {
|
||||||
|
const el = await fixture('config-field', {
|
||||||
|
schema: { key: 'k', label: 'Music folder', type },
|
||||||
|
value: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(labelledControl(el)).toBeTruthy();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('says which field a Browse button browses for', async () => {
|
||||||
|
const el = await fixture('config-field', {
|
||||||
|
schema: { key: 'k', label: 'Music folder', type: 'directory' },
|
||||||
|
value: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(shadow(el, 'button')?.getAttribute('aria-label'))
|
||||||
|
.toBe('Browse for Music folder');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('a shortcut button says what it binds', () => {
|
||||||
|
it('names the action, and keeps the key as the value', async () => {
|
||||||
|
const el = await fixture('shortcut-capture', {
|
||||||
|
action: 'player.playPause',
|
||||||
|
label: 'Play / Pause',
|
||||||
|
currentKey: 'Space',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(shadow(el, 'button')?.getAttribute('aria-label'))
|
||||||
|
.toBe('Play / Pause shortcut: Space');
|
||||||
|
expect(shadow(el, 'button')?.textContent?.trim()).toBe('Space');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says so when there is no key rather than announcing nothing', async () => {
|
||||||
|
const el = await fixture('shortcut-capture', {
|
||||||
|
action: 'tracklist.delete',
|
||||||
|
label: 'Delete Track',
|
||||||
|
currentKey: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(shadow(el, 'button')?.getAttribute('aria-label'))
|
||||||
|
.toBe('Delete Track shortcut: not set');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names the action in the reset button too', async () => {
|
||||||
|
const el = await fixture('shortcut-capture', {
|
||||||
|
action: 'player.next',
|
||||||
|
label: 'Next Track',
|
||||||
|
currentKey: 'X',
|
||||||
|
defaultKey: 'N',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(shadow(el, '.reset-btn')?.getAttribute('aria-label'))
|
||||||
|
.toBe('Reset Next Track to N');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -375,7 +375,11 @@ describe('<seek-bar>', () => {
|
|||||||
it('carries an accessible name, since it is otherwise an unlabelled slider', async () => {
|
it('carries an accessible name, since it is otherwise an unlabelled slider', async () => {
|
||||||
const el = await fixture('seek-bar');
|
const el = await fixture('seek-bar');
|
||||||
|
|
||||||
expect(shadow(el, 'wa-slider')?.getAttribute('aria-label')).toBe('Seek');
|
// This asserted `aria-label` on the host for six phases, and the
|
||||||
|
// host is not what carries `role="slider"` — the name never
|
||||||
|
// reached the accessibility tree. `wa-control-names.test.ts` is
|
||||||
|
// the whole story; the name now comes from `label`.
|
||||||
|
expect(shadow(el, 'wa-slider')?.getAttribute('label')).toBe('Seek');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('looks the way it did last time', async () => {
|
it('looks the way it did last time', async () => {
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
/**
|
||||||
|
* Two Web Awesome controls put the role somewhere the host's
|
||||||
|
* `aria-label` cannot reach it, and this pins the way out of each.
|
||||||
|
*
|
||||||
|
* `a11y.md` lists `seek-bar` and `volume-control` under **what is
|
||||||
|
* already correct** ("`wa-slider` with `aria-label`"). Measured against
|
||||||
|
* the running app with `Accessibility.getFullAXTree`, both sliders
|
||||||
|
* computed a name of `""` on all eleven views: the role is on a
|
||||||
|
* `<div id="slider" aria-labelledby="label">` inside `wa-slider`'s own
|
||||||
|
* shadow root, and an `aria-labelledby` pointing at an empty internal
|
||||||
|
* `<label>` outranks the host's `aria-label`. `volume-control` did not
|
||||||
|
* have the `aria-label` the audit credits it with in the first place.
|
||||||
|
*
|
||||||
|
* `a11y.25` is the same family, and the fix is the library's own API in
|
||||||
|
* both cases — `label` — which for a progress bar is invisible and for a
|
||||||
|
* slider is not, hence `styles/wa-slider-label.css.ts`.
|
||||||
|
*
|
||||||
|
* What this tier checks is the *wiring*: that the internal label carries
|
||||||
|
* the text, that the IDREF still points at it, and that hiding it does
|
||||||
|
* not move the control. Computing an accessible name is Playwright's
|
||||||
|
* job, and `e2e/specs/control-names.spec.ts` does it there.
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import '@awesome.me/webawesome/dist/components/slider/slider.js';
|
||||||
|
import '@awesome.me/webawesome/dist/components/progress-bar/progress-bar.js';
|
||||||
|
import '@components/audio-player/seekbar/seek-bar';
|
||||||
|
import '@components/audio-player/volume-control/volume-control';
|
||||||
|
import '@components/jobs/job-row';
|
||||||
|
import { fixture, shadow } from '@test/support/render';
|
||||||
|
|
||||||
|
/** The element Web Awesome puts `role="slider"` on, and its name source. */
|
||||||
|
function sliderName(wa: Element | null): string | null {
|
||||||
|
const inner = wa?.shadowRoot?.querySelector('[role="slider"]');
|
||||||
|
const id = inner?.getAttribute('aria-labelledby');
|
||||||
|
|
||||||
|
if (!id) return null;
|
||||||
|
|
||||||
|
return wa?.shadowRoot?.getElementById(id)?.textContent?.trim() ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('naming a wa-slider', () => {
|
||||||
|
it('names the seek bar through the internal label, not the host', async () => {
|
||||||
|
const el = await fixture('seek-bar');
|
||||||
|
const wa = shadow(el, 'wa-slider');
|
||||||
|
|
||||||
|
await (wa as HTMLElement & { updateComplete: Promise<unknown> })
|
||||||
|
.updateComplete;
|
||||||
|
|
||||||
|
// The positive case: the name a screen reader would compute is
|
||||||
|
// reachable from the element that carries the role.
|
||||||
|
expect(sliderName(wa)).toBe('Seek');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the seek bar the height it was without a label', async () => {
|
||||||
|
const el = await fixture('seek-bar');
|
||||||
|
const wa = shadow(el, 'wa-slider') as HTMLElement;
|
||||||
|
|
||||||
|
await (wa as HTMLElement & { updateComplete: Promise<unknown> })
|
||||||
|
.updateComplete;
|
||||||
|
|
||||||
|
// `#slider` takes an 8px margin-block-start as soon as a label
|
||||||
|
// exists, so hiding the label alone still grows the control from
|
||||||
|
// 6px to 14px and moves the transport bar with it.
|
||||||
|
const label = wa.shadowRoot?.querySelector('[part~="label"]') as HTMLElement;
|
||||||
|
|
||||||
|
expect(getComputedStyle(label).display).toBe('none');
|
||||||
|
expect(wa.getBoundingClientRect().height).toBeLessThan(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names the volume slider, which had no name of any kind', async () => {
|
||||||
|
const el = await fixture('volume-control');
|
||||||
|
const trigger = shadow<HTMLElement>(el, 'button');
|
||||||
|
|
||||||
|
trigger?.click();
|
||||||
|
await (el as HTMLElement & { updateComplete: Promise<unknown> })
|
||||||
|
.updateComplete;
|
||||||
|
|
||||||
|
const wa = shadow(el, 'wa-slider');
|
||||||
|
|
||||||
|
await (wa as HTMLElement & { updateComplete: Promise<unknown> })
|
||||||
|
.updateComplete;
|
||||||
|
|
||||||
|
expect(sliderName(wa)).toBe('Volume');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('naming a wa-progress-bar', () => {
|
||||||
|
it('says what is progressing rather than "progress"', async () => {
|
||||||
|
const el = await fixture('job-row', {
|
||||||
|
job: {
|
||||||
|
id: 'j1',
|
||||||
|
title: 'Scanning Music',
|
||||||
|
kind: 'library-scan',
|
||||||
|
state: 'running',
|
||||||
|
current: 45,
|
||||||
|
total: 100,
|
||||||
|
caps: { pausable: false, cancellable: false },
|
||||||
|
stages: [],
|
||||||
|
stats: [],
|
||||||
|
startedAt: 0,
|
||||||
|
updatedAt: 0,
|
||||||
|
logCount: 0,
|
||||||
|
warnCount: 0,
|
||||||
|
errorCount: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const bar = shadow(el, 'wa-progress-bar');
|
||||||
|
|
||||||
|
await (bar as HTMLElement & { updateComplete: Promise<unknown> })
|
||||||
|
.updateComplete;
|
||||||
|
|
||||||
|
// Web Awesome maps `label` onto the inner role="progressbar"'s
|
||||||
|
// aria-label, falling back to the localised word "progress" — so
|
||||||
|
// this was never *unnamed*, it was named after the widget instead
|
||||||
|
// of after the work.
|
||||||
|
const inner = bar?.shadowRoot?.querySelector('[role="progressbar"]');
|
||||||
|
|
||||||
|
expect(inner?.getAttribute('aria-label')).toBe('Scanning Music');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user