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
9 changes: 9 additions & 0 deletions .changeset/typeahead-token-tab-stop-named.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@astryxdesign/core': patch
---

[fix] With a value, a `Typeahead`'s Tab stop is the value's token button (WCAG 2.2 SC 2.4.6 and 4.1.2). It was named by the value alone ("Apple"), so screen reader users could not tell which field it belonged to. It is now named by the field label and the value ("Fruit Apple"), and described by the field's description and status.

A clickable `Token` passed `aria-labelledby` and `aria-describedby` to its root, a generic element where they named nothing. It now passes them to its inner button, the element that owns its action. Every other structure keeps them on its root.

@cixzhang @duytbp
47 changes: 24 additions & 23 deletions packages/core/src/Token/Token.spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ Consumer migration instructions belong in consumer docs and release notes.
| FR7 | The label never wraps: the root is capped at its container's inline size and clips overflow. In the static and link structures the Label ends in an ellipsis; in the clickable and link-and-remove structures the inner control clips the Label without an ellipsis. `isLabelHidden` keeps the Label in the DOM with a visually hidden clip treatment. | Current source and tests |
| FR8 | `color` defaults to `default`, painting `--color-neutral` with primary text; every other built-in tone paints its tinted background with its matching colored text. The `token` target reflects the resolved `color` and `size` for every value, including a theme-added color, which receives no component fill or text color until a theme paints it. | Current source and tests |
| FR9 | Clickable and link structures paint the shared hover overlay under a hover-capable pointer and the shared pressed overlay for mouse and touch presses; the static structure paints neither. | Current source |
| FR10 | `ref` reaches the root element of every structure. Supported DOM, ARIA, data, and event props reach the root; `data-testid` reaches the root; `xstyle`, `className`, and `style` merge in that order after component styles. | Current source and tests |
| FR10 | `ref` reaches the root element of every structure. Supported DOM, ARIA, data, and event props reach the root, except that in the clickable structure `aria-labelledby` and `aria-describedby` reach the inner button, the element that owns the token's action (`architecture:public-component-api` INV5); `data-testid` reaches the root; `xstyle`, `className`, and `style` merge in that order after component styles. | Current source and tests |

### Allowed variation

Expand All @@ -142,15 +142,15 @@ Consumer migration instructions belong in consumer docs and release notes.

### Representative states

| State | Required invariant | Allowed variation |
| ------------------ | ------------------------------------------------------------------------------------- | --------------------------- |
| Static | `span` root; Token adds no focusable element except a Remove button. | Tone, size, slots. |
| Clickable | One inner button named by the label; container click calls `onClick`. | End content. |
| Link | Root is the link; focus outline on the link itself. | Router link component. |
| Link and remove | Container holds a link and a sibling Remove button; surface click activates the link. | Modified and middle clicks. |
| Removable | Remove button named "Remove {label}" with a 24 by 24 CSS pixel target. | Locale wording. |
| Disabled clickable | Buttons focusable with `aria-disabled="true"`; no callback fires; half opacity. | Tone. |
| Hidden label | Label text visually hidden; the root's `aria-label` equals the label. | Structure. |
| State | Required invariant | Allowed variation |
| ------------------ | ------------------------------------------------------------------------------------------------------- | --------------------------- |
| Static | `span` root; Token adds no focusable element except a Remove button. | Tone, size, slots. |
| Clickable | One inner button named by the label, or by a caller `aria-labelledby`; container click calls `onClick`. | End content. |
| Link | Root is the link; focus outline on the link itself. | Router link component. |
| Link and remove | Container holds a link and a sibling Remove button; surface click activates the link. | Modified and middle clicks. |
| Removable | Remove button named "Remove {label}" with a 24 by 24 CSS pixel target. | Locale wording. |
| Disabled clickable | Buttons focusable with `aria-disabled="true"`; no callback fires; half opacity. | Tone. |
| Hidden label | Label text visually hidden; the root's `aria-label` equals the label. | Structure. |

### Transformation and precedence order

