@astryxdesign/core 0.7.2-canary.07f97c6 → 0.7.2-canary.198a7e3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astryxdesign/core",
3
- "version": "0.7.2-canary.07f97c6",
3
+ "version": "0.7.2-canary.198a7e3",
4
4
  "displayName": "Astryx Core",
5
5
  "description": "The component library. Accessible, themeable React components with built-in spacing, dark mode, and StyleX styling.",
6
6
  "author": "Meta Open Source",
@@ -792,8 +792,8 @@
792
792
  "react-dom": ">=19.0.0"
793
793
  },
794
794
  "devDependencies": {
795
- "@astryxdesign/a11y-spec": "0.7.2-canary.07f97c6",
796
- "@astryxdesign/cli": "0.7.2-canary.07f97c6",
795
+ "@astryxdesign/a11y-spec": "0.7.2-canary.198a7e3",
796
+ "@astryxdesign/cli": "0.7.2-canary.198a7e3",
797
797
  "@babel/cli": "^7.29.7",
798
798
  "@babel/core": "^7.29.7",
799
799
  "@babel/preset-react": "^7.29.7",
@@ -1352,18 +1352,6 @@ describe('DropdownMenu items', () => {
1352
1352
  );
1353
1353
  expect(handleClick).not.toHaveBeenCalled();
1354
1354
  });
1355
-
1356
- it('has aria-disabled when disabled', () => {
1357
- render(
1358
- <DropdownMenu
1359
- button={{label: 'Actions'}}
1360
- items={[{label: 'Edit', isDisabled: true}]}
1361
- />,
1362
- );
1363
- expect(
1364
- screen.getByRole('menuitem', {name: 'Edit', hidden: true}),
1365
- ).toHaveAttribute('aria-disabled', 'true');
1366
- });
1367
1355
  });
1368
1356
 
