Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion docs/feature-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -387,7 +387,29 @@ 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. **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;
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.
Expand Down
90 changes: 90 additions & 0 deletions libs/ui/src/a11y/option-group.directive.ts
Original file line number Diff line number Diff line change
@@ -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<string, number> = {
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<HTMLElement>>(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<HTMLButtonElement>('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();
}
}
62 changes: 62 additions & 0 deletions libs/ui/src/charts/chart-table.component.ts
Original file line number Diff line number Diff line change
@@ -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: `
<details class="chart-table">
<summary>{{ summary() }}</summary>
<div class="matrix-wrap">
<table class="matrix">
<caption class="sr-only">
{{
caption()
}}
</caption>
<thead>
<tr>
@for (column of columns(); track column) {
<th scope="col">{{ column }}</th>
}
</tr>
</thead>
<tbody>
@for (row of rows(); track row[0]) {
<tr>
@for (cell of row; track $index; let i = $index) {
@if (i === 0) {
<th scope="row">{{ cell }}</th>
} @else {
<td>{{ cell }}</td>
}
}
</tr>
}
</tbody>
</table>
</div>
</details>
`,
})
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<string>();
readonly columns = input.required<readonly string[]>();
/** Each row's first cell is its header — the thing the row is about. */
readonly rows = input.required<readonly (readonly (string | number)[])[]>();
}
2 changes: 2 additions & 0 deletions libs/ui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ 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 { 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';
Expand Down
20 changes: 20 additions & 0 deletions libs/ui/src/styles/_base.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
76 changes: 73 additions & 3 deletions libs/ui/src/styles/_tokens.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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;
Expand Down Expand Up @@ -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;
}
}
Loading