Expand Down Expand Up @@ -178,7 +178,8 @@ Consumer migration instructions belong in consumer docs and release notes.
separate focusable controls in DOM order; neither is nested in the other, so
the HTML interactive-content rule holds.
- **AR2 — Names.** In the clickable and link-and-remove structures the inner
button or link takes its name from the Label. In the link structure the link
button or link takes its name from the Label, or, in the clickable structure,
from a caller `aria-labelledby` (FR10). In the link structure the link

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Clickable representative state at line 148 still says "One inner button named by the label". With this AR2 a caller aria-labelledby names that button instead, so the record now gives two answers; amend line 148 with it, for example "One inner button named by the label, or by a caller aria-labelledby (FR10)".

is the root and takes its name from all of its content, including End content
(a label "Inbox" with a count reads "Inbox 12"). The
Remove button is named by the translated "Remove {label}" string.
Expand Down Expand Up @@ -234,18 +235,18 @@ token's text color.

## Verification map

| Contract | Verification | Representative states | Mutation or failure expectation | Audit section |
| ------------------ | -------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------- |
| FR2, FR10 | `Token.test.tsx` render, ref, and pass-through suites | Static, clickable, and link roots; caller props | A wrong root element, a detached ref, or a dropped caller prop fails DOM assertions. Gap: no test covers the ref on the link-and-remove container. | `audit:Token/behavior` |
| FR1, FR7 | `Token.stories.tsx` size, icon, and end-content fixtures in a browser | Sizes, icon and end content | Gap: the size and overflow unit tests assert presence only, and no story renders a long label; slot order and block size are visible only in the rendered fixtures, and the ellipsis has no check. | `audit:Token/layout` |
| FR3, FR4, FR6, AR1 | `Token.test.tsx` link, clickable, and link-with-remove suites | Link, clickable, link and remove, modified and middle clicks | Nesting the Remove button, firing the token action from Remove, or losing delegation fails. Gap: no test asserts that `onClick` is dropped with `href`. | `audit:Token/behavior` |
| FR5, AR5 | `Token.test.tsx` disabled tests | Disabled clickable with remove; disabled static; disabled link with and without remove | A native `disabled`, a lost tab stop, a disabled link with a destination, an unreflected disabled state, or a fired callback fails. | `audit:Token/a11y` |
| AR2, AR3, AR7 | `Token.test.tsx` accessibility suite | Remove name, hidden label, description | A missing remove name, lost hidden label, or missing description fails. Gap: no test asserts the link structure's content-derived name. | `audit:Token/a11y` |
| AR6 | `TokenRemoveTarget.a11y.chromium.spec.ts` | Pre-set filter tokens (default size), LTR and RTL | A remove target that misses its X edge or swallows a label press fails in real Chromium. Gap: the `sm` clipping is not covered. | `audit:Token/a11y` |
| FR8 | `Token.test.tsx` custom-color test; `extensibleAxes.test.ts`; `themingTargets.test.ts` | Theme-added color; map wiring | Dropping the `color` reflection fails. Gap: no test asserts `data-size` or any tone's paint; those are visible only in `Token.stories.tsx`. | `audit:Token/theming` |
| FR9, AR4 | `Token.stories.tsx` clickable and link fixtures in a browser | Hover, press, keyboard focus on the token | Gap: no automated check covers overlays or indicators, and no story renders a clickable or link token with Remove, so the double indicator on Remove has no fixture. | `audit:Token/design` |
| PR1 | Source review | Every structure | Gap: no automated check fails if Token adds a per-token listener, observer, or timer. | `audit:Token/behavior` |
| AR2, AR3, AR5, AR7 | Accessibility-tree evidence (`spec:AST-009` FR3) | Every structure; hidden label; description; disabled | Gap: no accessibility-tree check proves the computed names, the hidden-label name, the description, or the disabled state; the unit tests assert DOM attributes only. | `audit:Token/a11y` |
| Contract | Verification | Representative states | Mutation or failure expectation | Audit section |
| ------------------ | -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------- |
| FR2, FR10 | `Token.test.tsx` render, ref, and pass-through suites | Static, clickable, and link roots; caller props; caller naming on a clickable and a disabled link token | A wrong root element, a detached ref, a dropped caller prop, or a clickable token's `aria-labelledby` left on its root fails DOM assertions. Gap: no test covers the ref on the link-and-remove container. | `audit:Token/behavior` |
| FR1, FR7 | `Token.stories.tsx` size, icon, and end-content fixtures in a browser | Sizes, icon and end content | Gap: the size and overflow unit tests assert presence only, and no story renders a long label; slot order and block size are visible only in the rendered fixtures, and the ellipsis has no check. | `audit:Token/layout` |
| FR3, FR4, FR6, AR1 | `Token.test.tsx` link, clickable, and link-with-remove suites | Link, clickable, link and remove, modified and middle clicks | Nesting the Remove button, firing the token action from Remove, or losing delegation fails. Gap: no test asserts that `onClick` is dropped with `href`. | `audit:Token/behavior` |
| FR5, AR5 | `Token.test.tsx` disabled tests | Disabled clickable with remove; disabled static; disabled link with and without remove | A native `disabled`, a lost tab stop, a disabled link with a destination, an unreflected disabled state, or a fired callback fails. | `audit:Token/a11y` |
| AR2, AR3, AR7 | `Token.test.tsx` accessibility suite | Remove name, hidden label, description | A missing remove name, lost hidden label, or missing description fails. Gap: no test asserts the link structure's content-derived name. | `audit:Token/a11y` |
| AR6 | `TokenRemoveTarget.a11y.chromium.spec.ts` | Pre-set filter tokens (default size), LTR and RTL | A remove target that misses its X edge or swallows a label press fails in real Chromium. Gap: the `sm` clipping is not covered. | `audit:Token/a11y` |
| FR8 | `Token.test.tsx` custom-color test; `extensibleAxes.test.ts`; `themingTargets.test.ts` | Theme-added color; map wiring | Dropping the `color` reflection fails. Gap: no test asserts `data-size` or any tone's paint; those are visible only in `Token.stories.tsx`. | `audit:Token/theming` |
| FR9, AR4 | `Token.stories.tsx` clickable and link fixtures in a browser | Hover, press, keyboard focus on the token | Gap: no automated check covers overlays or indicators, and no story renders a clickable or link token with Remove, so the double indicator on Remove has no fixture. | `audit:Token/design` |
| PR1 | Source review | Every structure | Gap: no automated check fails if Token adds a per-token listener, observer, or timer. | `audit:Token/behavior` |
| AR2, AR3, AR5, AR7 | Accessibility-tree evidence (`spec:AST-009` FR3) | Every structure; hidden label; description; disabled | Gap: no accessibility-tree check proves the computed names, the hidden-label name, the description, or the disabled state; the unit tests assert DOM attributes only. | `audit:Token/a11y` |