1369
1357
  describe('DropdownMenu sections', () => {
@@ -0,0 +1,145 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Menu.a11y.chromium.spec.ts
5
+ * @input Built menu stories and MENU_PATTERN in real Chromium
6
+ * @output Browser evidence for every menu expectation across the DropdownMenu pointer-menu states
7
+ * @position Chromium lane of the DropdownMenu menu-surface binding
8
+ */
9
+
10
+ import {
11
+ expect,
12
+ test,
13
+ type CDPSession,
14
+ type Locator,
15
+ type Page,
16
+ } from '@playwright/test';
17
+ import {
18
+ MENU_PATTERN,
19
+ blockingResults,
20
+ checkAccessibilitySpec,
21
+ formatFailures,
22
+ neverExercised,
23
+ summarize,
24
+ unmatchedKnownFailures,
25
+ type BindingResult,
26
+ } from '@astryxdesign/a11y-spec';
27
+ import {
28
+ createChromiumHarness,
29
+ holdMotionStill,
30
+ } from '@astryxdesign/a11y-spec/chromium';
31
+ import {
32
+ DEFAULT_STORYBOOK_DIR,
33
+ serveStorybook,
34
+ type StaticServer,
35
+ } from '@astryxdesign/a11y-spec/storybook';
36
+ import {MENU_KNOWN_FAILURES} from './Menu.a11y.known-failures';
37
+ import {MENU_BINDING_STATES, type MenuBindingState} from './Menu.a11y.states';
38
+
39
+ let storybook: StaticServer;
40
+
41
+ test.beforeAll(async () => {
42
+ storybook = await serveStorybook(
43
+ process.env.ASTRYX_STORYBOOK_DIR ?? DEFAULT_STORYBOOK_DIR,
44
+ );
45
+ });
46
+
47
+ test.afterAll(async () => {
48
+ await storybook?.close();
49
+ });
50
+
51
+ function storyUrl(storyId: string): string {
52
+ return `${storybook.origin}/iframe.html?id=${storyId}&viewMode=story`;
53
+ }
54
+
55
+ /**
56
+ * Opens the menu the way a keyboard user does — Enter on the focused trigger —
57
+ * and waits until focus is on the first item, so every expectation starts from
58
+ * a displayed menu with focus inside it.
59
+ */
60
+ async function mountState(
61
+ page: Page,
62
+ state: MenuBindingState,
63
+ ): Promise<{subject: Locator; related: Record<string, Locator>}> {
64
+ await page.goto(storyUrl(state.storyId), {waitUntil: 'load'});
65
+ await holdMotionStill(page);
66
+ const opener = page
67
+ .locator('#storybook-root')
68
+ .getByRole('button', {name: state.label});
69
+ await opener.focus();
70
+ await page.keyboard.press('Enter');
71
+ const id = await opener.getAttribute('aria-controls');
72
+ if (id == null) {
73
+ throw new Error(`menu state "${state.id}" has no aria-controls`);
74
+ }
75
+ const related: Record<string, Locator> = {opener};
76
+ for (const [relation, [role, name]] of Object.entries(state.items)) {
77
+ related[relation] = page.getByRole(
78
+ role as Parameters<Page['getByRole']>[0],
79
+ {
80
+ name,
81
+ exact: true,
82
+ includeHidden: true,
83
+ },
84
+ );
85
+ }
86
+ await expect(related['first-item']).toBeFocused();
87
+ return {subject: page.locator(`[id="${id}"]`), related};
88
+ }
89
+
90
+ /**
91
+ * How many item actions the story's handlers have run. The story renders the
92
+ * count as an attribute, so this reads a fact the page shows rather than
93
+ * instrumenting it from the test.
94
+ */
95
+ async function activationsOn(page: Page): Promise<number> {
96
+ const raw = await page
97
+ .locator('[data-a11y-activations]')
98
+ .first()
99
+ .getAttribute('data-a11y-activations');
100
+ return Number(raw ?? '0');
101
+ }
102
+
103
+ async function runState(
104
+ page: Page,
105
+ cdp: CDPSession,
106
+ state: MenuBindingState,
107
+ ): Promise<BindingResult> {
108
+ return checkAccessibilitySpec({
109
+ spec: MENU_PATTERN,
110
+ binding: 'DropdownMenu.menu',
111
+ state: state.id,
112
+ facts: state.facts,
113
+ knownFailures: MENU_KNOWN_FAILURES,
114
+ mount: async () => {
115
+ const {subject, related} = await mountState(page, state);
116
+ return createChromiumHarness({page, cdp, subject, related});
117
+ },
118
+ activations: async () => activationsOn(page),
119
+ });
120
+ }
121
+
122
+ test('every menu expectation is exercised by a DropdownMenu state', async ({
123
+ page,
124
+ }) => {
125
+ const cdp = await page.context().newCDPSession(page);
126
+ const results: BindingResult[] = [];
127
+ for (const state of MENU_BINDING_STATES) {
128
+ results.push(await runState(page, cdp, state));
129
+ }
130
+ expect(neverExercised(results)).toEqual([]);
131
+ expect(unmatchedKnownFailures(MENU_KNOWN_FAILURES, results)).toEqual([]);
132
+ });
133
+
134
+ for (const state of MENU_BINDING_STATES) {
135
+ test(`DropdownMenu.menu [${state.id}] — ${state.summary}`, async ({page}) => {
136
+ const cdp = await page.context().newCDPSession(page);
137
+ const result = await runState(page, cdp, state);
138
+ const report = summarize(MENU_PATTERN, [result]);
139
+ // Required rows block (AST-020 FR9); advisory rows and exact known
140
+ // failures report.
141
+ expect(formatFailures(blockingResults([result]))).toBe('');
142
+ expect(report.unrunLayers).toEqual([]);
143
+ expect(report.counts.unexpectedPass).toBe(0);
144
+ });
145
+ }
@@ -0,0 +1,26 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Menu.a11y.known-failures.ts
5
+ * @input The exact menu-contract failures the shipped DropdownMenu produces today
6
+ * @output MENU_KNOWN_FAILURES — records both lanes reconcile against, so a fix shows up as an unexpected pass and any other failure stays a failure
7
+ * @position Known-failure ledger for the DropdownMenu menu-surface binding
8
+ */
9
+
10
+ import type {KnownFailure} from '@astryxdesign/a11y-spec';
11
+
12
+ export const MENU_KNOWN_FAILURES: ReadonlyArray<KnownFailure> = [
13
+ {
14
+ expectation: 'menu.keys.enter-activates-and-closes',
15
+ binding: 'DropdownMenu.menu',
16
+ state: 'dropdown-selectable-checkbox-row',
17
+ evidenceLayer: 'real-browser',
18
+ failureEquals: 'Enter on an item left the menu displayed',
19
+ standardsReference:
20
+ 'APG Menu and Menubar, Enter: "Otherwise, activates the item and closes the menu" (adopted as a required part by spec:AST-020 FR15); WCAG 2.2 2.1.1 Keyboard',
21
+ userImpact:
22
+ 'A keyboard user who presses Enter on a checkbox row toggles it but stays in the open menu, where the pattern says Enter chooses and closes; Escape or Tab is needed to get back to the page.',
23
+ reason:
24
+ 'DropdownMenuCheckboxItem keeps the menu open by default (hasCloseOnSelect is false), shipped as released behavior under component:DropdownMenu FR16. Whether Enter on a checkbox row closes the menu, or the component record names staying open as an exception, is a behavior decision for the component owner; this test-only binding records it and changes nothing.',
25
+ },
26
+ ];
@@ -0,0 +1,65 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ /**
6
+ * @file Menu.a11y.renders.tsx
7
+ * @input One menu binding state from Menu.a11y.states.ts
8
+ * @output The React tree every lane renders for that state (the jsdom test directly, the story through Storybook), publishing how many item actions have run as `data-a11y-activations`
9
+ * @position Shared fixture for the DropdownMenu menu-surface binding
10
+ */
11
+
12
+ import {useState, type ReactElement} from 'react';
13
+ import {DropdownMenu} from '../DropdownMenu';
14
+ import {DropdownMenuItem} from '../DropdownMenuItem';
15
+ import {DropdownMenuCheckboxItem} from '../DropdownMenuCheckboxItem';
16
+ import {DropdownMenuRadioGroup} from '../DropdownMenuRadioGroup';
17
+ import {DropdownMenuRadioItem} from '../DropdownMenuRadioItem';
18
+ import {MENU_ACTION_ITEMS, type MenuBindingState} from './Menu.a11y.states';
19
+
20
+ function MenuState({
21
+ content,
22
+ label,
23
+ }: Pick<MenuBindingState, 'content' | 'label'>): ReactElement {
24
+ // An item's action leaves no trace on the menu, so every handler counts
25
+ // here and the count is rendered for the browser lane to read.
26
+ const [activations, setActivations] = useState(0);
27
+ const activate = () => setActivations(count => count + 1);
28
+ const menu =
29
+ content === 'actions' ? (
30
+ <DropdownMenu
31
+ button={{label}}
32
+ items={MENU_ACTION_ITEMS.map(item => ({...item, onClick: activate}))}
33
+ presentation="popover"
34
+ />
35
+ ) : (
36
+ <DropdownMenu button={{label}} presentation="popover">
37
+ <DropdownMenuItem label="Edit" onClick={activate} />
38
+ {/* No hasCloseOnSelect: the row keeps the menu open by default. */}
39
+ <DropdownMenuCheckboxItem
40
+ label="Show archived"
41
+ value={true}
42
+ onChange={activate}
43
+ />
44
+ <DropdownMenuRadioGroup
45
+ value="newest"
46
+ onChange={activate}
47
+ label="Sort by">
48
+ <DropdownMenuRadioItem value="newest" label="Newest" />
49
+ <DropdownMenuRadioItem value="oldest" label="Oldest" />
50
+ </DropdownMenuRadioGroup>
51
+ </DropdownMenu>
52
+ );
53
+ return (
54
+ <div data-a11y-activations={activations}>
55
+ {menu}
56
+ <button type="button">After the menu</button>
57
+ </div>
58
+ );
59
+ }
60
+
61
+ export function renderMenuState(
62
+ state: Pick<MenuBindingState, 'content' | 'label'>,
63
+ ): ReactElement {
64
+ return <MenuState content={state.content} label={state.label} />;
65
+ }
@@ -0,0 +1,138 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Menu.a11y.states.ts
5
+ * @input The DropdownMenu pointer menu's representative open states and the facts MENU_PATTERN reads
6
+ * @output MENU_BINDING_STATES and the explicit ownership exclusions for this binding
7
+ * @position Binding inventory shared by the jsdom test and the Chromium spec (component:DropdownMenu menu surface under spec:AST-020 FR15)
8
+ */
9
+
10
+ import type {MenuStateFacts} from '@astryxdesign/a11y-spec';
11
+
12
+ export interface MenuBindingState {
13
+ readonly id: string;
14
+ readonly summary: string;
15
+ readonly storyId: string;
16
+ /** Which menu content the state renders. */
17
+ readonly content: 'actions' | 'selectable';
18
+ /** The trigger's accessible name; the menu is named by it. */
19
+ readonly label: string;
20
+ /** Relation name → [role, accessible name] of the item it designates. */
21
+ readonly items: Readonly<Record<string, readonly [string, string]>>;
22
+ readonly facts: MenuStateFacts;
23
+ }
24
+
25
+ export const MENU_ACTION_ITEMS = [
26
+ {label: 'Rename'},
27
+ {label: 'Duplicate'},
28
+ {label: 'Archive', isDisabled: true},
29
+ {label: 'Delete'},
30
+ ] as const;
31
+
32
+ const story = (id: string) => `a11y-menu--${id}`;
33
+
34
+ export const MENU_BINDING_STATES = [
35
+ {
36
+ id: 'dropdown-actions',
37
+ summary:
38
+ 'a DropdownMenu of four actions, one disabled, opened from its Button trigger',
39
+ storyId: story('dropdown-actions'),
40
+ content: 'actions',
41
+ label: 'Actions',
42
+ items: {
43
+ 'first-item': ['menuitem', 'Rename'],
44
+ 'second-item': ['menuitem', 'Duplicate'],
45
+ 'last-item': ['menuitem', 'Delete'],
46
+ 'disabled-item': ['menuitem', 'Archive'],
47
+ 'typeahead-target': ['menuitem', 'Duplicate'],
48
+ },
49
+ facts: {
50
+ enterTarget: 'first-item',
51
+ wraps: true,
52
+ homeEnd: true,
53
+ disabledItem: true,
54
+ checked: {},
55
+ typeaheadCharacter: 'd',
56
+ },
57
+ },
58
+ {
59
+ id: 'dropdown-selectable',
60
+ summary:
61
+ 'a DropdownMenu holding an action, a checked checkbox row and a titled radio group',
62
+ storyId: story('dropdown-selectable'),
63
+ content: 'selectable',
64
+ label: 'View',
65
+ items: {
66
+ 'first-item': ['menuitem', 'Edit'],
67
+ 'second-item': ['menuitemcheckbox', 'Show archived'],
68
+ 'last-item': ['menuitemradio', 'Oldest'],
69
+ 'checkbox-item': ['menuitemcheckbox', 'Show archived'],
70
+ 'radio-item': ['menuitemradio', 'Newest'],
71
+ 'unchecked-radio-item': ['menuitemradio', 'Oldest'],
72
+ 'typeahead-target': ['menuitemradio', 'Oldest'],
73
+ },
74
+ facts: {
75
+ enterTarget: 'first-item',
76
+ wraps: true,
77
+ homeEnd: true,
78
+ disabledItem: false,
79
+ checked: {
80
+ 'checkbox-item': true,
81
+ 'radio-item': true,
82
+ 'unchecked-radio-item': false,
83
+ },
84
+ typeaheadCharacter: 'o',
85
+ },
86
+ },
87
+ {
88
+ id: 'dropdown-selectable-checkbox-row',
89
+ summary:
90
+ 'the same selectable menu with Enter pressed on its checkbox row, which keeps the menu open (DropdownMenuCheckboxItem hasCloseOnSelect defaults to false)',
91
+ storyId: story('dropdown-selectable-checkbox-row'),
92
+ content: 'selectable',
93
+ label: 'View',
94
+ items: {
95
+ 'first-item': ['menuitem', 'Edit'],
96
+ 'second-item': ['menuitemcheckbox', 'Show archived'],
97
+ 'last-item': ['menuitemradio', 'Oldest'],
98
+ 'checkbox-item': ['menuitemcheckbox', 'Show archived'],
99
+ 'radio-item': ['menuitemradio', 'Newest'],
100
+ 'unchecked-radio-item': ['menuitemradio', 'Oldest'],
101
+ 'typeahead-target': ['menuitemradio', 'Oldest'],
102
+ },
103
+ facts: {
104
+ enterTarget: 'checkbox-item',
105
+ wraps: true,
106
+ homeEnd: true,
107
+ disabledItem: false,
108
+ checked: {
109
+ 'checkbox-item': true,
110
+ 'radio-item': true,
111
+ 'unchecked-radio-item': false,
112
+ },
113
+ typeaheadCharacter: 'o',
114
+ },
115
+ },
116
+ ] as const satisfies ReadonlyArray<MenuBindingState>;
117
+
118
+ export const MENU_EXCLUSIONS = [
119
+ {
120
+ id: 'opening-control',
121
+ owner: 'the menu-button pattern (MENU_BUTTON_PATTERN, MenuButton.a11y.*)',
122
+ reason:
123
+ 'How the trigger exposes itself and where focus lands when it opens the menu are the Menu Button contract. This binding opens the menu from the trigger with Enter only to reach the open state it checks.',
124
+ },
125
+ {
126
+ id: 'submenus',
127
+ owner:
128
+ 'DropdownMenuSubMenu.test.tsx and DropdownMenuDrillIn.a11y.chromium.spec.ts',
129
+ reason:
130
+ 'Right and Left Arrow opening and closing a flyout, a parent row’s haspopup and expanded state, and the compact-touch drill-in are not yet in a shared contract; the menu pattern exempts them to the binding, so none of these states renders a sub-menu.',
131
+ },
132
+ {
133
+ id: 'bottom-sheet-and-header-footer',
134
+ owner: 'component:DropdownMenu AR4/AR5 and the modal-dialog pattern',
135
+ reason:
136
+ 'On a compact touch display the menu is a bottom sheet, and with a header or footer the popover is a modal dialog in which Tab cycles instead of closing; both are dialogs, so every state here uses the plain pointer popover.',
137
+ },
138
+ ] as const;
@@ -0,0 +1,136 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ /** @vitest-environment jsdom */
3
+
4
+ /**
5
+ * @file Menu.a11y.test.tsx
6
+ * @input MENU_PATTERN bound to the open DropdownMenu pointer menu
7
+ * @output jsdom-lane proof of the DOM expectations and an honest "unrun" report for the browser layers
8
+ * @position Binding test for the DropdownMenu menu surface (the Chromium spec covers the rest)
9
+ */
10
+
11
+ import {cleanup, render, screen} from '@testing-library/react';
12
+ import userEvent from '@testing-library/user-event';
13
+ import {describe, expect, it} from 'vitest';
14
+ import {
15
+ MENU_PATTERN,
16
+ checkAccessibilitySpec,
17
+ createJsdomHarness,
18
+ expectAccessibilitySpec,
19
+ summarize,
20
+ type BindingResult,
21
+ } from '@astryxdesign/a11y-spec';
22
+ import {MENU_KNOWN_FAILURES} from './Menu.a11y.known-failures';
23
+ import {renderMenuState} from './Menu.a11y.renders';
24
+ import {
25
+ MENU_BINDING_STATES,
26
+ MENU_EXCLUSIONS,
27
+ type MenuBindingState,
28
+ } from './Menu.a11y.states';
29
+
30
+ function trigger(state: MenuBindingState): HTMLElement {
31
+ return screen.getByRole('button', {name: state.label});
32
+ }
33
+
34
+ function menuFor(state: MenuBindingState): HTMLElement {
35
+ const id = trigger(state).getAttribute('aria-controls');
36
+ const menu = id == null ? null : document.getElementById(id);
37
+ if (menu == null) {
38
+ throw new Error(`menu state "${state.id}" has no controlled menu`);
39
+ }
40
+ return menu;
41
+ }
42
+
43
+ function relatedFor(
44
+ state: MenuBindingState,
45
+ ): Readonly<Record<string, Element>> {
46
+ const related: Record<string, Element> = {opener: trigger(state)};
47
+ for (const [relation, [role, name]] of Object.entries(state.items)) {
48
+ related[relation] = screen.getByRole(role, {name, hidden: true});
49
+ }
50
+ return related;
51
+ }
52
+
53
+ async function open(state: MenuBindingState): Promise<void> {
54
+ render(renderMenuState(state));
55
+ await userEvent.setup().click(trigger(state));
56
+ }
57
+
58
+ async function checkState(state: MenuBindingState): Promise<BindingResult> {
59
+ return checkAccessibilitySpec({
60
+ spec: MENU_PATTERN,
61
+ binding: 'DropdownMenu.menu',
62
+ state: state.id,
63
+ facts: state.facts,
64
+ knownFailures: MENU_KNOWN_FAILURES,
65
+ mount: async () => {
66
+ await open(state);
67
+ return createJsdomHarness({
68
+ subject: menuFor(state),
69
+ related: relatedFor(state),
70
+ });
71
+ },
72
+ unmount: cleanup,
73
+ });
74
+ }
75
+
76
+ describe('DropdownMenu pointer menu — menu pattern, jsdom lane', () => {
77
+ it.each(
78
+ MENU_BINDING_STATES.map(state => [state.id, state.summary, state] as const),
79
+ )('%s — %s', async (_id, _summary, state) => {
80
+ await expectAccessibilitySpec({
81
+ spec: MENU_PATTERN,
82
+ binding: 'DropdownMenu.menu',
83
+ state: state.id,
84
+ facts: state.facts,
85
+ knownFailures: MENU_KNOWN_FAILURES,
86
+ render: async () => {
87
+ await open(state);
88
+ },
89
+ subject: () => menuFor(state),
90
+ related: () => relatedFor(state),
91
+ cleanup,
92
+ });
93
+ });
94
+
95
+ it('runs the DOM layer and reports the browser-owned outcomes as unrun', async () => {
96
+ const results: BindingResult[] = [];
97
+ for (const state of MENU_BINDING_STATES) {
98
+ results.push(await checkState(state));
99
+ }
100
+ const report = summarize(MENU_PATTERN, results);
101
+ expect(report.counts.pass).toBeGreaterThan(0);
102
+ expect(report.unrunLayers).toEqual(['accessibility-tree', 'real-browser']);
103
+ expect(report.counts.unexpectedPass).toBe(0);
104
+ });
105
+ });
106
+
107
+ describe('menu binding inventory', () => {
108
+ it('gives every representative state a distinct checked-in story', () => {
109
+ const stories = MENU_BINDING_STATES.map(state => state.storyId);
110
+ expect(new Set(stories).size).toBe(stories.length);
111
+ });
112
+
113
+ it('keeps the opening control, submenus and dialog presentations explicit', () => {
114
+ expect(MENU_EXCLUSIONS.map(row => row.id)).toEqual([
115
+ 'opening-control',
116
+ 'submenus',
117
+ 'bottom-sheet-and-header-footer',
118
+ ]);
119
+ for (const exclusion of MENU_EXCLUSIONS) {
120
+ expect(exclusion.owner.length, exclusion.id).toBeGreaterThan(20);
121
+ expect(exclusion.reason.length, exclusion.id).toBeGreaterThan(80);
122
+ }
123
+ });
124
+
125
+ it('renders the menu and every item relation it names for every state', async () => {
126
+ for (const state of MENU_BINDING_STATES) {
127
+ await open(state);
128
+ expect(menuFor(state).getAttribute('role'), state.id).toBe('menu');
129
+ expect(Object.keys(relatedFor(state)), state.id).toEqual([
130
+ 'opener',
131
+ ...Object.keys(state.items),
132
+ ]);
133
+ cleanup();
134
+ }
135
+ });
136
+ });
@@ -96,14 +96,15 @@ Supporting one does not justify omitting another from the durable contract.
96
96
 
97
97
  ## Behavioral and layout contract
98
98
 
99
- | ID | Invariant | Basis | Contract state |
100
- | --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | --------------------------------------------------- |
101
- | FR1 | The current render contains one Tree list, one Item and Item label per rendered node, optional Header, Chevron, Item description, Start content, End content, and Guide parts, and delegated Chevron glyphs. | Current source, docs, and tests | Current behavior |
102
- | FR2 | Tree list, Item, Chevron, Item label, and Guide carry the five current `tree-list*` targets documented below. | Current source and target docs | Current behavior |
103
- | FR3 | Chevron glyph delegates to Icon's `icon` target. Header, Start content, and End content are consumer-owned and have no TreeList target. | Current source and component docs | Current ownership |
104
- | FR4 | Item description is a stable library-rendered part below Item label and currently has no public target. It does not inherit through `tree-list-item-label`; both spans are siblings inside the row content. | Current source and target docs | Current reachability gap; no new target is accepted |
105
- | FR5 | `TreeListItemData` MUST admit `className`, `style`, and `xstyle` as one per-item styling seam. Every supplied value MUST compose on the same Item row box; an implementation MUST NOT treat current support for only one mechanism as authority to exclude the other two. | Owner decision; `architecture:public-component-api` INV5–INV6; `spec:AST-002/DEC-1` | Settled intent; implementation may be staged |
106
- | FR6 | The Item row box is the logical element that paints the row, owns its hover state, and contains Start content, Item label and description, and End content. The styling seam MUST NOT move to the semantic `<li>` or another element without a compatibility decision. | Owner decision and the caller-owned per-row interaction boundary | Settled intent |
99
+ | ID | Invariant | Basis | Contract state |
100
+ | --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | --------------------------------------------------- |
101
+ | FR1 | The current render contains one Tree list, one Item and Item label per rendered node, optional Header, Chevron, Item description, Start content, End content, and Guide parts, and delegated Chevron glyphs. | Current source, docs, and tests | Current behavior |
102
+ | FR2 | Tree list, Item, Chevron, Item label, and Guide carry the five current `tree-list*` targets documented below. | Current source and target docs | Current behavior |
103
+ | FR3 | Chevron glyph delegates to Icon's `icon` target. Header, Start content, and End content are consumer-owned and have no TreeList target. | Current source and component docs | Current ownership |
104
+ | FR4 | Item description is a stable library-rendered part below Item label and currently has no public target. It does not inherit through `tree-list-item-label`; both spans are siblings inside the row content. | Current source and target docs | Current reachability gap; no new target is accepted |
105
+ | FR5 | `TreeListItemData` MUST admit `className`, `style`, and `xstyle` as one per-item styling seam. Every supplied value MUST compose on the same Item row box; an implementation MUST NOT treat current support for only one mechanism as authority to exclude the other two. | Owner decision; `architecture:public-component-api` INV5–INV6; `spec:AST-002/DEC-1` | Settled intent; implementation may be staged |
106
+ | FR6 | The Item row box is the logical element that paints the row, owns its hover state, and contains Start content, Item label and description, and End content. The styling seam MUST NOT move to the semantic `<li>` or another element without a compatibility decision. | Owner decision and the caller-owned per-row interaction boundary | Settled intent |
107
+ | FR7 | A click on an Item row outside its Item label performs the row's action: an Item with `onClick` runs it; otherwise an Item with children toggles; otherwise an Item whose only action is its link follows that link as Enter does, with modified and middle clicks following the shared enlarged-surface rule in `family:navigation-destinations`. The Chevron and controls in Start content and End content keep their own activation, and a disabled Item does nothing. | Owner decision; `family:navigation-destinations` | Settled intent; implementation may be staged |
107
108
 
108
109
  ### Allowed variation
109
110
 
@@ -183,6 +184,16 @@ structure. Its current lack of a target is a reachability gap, not a decision
183
184
  that it must remain unthemeable. Header, Start content, and End content remain
184
185
  caller-owned even though TreeList positions their slot wrappers.
185
186
 
187
+ ### Design decisions
188
+
189
+ <!-- design-decisions:v1 -->
190
+
191
+ | ID | Decision | Intent or reason | Applies to | Allowed variation |
192
+ | --- | ---------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
193
+ | DD1 | Every Item spans the full width of the tree list at any depth; its nesting indent sits inside the row, ahead of its content. | Hover, pressed, and selected fills line up across depths, so the tree reads as one list of equal rows while labels keep their per-level alignment. | Item at every depth, at rest and through hover, pressed, selected, disabled, and focus | Themes set the indent step with `--tree-list-indent` and may restyle the row's fill, radius, and padding; row padding adds to the indent instead of replacing it. |
194
+ | DD2 | A nested Item's indent area is part of the row's painted target: it shows the row's hover and press feedback. | The whole painted row reads as one target, matching where a click acts (FR7), so no part of a row lights up apart from the rest. | Nested Items that act on click | Themes may restyle the feedback through the `tree-list-item` target. |
195
+ | DD3 | Guides paint above row fills, and an Item whose focus ring is showing paints above the guides. | Hierarchy lines stay continuous through hover and selection, and the focus indicator is never cut; on a focused row that is also filled, the ring takes precedence and the fill covers the guides at that row. | Guide and Item in `lineGuides` trees | Themes may recolor or hide guides through the `tree-list-guide` target. |
196
+
186
197
  ### Theming anatomy
187
198
 
188
199
  <!-- anatomy-theming:v1 -->
@@ -242,15 +253,16 @@ authorize or prohibit a future target.
242
253
 
243
254
  ## Verification map
244
255
 
245
- | Contract | Verification | Representative states | Mutation or failure expectation | Audit section |
246
- | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------ |
247
- | FR1 | `TreeList.test.tsx` rendering, hierarchy, slot, and accessibility suites plus source inspection | Flat, nested, described, headed, and slotted items | Removing asserted Tree list, Header, Item, Chevron, Item label, Item description, Start content, End content, or Guide behavior fails existing role, content, hierarchy, or state assertions; Chevron glyph presence remains source-inspected only. | `audit:TreeList/anatomy` |
248
- | FR2 | `TreeList.test.tsx` and `themingTargets.test.ts` | Five local targets and their documented states | Removing or moving any current local target fails focused class assertions or the global inventory. | `audit:TreeList/theming` |
249
- | FR3 | Source inspection plus Icon public target metadata | Expandable rows and caller-provided slots | Existing focused tests do not assert the composed Icon instance; changing glyph or slot ownership requires this map and the relevant owner metadata to change. | `audit:TreeList/theming` |
250
- | FR4 | `TreeList.test.tsx` description rendering test and source inspection | Item with description | Removing description fails content coverage; adding a target requires an explicit map update. | `audit:TreeList/anatomy` |
251
- | AR1 | Tree View accessibility contract (`internal/a11y-spec`, pending) bound to TreeList, in jsdom and Chromium; `TreeList.test.tsx` row-naming cases ("names a parent row by its label alone, not the chevron toggle", "names a plain row by its label and describes it with its description", "names and describes link and action rows themselves, not only their inner control", "keeps start and end content, including controls, out of the row name", "typeahead matches the label, not the description or start content") and `TreeListRowNames.a11y.chromium.spec.ts` for the browser-computed names; `TreeList.test.tsx` structural ARIA cases ("sets aria-level, aria-posinset, and aria-setsize at the top level", "sets aria-level/posinset/setsize correctly at deeper levels", "sets aria-level across three depths"); `TreeList.test.tsx` and `useTreeFocus.test.tsx` keyboard suites | Flat and nested trees; expanded/collapsed; selected/unselected; LTR and RTL; typeahead | A row loses its role, label name, state or position, two rows hold a Tab stop, an arrow key stops moving or stops mirroring under RTL, Home/End or typeahead stop working, or the tree claims multi-select | `audit:TreeList/accessibility` |
252
- | FR5, FR6, ORD1 | Focused `TreeList.test.tsx` row-identity and styling-composition coverage, required with implementation | One input at a time; all three together; sibling rows | Dropping any mechanism, applying mechanisms to different elements, targeting the `<li>`, changing the merge order, or styling a sibling row fails. | `audit:TreeList/api` |
253
- | Theming anatomy map | `scripts/check-knowledge.mjs` | Canonical anatomy and five current local targets | Missing, extra, duplicated, prefixed, or stale mappings fail repository validation. | `audit:TreeList/theming` |
256
+ | Contract | Verification | Representative states | Mutation or failure expectation | Audit section |
257
+ | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------ |
258
+ | FR1 | `TreeList.test.tsx` rendering, hierarchy, slot, and accessibility suites plus source inspection | Flat, nested, described, headed, and slotted items | Removing asserted Tree list, Header, Item, Chevron, Item label, Item description, Start content, End content, or Guide behavior fails existing role, content, hierarchy, or state assertions; Chevron glyph presence remains source-inspected only. | `audit:TreeList/anatomy` |
259
+ | FR2 | `TreeList.test.tsx` and `themingTargets.test.ts` | Five local targets and their documented states | Removing or moving any current local target fails focused class assertions or the global inventory. | `audit:TreeList/theming` |
260
+ | FR3 | Source inspection plus Icon public target metadata | Expandable rows and caller-provided slots | Existing focused tests do not assert the composed Icon instance; changing glyph or slot ownership requires this map and the relevant owner metadata to change. | `audit:TreeList/theming` |
261
+ | FR4 | `TreeList.test.tsx` description rendering test and source inspection | Item with description | Removing description fails content coverage; adding a target requires an explicit map update. | `audit:TreeList/anatomy` |
262
+ | AR1 | Tree View accessibility contract (`internal/a11y-spec`, pending) bound to TreeList, in jsdom and Chromium; `TreeList.test.tsx` row-naming cases ("names a parent row by its label alone, not the chevron toggle", "names a plain row by its label and describes it with its description", "names and describes link and action rows themselves, not only their inner control", "keeps start and end content, including controls, out of the row name", "typeahead matches the label, not the description or start content") and `TreeListRowNames.a11y.chromium.spec.ts` for the browser-computed names; `TreeList.test.tsx` structural ARIA cases ("sets aria-level, aria-posinset, and aria-setsize at the top level", "sets aria-level/posinset/setsize correctly at deeper levels", "sets aria-level across three depths"); `TreeList.test.tsx` and `useTreeFocus.test.tsx` keyboard suites | Flat and nested trees; expanded/collapsed; selected/unselected; LTR and RTL; typeahead | A row loses its role, label name, state or position, two rows hold a Tab stop, an arrow key stops moving or stops mirroring under RTL, Home/End or typeahead stop working, or the tree claims multi-select | `audit:TreeList/accessibility` |
263
+ | FR5, FR6, ORD1 | Focused `TreeList.test.tsx` row-identity and styling-composition coverage, required with implementation | One input at a time; all three together; sibling rows | Dropping any mechanism, applying mechanisms to different elements, targeting the `<li>`, changing the merge order, or styling a sibling row fails. | `audit:TreeList/api` |
264
+ | FR7 | Focused `TreeList.test.tsx` row-click coverage, required with implementation | Action, parent, link, and disabled rows; clicks in the indent and padding, on the Chevron, and on Start and End content controls; plain, modified, and middle clicks | A click outside the label does nothing or runs the wrong action, a link-row click reaches ancestors twice, the Chevron or a Start or End content control activates the row, or a disabled row acts. | `audit:TreeList/behavior` |
265
+ | Theming anatomy map | `scripts/check-knowledge.mjs` | Canonical anatomy and five current local targets | Missing, extra, duplicated, prefixed, or stale mappings fail repository validation. | `audit:TreeList/theming` |
254
266
 
255
267
  Focused target-placement assertions cover all five local targets. Runtime support
256
268
  and mutation-sensitive coverage for the item styling seam may land separately,