From 65cac78db5b7b3735ad660e5a3b49c74e8584c0f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 22:42:38 +0000 Subject: [PATCH 1/2] The numbers behind the charts, and colours that clear the bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every chart here carries role="img" and a one-line summary, which tells a screen-reader user that a shape exists and roughly how big it is — not what it says. The radar and the values strips now fold out a real table of the same values: rows are the questions, columns are the people, answers are the answers rather than the 0..1 the shapes are drawn from. Unanswered reads as a dash, never as a zero, because "did not answer" and "answered zero" are different facts and the strip above draws only one dot. The seeking matrix got nothing, deliberately. It turned out to already be a real table — scoped headers, a visible level label in every cell — and a second table saying the same thing is worse for a screen reader than one. A spec now holds it to exactly one table, since the obvious future "fix" is to add the disclosure everywhere for consistency. Contrast: measured, not eyeballed, and three light-theme values were failing. --muted was 3.4:1 under the .fine text it exists for, which is the honest-limits copy — the last text in this app that should be hard to read. --series-4 was 2.1:1 and --series-3 2.7:1, both under the 3:1 that non-text graphics need, so a third or fourth person's line was effectively invisible on a light background. The green was found by the spec checking all four hues after I had eyeballed two: the ones that fail are not the ones that look faint, they are the ones whose luminance sits near the page's. Since it is measurable it is now tested — contrast.spec.ts reads the token file and checks text at 4.5:1 and graphics at 3:1 across both themes, so the next palette change cannot quietly undo this. Also answers prefers-contrast: more (greys give up first; series hues stay put, since darkening them toward each other costs the distinguishability they exist for) and forced-colors: active (tokens map onto system keywords, charts opt out via forced-color-adjust because four lines all painted CanvasText are four identical lines, and the focus ring is kept explicitly because nothing else will draw one). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UaDanzXTm6kVSgbNh1eNrs --- docs/feature-plan.md | 10 ++- libs/ui/src/charts/chart-table.component.ts | 62 +++++++++++++ libs/ui/src/index.ts | 1 + libs/ui/src/styles/_base.scss | 20 +++++ libs/ui/src/styles/_tokens.scss | 76 +++++++++++++++- libs/ui/src/styles/contrast.spec.ts | 90 +++++++++++++++++++ src/app/compare/panels/chart-tables.spec.ts | 88 ++++++++++++++++++ src/app/compare/panels/fingerprint.panel.ts | 24 ++++- .../compare/panels/seeking-matrix.panel.ts | 4 + src/app/compare/panels/values-strips.panel.ts | 22 ++++- 10 files changed, 388 insertions(+), 9 deletions(-) create mode 100644 libs/ui/src/charts/chart-table.component.ts create mode 100644 libs/ui/src/styles/contrast.spec.ts create mode 100644 src/app/compare/panels/chart-tables.spec.ts diff --git a/docs/feature-plan.md b/docs/feature-plan.md index 812641f..e7c99a7 100644 --- a/docs/feature-plan.md +++ b/docs/feature-plan.md @@ -387,7 +387,15 @@ it got rather than the retype challenge, which would have taxed the instant hatch the plan also asks us to protect. **Wave 4 — the sweep.** C3 chart alternatives · C4 contrast · C5 control -audit. Best done together, as one audit with one vocabulary. +audit. Best done together, as one audit with one vocabulary. **C3 and C4 +shipped.** Two notes for whoever picks up C5: + +- The interest matrix needed nothing — it was already a real table with + scoped headers and a visible level in every cell. Check before adding; + a second table is worse for a screen reader than one. +- Contrast is measurable, so `libs/ui/src/styles/contrast.spec.ts` now + measures it on every run. It found a failure by testing all four series + hues that eyeballing two had missed. **Wave 5 — reach.** D1 PWA · D2 share and print. Multiply the loop after it works. diff --git a/libs/ui/src/charts/chart-table.component.ts b/libs/ui/src/charts/chart-table.component.ts new file mode 100644 index 0000000..f56d34c --- /dev/null +++ b/libs/ui/src/charts/chart-table.component.ts @@ -0,0 +1,62 @@ +import { ChangeDetectionStrategy, Component, input } from '@angular/core'; + +/** + * The numbers behind a chart, as a real table. + * + * Every chart in this app carries `role="img"` and a one-line summary, which + * tells a screen-reader user that a shape exists and roughly how big it is — + * not what it says. This is the rest: the same values, in a table anyone can + * read cell by cell, folded away behind a disclosure so it costs sighted + * readers nothing. + * + * Not only for screen readers. A table is also what someone reaches for when + * they distrust a shape, or want to quote one row to the person they compared + * with, or is looking at a radar on a phone where the axis labels collide. + */ +@Component({ + selector: 'moxy-chart-table', + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+ {{ summary() }} +
+ + + + + @for (column of columns(); track column) { + + } + + + + @for (row of rows(); track row[0]) { + + @for (cell of row; track $index; let i = $index) { + @if (i === 0) { + + } @else { + + } + } + + } + +
+ {{ + caption() + }} +
{{ column }}
{{ cell }}{{ cell }}
+
+
+ `, +}) +export class ChartTableComponent { + /** The disclosure's own label — what opening it gets you. */ + readonly summary = input('Read this as a table'); + /** Describes the table to a screen reader; never shown. */ + readonly caption = input.required(); + readonly columns = input.required(); + /** Each row's first cell is its header — the thing the row is about. */ + readonly rows = input.required(); +} diff --git a/libs/ui/src/index.ts b/libs/ui/src/index.ts index 74fe8c4..39cfc04 100644 --- a/libs/ui/src/index.ts +++ b/libs/ui/src/index.ts @@ -5,6 +5,7 @@ export { PersonKeyComponent } from './charts/person-key.component'; export { ScaleStripComponent } from './charts/scale-strip.component'; export { InterestMatrixComponent } from './charts/interest-matrix.component'; export { MeterComponent } from './charts/meter.component'; +export { ChartTableComponent } from './charts/chart-table.component'; export { StatTileComponent } from './charts/stat-tile.component'; export { SimDotComponent } from './charts/sim-dot.component'; export { AnswerTextComponent } from './charts/answer-text.component'; diff --git a/libs/ui/src/styles/_base.scss b/libs/ui/src/styles/_base.scss index 2e1020f..0892670 100644 --- a/libs/ui/src/styles/_base.scss +++ b/libs/ui/src/styles/_base.scss @@ -1302,6 +1302,26 @@ textarea { } /* matrices */ +/* The numbers behind a chart, folded away. Costs sighted readers a line of + text; gives everyone else the data the shape is drawn from. */ +.chart-table { + margin-top: 12px; +} +.chart-table > summary { + cursor: pointer; + color: var(--ink-2); + font-size: 13.5px; + padding: 4px 2px; + border-radius: var(--radius-sm); + width: fit-content; +} +.chart-table > summary:hover { + color: var(--ink); +} +.chart-table[open] > summary { + margin-bottom: 6px; +} + .matrix-wrap { overflow-x: auto; } diff --git a/libs/ui/src/styles/_tokens.scss b/libs/ui/src/styles/_tokens.scss index 86e84a3..e27a804 100644 --- a/libs/ui/src/styles/_tokens.scss +++ b/libs/ui/src/styles/_tokens.scss @@ -20,7 +20,9 @@ --surface-2: #f2efe8; --ink: #0b0b0b; --ink-2: #52514e; - --muted: #898781; + /* Was #898781 — 3.4:1 on --page, which fails AA for the .fine text this + colour exists for. Recessive is a design intent; unreadable is not. */ + --muted: #6b6964; --hairline: #e1e0d9; --baseline: #c3c2b7; --border: rgba(11, 11, 11, 0.1); @@ -35,8 +37,13 @@ --series-1: #2a78d6; --series-2: #eb6834; - --series-3: #1baf7a; - --series-4: #eda100; + /* Was #1baf7a — 2.7:1, under the same bar. Found by the spec across all + four hues, not by eye: the ones that fail are not the ones that look + faint, they are the ones whose luminance happens to sit near the page's. */ + --series-3: #158f63; + /* Was #eda100 — 2.1:1, below even the 3:1 that non-text graphics need, so + a fourth person's line was effectively invisible on a light background. */ + --series-4: #ad7500; --ramp-1: #86b6ef; --ramp-2: #2a78d6; @@ -92,3 +99,66 @@ :root[data-theme='dark'] { @include dark-tokens; } + +/* ---------- contrast preferences ---------- */ + +/* "More contrast" is a real setting people turn on, usually because the + default is not working for them. Recessive greys are the first thing to + give up: --muted stops being a shade of the ink and becomes the ink, and + the hairlines that separate rows get strong enough to actually separate + them. Series hues stay put — they carry identity, and darkening them all + toward each other would cost the distinguishability they exist for. */ +@media (prefers-contrast: more) { + :root { + --muted: var(--ink-2); + --hairline: var(--baseline); + --border: rgba(11, 11, 11, 0.32); + } +} + +/* Dark's stronger border, in the same two-selector shape the theme itself + uses: the system preference, then the explicit toggle that must beat it. */ +@media (prefers-contrast: more) and (prefers-color-scheme: dark) { + :root:where(:not([data-theme='light'])) { + --border: rgba(255, 255, 255, 0.32); + } +} +@media (prefers-contrast: more) { + :root[data-theme='dark'] { + --border: rgba(255, 255, 255, 0.32); + } +} + +/* Forced colours (Windows high contrast and friends) replaces the palette + wholesale, and anything painted with a custom colour stops meaning + anything. Map the tokens onto the system keywords so the app keeps its + structure, and let charts keep their own hues via forced-color-adjust: + a four-line chart where every line is CanvasText is four identical lines. */ +@media (forced-colors: active) { + :root { + --page: Canvas; + --surface: Canvas; + --surface-2: Canvas; + --ink: CanvasText; + --ink-2: CanvasText; + --muted: CanvasText; + --hairline: CanvasText; + --baseline: GrayText; + --border: CanvasText; + --accent: LinkText; + --accent-ink: Canvas; + --accent-soft: Canvas; + --danger: LinkText; + } + + svg { + forced-color-adjust: none; + } + + /* The focus ring must survive: it is the only thing telling a keyboard + user where they are, and the system palette will not draw one for a + custom control. */ + :focus-visible { + outline: 2px solid Highlight; + } +} diff --git a/libs/ui/src/styles/contrast.spec.ts b/libs/ui/src/styles/contrast.spec.ts new file mode 100644 index 0000000..80f44f2 --- /dev/null +++ b/libs/ui/src/styles/contrast.spec.ts @@ -0,0 +1,90 @@ +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +/** + * Contrast is measurable, so it is testable, so it should not rely on anyone + * remembering to check. Two of these values were real AA failures found by + * measuring rather than by looking — `--muted` at 3.4:1 under the `.fine` + * text it exists for, and the fourth series hue at 2.1:1, below even the 3:1 + * that non-text graphics need. + * + * Reads the token file rather than a rendered page: the values are the + * contract, and a headless browser would only tell us the same numbers more + * slowly. + */ +const TOKENS = readFileSync(join(dirname(fileURLToPath(import.meta.url)), '_tokens.scss'), 'utf8'); + +function channel(value: number): number { + const c = value / 255; + return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; +} + +function luminance(hex: string): number { + const h = hex.replace('#', ''); + const [r, g, b] = [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16)); + return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b); +} + +function contrast(a: string, b: string): number { + const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x); + return (hi + 0.05) / (lo + 0.05); +} + +/** Read a token's hex value from the light block or the dark mixin. */ +function token(name: string, theme: 'light' | 'dark'): string { + const block = + theme === 'light' + ? TOKENS.slice(TOKENS.indexOf(':root {'), TOKENS.indexOf('@mixin dark-tokens')) + : TOKENS.slice(TOKENS.indexOf('@mixin dark-tokens')); + const match = block.match(new RegExp(`--${name}:\\s*(#[0-9a-fA-F]{6})`)); + if (!match) throw new Error(`no hex value for --${name} in the ${theme} palette`); + return match[1]; +} + +describe('token contrast', () => { + for (const theme of ['light', 'dark'] as const) { + describe(theme, () => { + // AA for normal text. --muted is the colour of `.fine`, which carries + // the honest-limits copy — the last text in this app that should be + // hard to read. + it('gives muted text 4.5:1 against both the page and a card', () => { + for (const surface of ['page', 'surface'] as const) { + const ratio = contrast(token('muted', theme), token(surface, theme)); + expect(ratio, `--muted on --${surface} (${theme})`).toBeGreaterThanOrEqual(4.5); + } + }); + + it('gives secondary ink 4.5:1 against a card', () => { + expect(contrast(token('ink-2', theme), token('surface', theme))).toBeGreaterThanOrEqual( + 4.5, + ); + }); + + it('gives the accent and danger colours 4.5:1 against a card', () => { + for (const name of ['accent', 'danger'] as const) { + const ratio = contrast(token(name, theme), token('surface', theme)); + expect(ratio, `--${name} (${theme})`).toBeGreaterThanOrEqual(4.5); + } + }); + + // Non-text graphics: 3:1 is the bar, and a series colour that misses it + // is a person's line nobody can see. + it('gives every series hue 3:1 as a graphic', () => { + for (const n of [1, 2, 3, 4]) { + const ratio = contrast(token(`series-${n}`, theme), token('surface', theme)); + expect(ratio, `--series-${n} (${theme})`).toBeGreaterThanOrEqual(3); + } + }); + }); + } + + it('answers the contrast and forced-colours preferences at all', () => { + expect(TOKENS).toContain('prefers-contrast: more'); + expect(TOKENS).toContain('forced-colors: active'); + // Charts opt out of forced colours on purpose: four lines all painted + // CanvasText are four identical lines. + expect(TOKENS).toContain('forced-color-adjust: none'); + }); +}); diff --git a/src/app/compare/panels/chart-tables.spec.ts b/src/app/compare/panels/chart-tables.spec.ts new file mode 100644 index 0000000..97e6f7a --- /dev/null +++ b/src/app/compare/panels/chart-tables.spec.ts @@ -0,0 +1,88 @@ +import { type Type } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { buildDemoCast, personaFromViewPhrase } from '@moxy/core'; +import { buildCompareModel, type CompareModel, type CompareSlot } from '../compare-model'; +import { FingerprintPanel } from './fingerprint.panel'; +import { SeekingMatrixPanel } from './seeking-matrix.panel'; +import { ValuesStripsPanel } from './values-strips.panel'; + +/** + * Every chart carries role="img" and a one-line summary, which says a shape + * exists without saying what it says. These tables are the rest of it, so + * what matters is that the numbers in them are the numbers the chart drew. + */ +async function demoModel(): Promise { + const cast = await buildDemoCast(); + const slots: CompareSlot[] = await Promise.all( + cast.map(async (profile) => ({ + ref: profile.phrase, + payload: profile.payload, + persona: await personaFromViewPhrase(profile.phrase), + })), + ); + return buildCompareModel(slots); +} + +function render(type: Type, model: CompareModel): HTMLElement { + const fixture = TestBed.createComponent(type); + fixture.componentRef.setInput('model', model); + fixture.detectChanges(); + return fixture.nativeElement; +} + +/** Rows as [header, ...cells], the way a screen reader would walk them. */ +function tableRows(el: HTMLElement): string[][] { + return [...el.querySelectorAll('tbody tr')].map((tr) => + [...tr.querySelectorAll('th, td')].map((cell) => cell.textContent?.trim() ?? ''), + ); +} + +describe('chart tables', () => { + let model: CompareModel; + + beforeEach(async () => { + model = await demoModel(); + await TestBed.configureTestingModule({}).compileComponents(); + }); + + it('gives the fingerprint its axes as rows and its people as columns', () => { + const el = render(FingerprintPanel, model); + const headers = [...el.querySelectorAll('thead th')].map((th) => th.textContent?.trim()); + expect(headers).toEqual(['Value', 'brave-azure-otter', 'calm-bright-owl']); + + const rows = tableRows(el); + expect(rows.length).toBeGreaterThanOrEqual(3); + // Values are the answers themselves, not the 0..1 the radar draws with. + for (const row of rows) { + expect(row[1]).toMatch(/^\d\/\d$/); + expect(row[2]).toMatch(/^\d\/\d$/); + } + }); + + // The interest matrix is already a table with scoped headers and a visible + // level in every cell, so it gets no second one — and must not grow one. + it('leaves the interest matrix as the single table it already is', () => { + const el = render(SeekingMatrixPanel, model); + expect(el.querySelectorAll('table')).toHaveLength(1); + expect(el.querySelector('details.chart-table')).toBeNull(); + const friendship = tableRows(el).find((row) => row[0] === 'Friendship'); + expect(friendship?.slice(0, 3)).toEqual(['Friendship', 'Into it', 'Into it']); + }); + + it('marks an unanswered scale as absent rather than as zero', async () => { + // One profile answers a values scale the other never touched. + const [first, second] = model.slots; + const thinnedAnswers = { ...second.payload!.a }; + delete thinnedAnswers['va.together']; + const thinned: CompareSlot = { + ...second, + payload: { ...second.payload!, a: thinnedAnswers }, + }; + + const rows = tableRows(render(ValuesStripsPanel, await buildCompareModel([first, thinned]))); + const together = rows.find((row) => row[0].includes('Togetherness')); + // A dash, not a zero: "did not answer" and "answered zero" are different + // facts, and the strip above draws only one dot for this row. + expect(together?.[2]).toBe('—'); + }); +}); diff --git a/src/app/compare/panels/fingerprint.panel.ts b/src/app/compare/panels/fingerprint.panel.ts index 1671b84..440ef04 100644 --- a/src/app/compare/panels/fingerprint.panel.ts +++ b/src/app/compare/panels/fingerprint.panel.ts @@ -1,6 +1,11 @@ import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core'; import { SCALE_MAX, getSection, type ScaleItem } from '@moxy/core'; -import { PersonKeyComponent, RadarComponent, type RadarSeries } from '@moxy/ui'; +import { + ChartTableComponent, + PersonKeyComponent, + RadarComponent, + type RadarSeries, +} from '@moxy/ui'; import type { CompareModel } from '../compare-model'; import type { ComparePanelComponent } from '../compare-panels.token'; @@ -12,7 +17,7 @@ import type { ComparePanelComponent } from '../compare-panels.token'; @Component({ selector: 'moxy-fingerprint-panel', changeDetection: ChangeDetectionStrategy.OnPush, - imports: [PersonKeyComponent, RadarComponent], + imports: [ChartTableComponent, PersonKeyComponent, RadarComponent], template: `

Values fingerprint

@@ -22,6 +27,11 @@ import type { ComparePanelComponent } from '../compare-panels.token';

+
`, }) @@ -43,6 +53,16 @@ export class FingerprintPanel implements ComparePanelComponent { protected readonly axes = computed(() => this.sharedScales().map((s) => s.right)); + /** The shape's own numbers: one row per axis, one column per person. */ + protected readonly tableColumns = computed(() => ['Value', ...this.model().names]); + + protected readonly tableRows = computed(() => + this.sharedScales().map((scale) => [ + `${scale.left} → ${scale.right}`, + ...this.model().payloads.map((p) => `${p.a[scale.id] as number}/${SCALE_MAX}`), + ]), + ); + protected readonly series = computed(() => this.model().payloads.map((p, i) => ({ name: this.model().names[i], diff --git a/src/app/compare/panels/seeking-matrix.panel.ts b/src/app/compare/panels/seeking-matrix.panel.ts index 4fc01f0..f7eb15a 100644 --- a/src/app/compare/panels/seeking-matrix.panel.ts +++ b/src/app/compare/panels/seeking-matrix.panel.ts @@ -11,6 +11,10 @@ import type { ComparePanelComponent } from '../compare-panels.token';

What each of you is open to

Highlighted rows are mutual — everyone answered is at least “Curious”.

+
`, diff --git a/src/app/compare/panels/values-strips.panel.ts b/src/app/compare/panels/values-strips.panel.ts index 210b7d3..5874303 100644 --- a/src/app/compare/panels/values-strips.panel.ts +++ b/src/app/compare/panels/values-strips.panel.ts @@ -1,13 +1,13 @@ import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core'; -import type { ScaleItem } from '@moxy/core'; -import { PersonKeyComponent, ScaleStripComponent } from '@moxy/ui'; +import { SCALE_MAX, itemLabel, type ScaleItem } from '@moxy/core'; +import { ChartTableComponent, PersonKeyComponent, ScaleStripComponent } from '@moxy/ui'; import type { CompareModel } from '../compare-model'; import type { ComparePanelComponent } from '../compare-panels.token'; @Component({ selector: 'moxy-values-strips-panel', changeDetection: ChangeDetectionStrategy.OnPush, - imports: [PersonKeyComponent, ScaleStripComponent], + imports: [ChartTableComponent, PersonKeyComponent, ScaleStripComponent], template: `

Values, side by side

@@ -20,6 +20,11 @@ import type { ComparePanelComponent } from '../compare-panels.token'; [names]="model().names" /> } +
`, }) @@ -31,6 +36,17 @@ export class ValuesStripsPanel implements ComparePanelComponent { return grid ? grid.rows.filter((r) => r.answeredCount > 0) : []; }); + protected readonly tableColumns = computed(() => ['Value', ...this.model().names]); + + protected readonly tableRows = computed(() => + this.rows().map((row) => [ + itemLabel(row.item), + // A dash, not a zero: unanswered and "answered zero" are different + // things, and a table that blurs them lies about the strip above it. + ...row.answers.map((a) => (typeof a === 'number' ? `${a}/${SCALE_MAX}` : '—')), + ]), + ); + protected asScale(item: unknown): ScaleItem { return item as ScaleItem; } From c75f204f96da7a3d9de3bccae5a55ec0affa860d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 23:00:29 +0000 Subject: [PATCH 2/2] Make the survey controls navigable, and say what they are set to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The audit expected these controls to need radio semantics. They do not: every answer control here is deselectable — clicking the chosen option clears the answer, because every question is optional — and an ARIA radio group may not behave that way. Announcing them as radios would have been tidier and would have lied. The aria-pressed toggles stay; the navigation is what was wrong. A fully expanded survey was 462 tab stops, one per option button: seven for a scale, four per interest item, one per choice. Seventy of them to cross "What I value" alone. That is a toll only keyboard users paid, and no mouse user could see it. OptionGroupDirective gives each group the composite-widget treatment — one tab stop, arrows within, Home and End to the ends, wrapping at the edges — so the survey is one stop per question instead. The tab stop follows the selection, so returning to an answered question lands on the answer rather than at the start of the row. The importance control was worse: which tier was selected lived entirely in a highlight class, so a screen reader was told nothing about the state of a control that decides how the whole comparison is weighted. It now says so, and both its rows are named groups rather than bare clusters of buttons. The e2e is what proves this did not break the mouse: it drives these exact controls by clicking, and a roving tabindex is an easy way to make that stop working. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UaDanzXTm6kVSgbNh1eNrs --- docs/feature-plan.md | 18 +- libs/ui/src/a11y/option-group.directive.ts | 90 ++++++++++ libs/ui/src/index.ts | 1 + .../survey/items/choice-editor.component.ts | 4 +- .../survey/items/interest-editor.component.ts | 4 +- .../survey/items/multi-editor.component.ts | 4 +- src/app/survey/items/option-group.spec.ts | 158 ++++++++++++++++++ .../survey/items/scale-editor.component.ts | 9 +- .../survey/items/weight-control.component.ts | 25 ++- 9 files changed, 304 insertions(+), 9 deletions(-) create mode 100644 libs/ui/src/a11y/option-group.directive.ts create mode 100644 src/app/survey/items/option-group.spec.ts diff --git a/docs/feature-plan.md b/docs/feature-plan.md index e7c99a7..7d80d1e 100644 --- a/docs/feature-plan.md +++ b/docs/feature-plan.md @@ -387,8 +387,22 @@ it got rather than the retype challenge, which would have taxed the instant hatch the plan also asks us to protect. **Wave 4 — the sweep.** C3 chart alternatives · C4 contrast · C5 control -audit. Best done together, as one audit with one vocabulary. **C3 and C4 -shipped.** Two notes for whoever picks up C5: +audit. Best done together, as one audit with one vocabulary. **Shipped.** + +C5 found two things worth recording, since neither was what the item +predicted. It guessed the controls needed radio semantics; they do not — +every answer control here is deselectable (clicking the chosen option clears +it, because every question is optional), and ARIA radios may not behave that +way. `aria-pressed` toggles are the honest match, so the roles stayed and the +navigation changed instead. What was actually broken: + +- **462 tab stops** in a fully expanded survey, one per option button — 70 + to cross "What I value" alone. A roving tabindex (`OptionGroupDirective`) + makes each question one stop with arrows inside it. +- **The importance control exposed no state at all.** Which tier was + selected lived in a highlight class, so a screen reader was told nothing. + +Two more notes: - The interest matrix needed nothing — it was already a real table with scoped headers and a visible level in every cell. Check before adding; diff --git a/libs/ui/src/a11y/option-group.directive.ts b/libs/ui/src/a11y/option-group.directive.ts new file mode 100644 index 0000000..7644beb --- /dev/null +++ b/libs/ui/src/a11y/option-group.directive.ts @@ -0,0 +1,90 @@ +import { Directive, ElementRef, HostListener, afterNextRender, inject } from '@angular/core'; + +/** Keys that move within the group rather than out of it. */ +const MOVES: Record = { + ArrowRight: 1, + ArrowDown: 1, + ArrowLeft: -1, + ArrowUp: -1, +}; + +/** + * One tab stop per question instead of one per option. + * + * Every answer control here is a row of buttons, and each button was its own + * tab stop: 7 for a scale, 4 per interest item, one per choice. A fully + * expanded survey came to 462 Tab presses — 70 of them to cross "What I + * value" alone. Keyboard users were paying a toll no mouse user could see. + * + * This is the composite-widget pattern: the group holds a single tab stop, + * arrows move between options inside it, Home and End jump to the ends. The + * buttons keep their `aria-pressed` toggle semantics, which matters — these + * controls are deselectable (clicking the chosen option clears the answer, + * because every question here is optional), and that is precisely what a + * `role="radio"` group may not do. Announcing them as radios would be tidier + * and would lie. + * + * The tab stop follows the selection, so returning to a question by Tab lands + * on the answer that is already given rather than back at the first option. + */ +@Directive({ selector: '[moxyOptionGroup]' }) +export class OptionGroupDirective { + private readonly host = inject>(ElementRef); + + constructor() { + // Before this runs every button is a tab stop; after it, exactly one is. + // It has to happen on render, or the group would be unreachable by Tab. + afterNextRender(() => this.syncTabStops()); + } + + private options(): HTMLButtonElement[] { + return [...this.host.nativeElement.querySelectorAll('button')]; + } + + /** The pressed option owns the tab stop; with none pressed, the first does. */ + private syncTabStops(): void { + const options = this.options(); + const pressed = options.findIndex((b) => b.getAttribute('aria-pressed') === 'true'); + const stop = pressed === -1 ? 0 : pressed; + options.forEach((button, i) => { + button.tabIndex = i === stop ? 0 : -1; + }); + } + + /** + * Re-sync whenever the group is entered or its selection changes. Cheaper + * and more robust than observing mutations: the DOM is small, and the only + * moments the right tab stop can change are the ones handled here. + */ + @HostListener('focusin') + @HostListener('click') + protected onInteract(): void { + this.syncTabStops(); + } + + @HostListener('keydown', ['$event']) + protected onKeydown(event: KeyboardEvent): void { + const step = MOVES[event.key]; + const isEdge = event.key === 'Home' || event.key === 'End'; + if (step === undefined && !isEdge) return; + + const options = this.options(); + if (options.length === 0) return; + const current = options.indexOf(document.activeElement as HTMLButtonElement); + if (current === -1) return; + + // Wrapping, so a row of options behaves like every other composite + // widget rather than dead-ending at its edges. + const next = isEdge + ? event.key === 'Home' + ? 0 + : options.length - 1 + : (current + step + options.length) % options.length; + + // Arrow keys inside a group must not also scroll the page. + event.preventDefault(); + options[current].tabIndex = -1; + options[next].tabIndex = 0; + options[next].focus(); + } +} diff --git a/libs/ui/src/index.ts b/libs/ui/src/index.ts index 39cfc04..56c40b5 100644 --- a/libs/ui/src/index.ts +++ b/libs/ui/src/index.ts @@ -6,6 +6,7 @@ export { ScaleStripComponent } from './charts/scale-strip.component'; export { InterestMatrixComponent } from './charts/interest-matrix.component'; export { MeterComponent } from './charts/meter.component'; export { ChartTableComponent } from './charts/chart-table.component'; +export { OptionGroupDirective } from './a11y/option-group.directive'; export { StatTileComponent } from './charts/stat-tile.component'; export { SimDotComponent } from './charts/sim-dot.component'; export { AnswerTextComponent } from './charts/answer-text.component'; diff --git a/src/app/survey/items/choice-editor.component.ts b/src/app/survey/items/choice-editor.component.ts index a266488..5e61629 100644 --- a/src/app/survey/items/choice-editor.component.ts +++ b/src/app/survey/items/choice-editor.component.ts @@ -1,12 +1,14 @@ import { ChangeDetectionStrategy, Component, input, output } from '@angular/core'; import type { AnswerValue, ChoiceItem } from '@moxy/core'; +import { OptionGroupDirective } from '@moxy/ui'; /** Single-select pills; clicking the selected pill clears the answer. */ @Component({ selector: 'moxy-choice-editor', changeDetection: ChangeDetectionStrategy.OnPush, + imports: [OptionGroupDirective], template: ` -
+
@for (opt of item().options; track $index) {
@if (weight() === 3) { -
+
I could match with: @for (opt of acceptOptions(); track $index) {