## Decision log

Expand Down
37 changes: 37 additions & 0 deletions packages/core/src/Token/Token.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,43 @@ describe('Token accessibility', () => {
).toBeInTheDocument();
});

it('keeps aria-labelledby and aria-describedby on a disabled link token', () => {
render(
<>
<span id="dl-label">Docs section</span>
<span id="dl-hint">Unavailable offline</span>
<Token
label="Docs"
href="/docs"
isDisabled
aria-labelledby="dl-label"
aria-describedby="dl-hint"
/>
</>,
);
const link = screen.getByRole('link', {name: 'Docs section'});
expect(link).toHaveAccessibleDescription('Unavailable offline');
});

it("passes aria-labelledby and aria-describedby to a clickable token's button (public-component-api INV5)", () => {
render(
<>
<span id="field-label">Fruit</span>
<span id="field-hint">Pick one</span>
<Token
label="Apple"
onClick={() => {}}
aria-labelledby="field-label"
aria-describedby="field-hint"
data-testid="token"
/>
</>,
);
const button = screen.getByRole('button', {name: 'Fruit'});
expect(button).toHaveAccessibleDescription('Pick one');
expect(screen.getByTestId('token')).not.toHaveAttribute('aria-labelledby');
});

it('reflects the disabled state on the token target in every structure (theming-surface INV6)', () => {
render(
<>
Expand Down
Loading
Loading