@astryxdesign/core 0.7.2-canary.55d8dff → 0.7.2-canary.577a62e

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.
Files changed (111) hide show
  1. package/README.md +4 -4
  2. package/dist/Avatar/Avatar.d.ts.map +1 -1
  3. package/dist/Avatar/Avatar.js +8 -1
  4. package/dist/AvatarGroup/AvatarGroupOverflow.d.ts.map +1 -1
  5. package/dist/AvatarGroup/AvatarGroupOverflow.js +4 -1
  6. package/dist/Chat/ChatLayoutScrollButton.d.ts.map +1 -1
  7. package/dist/Chat/ChatLayoutScrollButton.js +1 -0
  8. package/dist/CheckboxList/CheckboxList.d.ts +1 -1
  9. package/dist/CheckboxList/CheckboxList.d.ts.map +1 -1
  10. package/dist/CheckboxList/CheckboxList.js +6 -2
  11. package/dist/DropdownMenu/DropdownMenu.d.ts.map +1 -1
  12. package/dist/DropdownMenu/DropdownMenu.js +2 -103
  13. package/dist/DropdownMenu/DropdownMenuCheckboxItem.d.ts +1 -1
  14. package/dist/DropdownMenu/DropdownMenuGroup.d.ts +6 -4
  15. package/dist/DropdownMenu/DropdownMenuGroup.d.ts.map +1 -1
  16. package/dist/DropdownMenu/DropdownMenuGroup.js +4 -3
  17. package/dist/DropdownMenu/DropdownMenuRadioItem.d.ts +1 -1
  18. package/dist/DropdownMenu/menuRegionStops.d.ts +38 -0
  19. package/dist/DropdownMenu/menuRegionStops.d.ts.map +1 -0
  20. package/dist/DropdownMenu/menuRegionStops.js +131 -0
  21. package/dist/FormLayout/FormLayout.d.ts +5 -4
  22. package/dist/FormLayout/FormLayout.d.ts.map +1 -1
  23. package/dist/FormLayout/FormLayout.js +1 -1
  24. package/dist/FormLayout/FormLayoutContext.d.ts +3 -3
  25. package/dist/FormLayout/FormLayoutContext.js +3 -3
  26. package/dist/Grid/Grid.d.ts +8 -7
  27. package/dist/Grid/Grid.d.ts.map +1 -1
  28. package/dist/Grid/Grid.js +5 -4
  29. package/dist/Grid/GridSpan.d.ts +1 -1
  30. package/dist/Markdown/parser.d.ts.map +1 -1
  31. package/dist/Markdown/parser.js +8 -2
  32. package/dist/Markdown/plugins/headingLinks.d.ts.map +1 -1
  33. package/dist/Markdown/plugins/headingLinks.js +9 -1
  34. package/dist/PowerSearch/PowerSearch.d.ts.map +1 -1
  35. package/dist/PowerSearch/PowerSearch.js +5 -0
  36. package/dist/PowerSearch/PowerSearchValueEditor.d.ts.map +1 -1
  37. package/dist/PowerSearch/PowerSearchValueEditor.js +45 -11
  38. package/dist/SideNav/SideNavItem.d.ts.map +1 -1
  39. package/dist/SideNav/SideNavItem.js +6 -2
  40. package/dist/Token/Token.js +1 -1
  41. package/dist/TopNav/TopNavMegaMenu.js +2 -0
  42. package/dist/TopNav/TopNavMenu.d.ts.map +1 -1
  43. package/dist/TopNav/TopNavMenu.js +2 -0
  44. package/dist/astryx.css +1 -1
  45. package/dist/reset.a11y.chromium.spec.js +50 -1
  46. package/docs.mjs +4 -4
  47. package/package.json +3 -3
  48. package/scripts/agent-doc-state.mjs +29 -7
  49. package/scripts/postinstall.mjs +43 -2
  50. package/src/Avatar/Avatar.tsx +11 -2
  51. package/src/AvatarGroup/AvatarGroupOverflow.test.tsx +24 -1
  52. package/src/AvatarGroup/AvatarGroupOverflow.tsx +2 -0
  53. package/src/Chat/ChatLayoutScrollButton.test.tsx +13 -0
  54. package/src/Chat/ChatLayoutScrollButton.tsx +4 -2
  55. package/src/Chat/__tests__/ChatLayoutScrollButton.a11y.chromium.spec.ts +64 -19
  56. package/src/CheckboxList/CheckboxList.fr10.test.tsx +29 -2
  57. package/src/CheckboxList/CheckboxList.tsx +4 -1
  58. package/src/ContextMenu/ContextMenu.test.tsx +12 -2
  59. package/src/DropdownMenu/DropdownMenu.test.tsx +3 -4
  60. package/src/DropdownMenu/DropdownMenu.tsx +7 -134
  61. package/src/DropdownMenu/DropdownMenuCheckboxItem.doc.mjs +1 -1
  62. package/src/DropdownMenu/DropdownMenuCheckboxItem.tsx +1 -1
  63. package/src/DropdownMenu/DropdownMenuGroup.doc.mjs +169 -10
  64. package/src/DropdownMenu/DropdownMenuGroup.tsx +6 -4
  65. package/src/DropdownMenu/DropdownMenuRadioItem.doc.mjs +1 -1
  66. package/src/DropdownMenu/DropdownMenuRadioItem.tsx +1 -1
  67. package/src/DropdownMenu/__tests__/DropdownMenuGroupAudit.a11y.chromium.spec.ts +334 -0
  68. package/src/DropdownMenu/menuRegionStops.ts +167 -0
  69. package/src/Field/FieldLabel.doc.mjs +133 -26
  70. package/src/Field/FieldLabel.spec.md +172 -0
  71. package/src/Field/__tests__/FieldLabelAudit.a11y.chromium.spec.ts +296 -0
  72. package/src/FormLayout/FormLayout.doc.mjs +6 -3
  73. package/src/FormLayout/FormLayout.spec.md +192 -0
  74. package/src/FormLayout/FormLayout.test.tsx +15 -0
  75. package/src/FormLayout/FormLayout.tsx +7 -6
  76. package/src/FormLayout/FormLayoutContext.ts +3 -3
  77. package/src/Grid/Grid.doc.mjs +7 -1
  78. package/src/Grid/Grid.spec.md +93 -113
  79. package/src/Grid/Grid.test.tsx +22 -0
  80. package/src/Grid/Grid.tsx +10 -9
  81. package/src/Grid/GridSpan.doc.mjs +3 -3
  82. package/src/Grid/GridSpan.tsx +1 -1
  83. package/src/Indicator/Indicator.doc.mjs +3 -3
  84. package/src/Markdown/parser.ts +8 -2
  85. package/src/Markdown/plugins/headingLinks.ts +9 -1
  86. package/src/PowerSearch/PowerSearch.test.tsx +62 -0
  87. package/src/PowerSearch/PowerSearch.tsx +9 -0
  88. package/src/PowerSearch/PowerSearchValueEditor.test.tsx +71 -0
  89. package/src/PowerSearch/PowerSearchValueEditor.tsx +45 -0
  90. package/src/Resizable/useResizable.doc.mjs +0 -1
  91. package/src/SideNav/SideNav.spec.md +227 -0
  92. package/src/SideNav/SideNav.test.tsx +41 -0
  93. package/src/SideNav/SideNavItem.tsx +5 -2
  94. package/src/Table/Table.spec.md +6 -1
  95. package/src/Table/plugins/activation/useTableActivation.spec.md +254 -0
  96. package/src/Token/Token.tsx +1 -1
  97. package/src/Token/__tests__/TokenRemoveTarget.a11y.chromium.spec.ts +86 -0
  98. package/src/Toolbar/Toolbar.test.tsx +0 -8
  99. package/src/Toolbar/__tests__/Toolbar.a11y.chromium.spec.ts +163 -0
  100. package/src/Toolbar/__tests__/Toolbar.a11y.renders.tsx +63 -0
  101. package/src/Toolbar/__tests__/Toolbar.a11y.states.ts +142 -0
  102. package/src/Toolbar/__tests__/Toolbar.a11y.test.tsx +126 -0
  103. package/src/TopNav/TopNavMegaMenu.test.tsx +25 -0
  104. package/src/TopNav/TopNavMegaMenu.tsx +2 -0
  105. package/src/TopNav/TopNavMenu.test.tsx +27 -1
  106. package/src/TopNav/TopNavMenu.tsx +2 -0
  107. package/src/postinstall.test.mjs +33 -0
  108. package/src/reset.a11y.chromium.spec.ts +52 -1
  109. package/src/reset.css +9 -0
  110. package/src/reset.test.ts +11 -0
  111. package/src/unicodePropertyEscapes.source-build.test.mjs +69 -0
@@ -0,0 +1,163 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Toolbar.a11y.chromium.spec.ts
5
+ * @input Built toolbar stories and TOOLBAR_PATTERN in real Chromium
6
+ * @output Browser evidence for every toolbar expectation across Toolbar's representative states, and a paint check that each state's controls sit on the axis and in the direction it declares
7
+ * @position Chromium lane of the Toolbar 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
+ TOOLBAR_PATTERN,
19
+ blockingResults,
20
+ checkAccessibilitySpec,
21
+ formatFailures,
22
+ neverExercised,
23
+ summarize,
24
+ type BindingResult,
25
+ } from '@astryxdesign/a11y-spec';
26
+ import {
27
+ createChromiumHarness,
28
+ holdMotionStill,
29
+ } from '@astryxdesign/a11y-spec/chromium';
30
+ import {
31
+ DEFAULT_STORYBOOK_DIR,
32
+ serveStorybook,
33
+ type StaticServer,
34
+ } from '@astryxdesign/a11y-spec/storybook';
35
+ import {
36
+ TOOLBAR_BINDING_STATES,
37
+ type ToolbarBindingState,
38
+ } from './Toolbar.a11y.states';
39
+
40
+ let storybook: StaticServer;
41
+
42
+ test.beforeAll(async () => {
43
+ storybook = await serveStorybook(
44
+ process.env.ASTRYX_STORYBOOK_DIR ?? DEFAULT_STORYBOOK_DIR,
45
+ );
46
+ });
47
+
48
+ test.afterAll(async () => {
49
+ await storybook?.close();
50
+ });
51
+
52
+ function storyUrl(storyId: string): string {
53
+ return `${storybook.origin}/iframe.html?id=${storyId}&viewMode=story`;
54
+ }
55
+
56
+ async function mountState(
57
+ page: Page,
58
+ state: ToolbarBindingState,
59
+ ): Promise<{subject: Locator; related: Record<string, Locator>}> {
60
+ await page.goto(storyUrl(state.storyId), {waitUntil: 'load'});
61
+ await holdMotionStill(page);
62
+ const root = page.locator('#storybook-root');
63
+ const subject = root.getByRole('toolbar', {name: state.label, exact: true});
64
+ await expect(subject).toBeVisible();
65
+ const related: Record<string, Locator> = {
66
+ before: root.getByRole('button', {
67
+ name: 'Before the toolbar',
68
+ exact: true,
69
+ }),
70
+ after: root.getByRole('button', {name: 'After the toolbar', exact: true}),
71
+ };
72
+ for (const [relation, name] of Object.entries(state.controls)) {
73
+ related[relation] = subject.getByRole('button', {name, exact: true});
74
+ }
75
+ return {subject, related};
76
+ }
77
+
78
+ async function runState(
79
+ page: Page,
80
+ cdp: CDPSession,
81
+ state: ToolbarBindingState,
82
+ ): Promise<BindingResult> {
83
+ return checkAccessibilitySpec({
84
+ spec: TOOLBAR_PATTERN,
85
+ binding: 'Toolbar',
86
+ state: state.id,
87
+ facts: state.facts,
88
+ mount: async () => {
89
+ const {subject, related} = await mountState(page, state);
90
+ return createChromiumHarness({page, cdp, subject, related});
91
+ },
92
+ });
93
+ }
94
+
95
+ test('every toolbar expectation is exercised by a Toolbar state', async ({
96
+ page,
97
+ }) => {
98
+ const cdp = await page.context().newCDPSession(page);
99
+ const results: BindingResult[] = [];
100
+ for (const state of TOOLBAR_BINDING_STATES) {
101
+ results.push(await runState(page, cdp, state));
102
+ }
103
+ expect(neverExercised(results)).toEqual([]);
104
+ });
105
+
106
+ /**
107
+ * The contract reads `orientation` and `direction` as facts the binding
108
+ * declares. This checks the painted page agrees: the second control sits
109
+ * below the first in a vertical state, and after it in reading order — to
110
+ * its right, or its left under right-to-left — in a horizontal one.
111
+ */
112
+ for (const state of TOOLBAR_BINDING_STATES) {
113
+ test(`Toolbar [${state.id}] paints its controls on the declared axis and direction`, async ({
114
+ page,
115
+ }) => {
116
+ const {related} = await mountState(page, state);
117
+ const first = await related['first-control'].boundingBox();
118
+ const second = await related['second-control'].boundingBox();
119
+ if (first == null || second == null) {
120
+ throw new Error(`state "${state.id}" has an unpainted control`);
121
+ }
122
+ const firstMiddle = {
123
+ x: first.x + first.width / 2,
124
+ y: first.y + first.height / 2,
125
+ };
126
+ const secondMiddle = {
127
+ x: second.x + second.width / 2,
128
+ y: second.y + second.height / 2,
129
+ };
130
+ if (state.facts.orientation === 'vertical') {
131
+ expect(second.y, 'second control below the first').toBeGreaterThanOrEqual(
132
+ first.y + first.height,
133
+ );
134
+ } else {
135
+ expect(
136
+ Math.abs(secondMiddle.y - firstMiddle.y),
137
+ 'controls share a row',
138
+ ).toBeLessThan(first.height / 2);
139
+ if (state.facts.direction === 'rtl') {
140
+ expect(secondMiddle.x, 'second control left of the first').toBeLessThan(
141
+ firstMiddle.x,
142
+ );
143
+ } else {
144
+ expect(
145
+ secondMiddle.x,
146
+ 'second control right of the first',
147
+ ).toBeGreaterThan(firstMiddle.x);
148
+ }
149
+ }
150
+ });
151
+ }
152
+
153
+ for (const state of TOOLBAR_BINDING_STATES) {
154
+ test(`Toolbar [${state.id}] — ${state.summary}`, async ({page}) => {
155
+ const cdp = await page.context().newCDPSession(page);
156
+ const result = await runState(page, cdp, state);
157
+ const report = summarize(TOOLBAR_PATTERN, [result]);
158
+ // Required rows block (AST-020 FR9).
159
+ expect(formatFailures(blockingResults([result]))).toBe('');
160
+ expect(report.unrunLayers).toEqual([]);
161
+ expect(report.counts.unexpectedPass).toBe(0);
162
+ });
163
+ }
@@ -0,0 +1,63 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ /**
6
+ * @file Toolbar.a11y.renders.tsx
7
+ * @input One toolbar binding state from Toolbar.a11y.states.ts
8
+ * @output The React tree every lane renders for that state (the jsdom test directly, the story through Storybook): a page button before the Toolbar, the Toolbar, and a page button after it
9
+ * @position Shared fixture for the Toolbar binding
10
+ */
11
+
12
+ import type {ReactElement} from 'react';
13
+ import {Button} from '../../Button/Button';
14
+ import {VStack} from '../../VStack/VStack';
15
+ import {Toolbar} from '../Toolbar';
16
+ import type {ToolbarBindingState, ToolbarControl} from './Toolbar.a11y.states';
17
+
18
+ /**
19
+ * One slot's controls. A slot lays its children out in a row, so a vertical
20
+ * toolbar's caller stacks them, as a product toolbar would, to arrange the
21
+ * controls on the axis the state declares.
22
+ */
23
+ function controls(
24
+ list: ReadonlyArray<ToolbarControl>,
25
+ orientation: ToolbarBindingState['facts']['orientation'],
26
+ ): ReactElement | undefined {
27
+ if (list.length === 0) {
28
+ return undefined;
29
+ }
30
+ const buttons = list.map(control => (
31
+ <Button
32
+ key={control.label}
33
+ label={control.label}
34
+ variant="ghost"
35
+ isDisabled={control.isDisabled}
36
+ />
37
+ ));
38
+ return orientation === 'vertical' ? (
39
+ <VStack gap={1}>{buttons}</VStack>
40
+ ) : (
41
+ <>{buttons}</>
42
+ );
43
+ }
44
+
45
+ export function renderToolbarState(
46
+ state: Pick<
47
+ ToolbarBindingState,
48
+ 'label' | 'startControls' | 'endControls' | 'facts'
49
+ >,
50
+ ): ReactElement {
51
+ return (
52
+ <div dir={state.facts.direction} style={{padding: 24, maxWidth: 640}}>
53
+ <button type="button">Before the toolbar</button>
54
+ <Toolbar
55
+ label={state.label}
56
+ orientation={state.facts.orientation}
57
+ startContent={controls(state.startControls, state.facts.orientation)}
58
+ endContent={controls(state.endControls, state.facts.orientation)}
59
+ />
60
+ <button type="button">After the toolbar</button>
61
+ </div>
62
+ );
63
+ }
@@ -0,0 +1,142 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Toolbar.a11y.states.ts
5
+ * @input Toolbar's representative states and the facts TOOLBAR_PATTERN reads
6
+ * @output TOOLBAR_BINDING_STATES and the explicit ownership exclusions for this binding
7
+ * @position Binding inventory shared by the jsdom test and the Chromium spec (Toolbar under spec:AST-020 FR15)
8
+ */
9
+
10
+ import type {ToolbarStateFacts} from '@astryxdesign/a11y-spec';
11
+
12
+ export interface ToolbarControl {
13
+ readonly label: string;
14
+ readonly isDisabled?: boolean;
15
+ }
16
+
17
+ export interface ToolbarBindingState {
18
+ readonly id: string;
19
+ readonly summary: string;
20
+ readonly storyId: string;
21
+ /** The Toolbar `label`, which names the toolbar. */
22
+ readonly label: string;
23
+ /** Controls in the start slot, in order. */
24
+ readonly startControls: ReadonlyArray<ToolbarControl>;
25
+ /** Controls in the end slot, in order. */
26
+ readonly endControls: ReadonlyArray<ToolbarControl>;
27
+ /** Relation name → accessible name of the control it designates. */
28
+ readonly controls: Readonly<
29
+ Record<'first-control' | 'second-control' | 'last-control', string>
30
+ >;
31
+ readonly facts: ToolbarStateFacts;
32
+ }
33
+
34
+ const story = (id: string) => `a11y-toolbar--${id}`;
35
+
36
+ const FORMATTING = [
37
+ {label: 'Bold'},
38
+ {label: 'Italic'},
39
+ {label: 'Underline'},
40
+ ] as const;
41
+
42
+ const FORMATTING_CONTROLS = {
43
+ 'first-control': 'Bold',
44
+ 'second-control': 'Italic',
45
+ 'last-control': 'Settings',
46
+ } as const;
47
+
48
+ const HORIZONTAL_LTR: ToolbarStateFacts = {
49
+ orientation: 'horizontal',
50
+ direction: 'ltr',
51
+ wraps: true,
52
+ homeEnd: true,
53
+ };
54
+
55
+ export const TOOLBAR_BINDING_STATES = [
56
+ {
57
+ id: 'horizontal',
58
+ summary:
59
+ 'a named horizontal Toolbar with three formatting buttons in the start slot and Settings in the end slot',
60
+ storyId: story('horizontal'),
61
+ label: 'Text formatting',
62
+ startControls: FORMATTING,
63
+ endControls: [{label: 'Settings'}],
64
+ controls: FORMATTING_CONTROLS,
65
+ facts: HORIZONTAL_LTR,
66
+ },
67
+ {
68
+ id: 'horizontal-rtl',
69
+ summary:
70
+ 'the same Toolbar inside a right-to-left container, where ArrowLeft moves to the next control',
71
+ storyId: story('horizontal-rtl'),
72
+ label: 'Text formatting',
73
+ startControls: FORMATTING,
74
+ endControls: [{label: 'Settings'}],
75
+ controls: FORMATTING_CONTROLS,
76
+ facts: {...HORIZONTAL_LTR, direction: 'rtl'},
77
+ },
78
+ {
79
+ id: 'vertical',
80
+ summary:
81
+ 'a vertical Toolbar of drawing tools, moved through with ArrowDown and ArrowUp',
82
+ storyId: story('vertical'),
83
+ label: 'Drawing tools',
84
+ startControls: [{label: 'Select'}, {label: 'Pen'}, {label: 'Eraser'}],
85
+ endControls: [],
86
+ controls: {
87
+ 'first-control': 'Select',
88
+ 'second-control': 'Pen',
89
+ 'last-control': 'Eraser',
90
+ },
91
+ facts: {...HORIZONTAL_LTR, orientation: 'vertical'},
92
+ },
93
+ {
94
+ id: 'first-control-disabled',
95
+ summary:
96
+ 'a Toolbar whose first button is disabled, so Tab enters on the first enabled button and the arrows skip the disabled one',
97
+ storyId: story('first-control-disabled'),
98
+ label: 'Edit actions',
99
+ startControls: [
100
+ {label: 'Undo', isDisabled: true},
101
+ {label: 'Cut'},
102
+ {label: 'Copy'},
103
+ ],
104
+ endControls: [{label: 'Paste'}],
105
+ controls: {
106
+ 'first-control': 'Cut',
107
+ 'second-control': 'Copy',
108
+ 'last-control': 'Paste',
109
+ },
110
+ facts: HORIZONTAL_LTR,
111
+ },
112
+ ] as const satisfies ReadonlyArray<ToolbarBindingState>;
113
+
114
+ export const TOOLBAR_EXCLUSIONS = [
115
+ {
116
+ id: 'control-semantics',
117
+ owner:
118
+ 'the button, toggle-button and menu-button patterns and their bindings',
119
+ reason:
120
+ 'Each control placed in a Toolbar exposes its own role, name, state and activation through its own pattern; this binding renders plain Buttons only so the toolbar container and the movement between controls are what is checked.',
121
+ },
122
+ {
123
+ id: 'caret-guard-and-nested-popovers',
124
+ owner:
125
+ 'Toolbar.test.tsx (caret-guard, nested popover and onKeyDown composition suites)',
126
+ reason:
127
+ 'Leaving caret keys to a text field mid-line, leaving keys inside an open MoreMenu to that menu, and composing a caller onKeyDown are binding behaviour the APG leaves to the author, so no state here places a text field or popover in the toolbar.',
128
+ },
129
+ {
130
+ id: 'three-lane-layout',
131
+ owner:
132
+ 'Toolbar.test.tsx (centerContent and three-slot layout tests) and the repository visual gate',
133
+ reason:
134
+ 'Center content switches the row to a three-column grid, but the controls stay in DOM order inside the same toolbar element and the same roving tab stop walks them, so the role, name, Tab stop and arrow order this contract reads do not change; the grid itself is layout paint.',
135
+ },
136
+ {
137
+ id: 'outer-chrome',
138
+ owner: 'component:Section and the repository visual gate',
139
+ reason:
140
+ 'Variant, dividers, block padding and edge compensation are Section and layout paint around the toolbar row; they change no role, name, Tab stop or arrow movement, so every state uses the default transparent chrome.',
141
+ },
142
+ ] as const;
@@ -0,0 +1,126 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ /** @vitest-environment jsdom */
3
+
4
+ /**
5
+ * @file Toolbar.a11y.test.tsx
6
+ * @input TOOLBAR_PATTERN bound to Toolbar's representative states
7
+ * @output jsdom-lane proof of the DOM expectations and an honest "unrun" report for the browser layers
8
+ * @position Binding test for Toolbar (the Chromium spec covers the rest)
9
+ */
10
+
11
+ import {cleanup, render, screen} from '@testing-library/react';
12
+ import {describe, expect, it} from 'vitest';
13
+ import {
14
+ TOOLBAR_PATTERN,
15
+ checkAccessibilitySpec,
16
+ createJsdomHarness,
17
+ expectAccessibilitySpec,
18
+ summarize,
19
+ type BindingResult,
20
+ } from '@astryxdesign/a11y-spec';
21
+ import {renderToolbarState} from './Toolbar.a11y.renders';
22
+ import {
23
+ TOOLBAR_BINDING_STATES,
24
+ TOOLBAR_EXCLUSIONS,
25
+ type ToolbarBindingState,
26
+ } from './Toolbar.a11y.states';
27
+
28
+ function toolbarFor(state: ToolbarBindingState): HTMLElement {
29
+ return screen.getByRole('toolbar', {name: state.label});
30
+ }
31
+
32
+ function relatedFor(
33
+ state: ToolbarBindingState,
34
+ ): Readonly<Record<string, Element>> {
35
+ const related: Record<string, Element> = {
36
+ before: screen.getByRole('button', {name: 'Before the toolbar'}),
37
+ after: screen.getByRole('button', {name: 'After the toolbar'}),
38
+ };
39
+ for (const [relation, name] of Object.entries(state.controls)) {
40
+ related[relation] = screen.getByRole('button', {name});
41
+ }
42
+ return related;
43
+ }
44
+
45
+ async function checkState(state: ToolbarBindingState): Promise<BindingResult> {
46
+ return checkAccessibilitySpec({
47
+ spec: TOOLBAR_PATTERN,
48
+ binding: 'Toolbar',
49
+ state: state.id,
50
+ facts: state.facts,
51
+ mount: async () => {
52
+ render(renderToolbarState(state));
53
+ return createJsdomHarness({
54
+ subject: toolbarFor(state),
55
+ related: relatedFor(state),
56
+ });
57
+ },
58
+ unmount: cleanup,
59
+ });
60
+ }
61
+
62
+ describe('Toolbar — toolbar pattern, jsdom lane', () => {
63
+ it.each(
64
+ TOOLBAR_BINDING_STATES.map(
65
+ state => [state.id, state.summary, state] as const,
66
+ ),
67
+ )('%s — %s', async (_id, _summary, state) => {
68
+ await expectAccessibilitySpec({
69
+ spec: TOOLBAR_PATTERN,
70
+ binding: 'Toolbar',
71
+ state: state.id,
72
+ facts: state.facts,
73
+ render: () => {
74
+ render(renderToolbarState(state));
75
+ },
76
+ subject: () => toolbarFor(state),
77
+ related: () => relatedFor(state),
78
+ cleanup,
79
+ });
80
+ });
81
+
82
+ it('runs the DOM layer and reports the browser-owned outcomes as unrun', async () => {
83
+ const results: BindingResult[] = [];
84
+ for (const state of TOOLBAR_BINDING_STATES) {
85
+ results.push(await checkState(state));
86
+ }
87
+ const report = summarize(TOOLBAR_PATTERN, results);
88
+ expect(report.counts.pass).toBeGreaterThan(0);
89
+ expect(report.unrunLayers).toEqual(['accessibility-tree', 'real-browser']);
90
+ expect(report.counts.unexpectedPass).toBe(0);
91
+ });
92
+ });
93
+
94
+ describe('toolbar binding inventory', () => {
95
+ it('gives every representative state a distinct checked-in story', () => {
96
+ const stories = TOOLBAR_BINDING_STATES.map(state => state.storyId);
97
+ expect(new Set(stories).size).toBe(stories.length);
98
+ });
99
+
100
+ it('keeps control semantics, editing and popover keys, the three-lane layout, and outer chrome explicit', () => {
101
+ expect(TOOLBAR_EXCLUSIONS.map(row => row.id)).toEqual([
102
+ 'control-semantics',
103
+ 'caret-guard-and-nested-popovers',
104
+ 'three-lane-layout',
105
+ 'outer-chrome',
106
+ ]);
107
+ for (const exclusion of TOOLBAR_EXCLUSIONS) {
108
+ expect(exclusion.owner.length, exclusion.id).toBeGreaterThan(20);
109
+ expect(exclusion.reason.length, exclusion.id).toBeGreaterThan(80);
110
+ }
111
+ });
112
+
113
+ it('renders the toolbar and every control relation it names for every state', () => {
114
+ for (const state of TOOLBAR_BINDING_STATES) {
115
+ render(renderToolbarState(state));
116
+ const toolbar = toolbarFor(state);
117
+ for (const name of Object.values(state.controls)) {
118
+ expect(
119
+ toolbar.contains(screen.getByRole('button', {name})),
120
+ `${state.id}: ${name}`,
121
+ ).toBe(true);
122
+ }
123
+ cleanup();
124
+ }
125
+ });
126
+ });
@@ -769,6 +769,31 @@ describe('TopNavMegaMenu — drawer mode', () => {
769
769
  expect(handleClick).toHaveBeenCalledTimes(1);
770
770
  });
771
771
 
772
+ it('makes collapsed items inert and hidden from assistive technology', async () => {
773
+ const user = userEvent.setup();
774
+ render(
775
+ <TopNavRenderContext value="drawer">
776
+ <TopNavMegaMenu
777
+ label="Products"
778
+ items={<TopNavMegaMenuItem title="Analytics" href="/analytics" />}
779
+ />
780
+ </TopNavRenderContext>,
781
+ );
782
+ const trigger = screen.getByRole('button', {name: 'Products'});
783
+ const region = document.getElementById(
784
+ trigger.getAttribute('aria-controls') ?? '',
785
+ );
786
+
787
+ expect(region).toHaveAttribute('inert');
788
+ expect(region).toHaveAttribute('aria-hidden', 'true');
789
+ expect(screen.queryByRole('link', {name: 'Analytics'})).toBeNull();
790
+
791
+ await user.click(trigger);
792
+ expect(region).not.toHaveAttribute('inert');
793
+ expect(region).toHaveAttribute('aria-hidden', 'false');
794
+ expect(screen.getByRole('link', {name: 'Analytics'})).toBeInTheDocument();
795
+ });
796
+
772
797
  it('clips the drawer section only while it is collapsed', async () => {
773
798
  // The ring paints outside the item, so a permanent clip would cut it off.
774
799
  const user = userEvent.setup();
@@ -599,6 +599,8 @@ function DrawerMegaMenu({
599
599
  {/* Animated expand/collapse container */}
600
600
  <div
601
601
  id={`${menuId}-items`}
602
+ aria-hidden={!isExpanded}
603
+ inert={!isExpanded ? true : undefined}
602
604
  {...stylex.props(
603
605
  styles.drawerItems,
604
606
  isExpanded && styles.drawerItemsExpanded,
@@ -233,12 +233,14 @@ describe('TopNavMenu — drawer focus ring', () => {
233
233
  expectSharedFocusRing(screen.getByRole('button', {name: /Products/}));
234
234
  });
235
235
 
236
- it('draws the shared ring on a drawer item', () => {
236
+ it('draws the shared ring on a drawer item', async () => {
237
+ const user = userEvent.setup();
237
238
  render(
238
239
  <TopNavRenderContext value="drawer">
239
240
  <TopNavMenu label="Products" items={mockItems} />
240
241
  </TopNavRenderContext>,
241
242
  );
243
+ await user.click(screen.getByRole('button', {name: /Products/}));
242
244
  expectSharedFocusRing(screen.getByRole('link', {name: /Analytics/}));
243
245
  });
244
246
 
@@ -262,6 +264,30 @@ describe('TopNavMenu — drawer focus ring', () => {
262
264
  });
263
265
  });
264
266
 
267
+ describe('TopNavMenu — drawer collapsed section', () => {
268
+ it('makes collapsed items inert and hidden from assistive technology', async () => {
269
+ const user = userEvent.setup();
270
+ render(
271
+ <TopNavRenderContext value="drawer">
272
+ <TopNavMenu label="Products" items={mockItems} />
273
+ </TopNavRenderContext>,
274
+ );
275
+ const trigger = screen.getByRole('button', {name: /Products/});
276
+ const region = document.getElementById(
277
+ trigger.getAttribute('aria-controls') ?? '',
278
+ );
279
+
280
+ expect(region).toHaveAttribute('inert');
281
+ expect(region).toHaveAttribute('aria-hidden', 'true');
282
+ expect(screen.queryByRole('link', {name: /Analytics/})).toBeNull();
283
+
284
+ await user.click(trigger);
285
+ expect(region).not.toHaveAttribute('inert');
286
+ expect(region).toHaveAttribute('aria-hidden', 'false');
287
+ expect(screen.getByRole('link', {name: /Analytics/})).toBeInTheDocument();
288
+ });
289
+ });
290
+
265
291
  describe('TopNavMenu pass-through props', () => {
266
292
  it('forwards pass-through props to the trigger button', () => {
267
293
  render(
@@ -494,6 +494,8 @@ export function TopNavMenu({
494
494
  </button>
495
495
  <div
496
496
  id={`${menuId}-items`}
497
+ aria-hidden={!drawerExpanded}
498
+ inert={!drawerExpanded ? true : undefined}
497
499
  {...stylex.props(
498
500
  drawerStyles.items,
499
501
  drawerExpanded && drawerStyles.itemsExpanded,
@@ -24,7 +24,9 @@ import {
24
24
  AGENT_DOC_PATHS,
25
25
  INIT_MARKERS,
26
26
  SETUP_NUDGE,
27
+ setupNudge,
27
28
  } from '../scripts/agent-doc-state.mjs';
29
+ import {cliInvocation} from '../scripts/postinstall.mjs';
28
30
  // Dev-only, monorepo-relative: core must not depend on the CLI at runtime (the
29
31
  // CLI peer-depends on core), but the test can read the source and the generator.
30
32
  import * as cliLeaf from '../../cli/foundation/agent-docs/agent-doc-state.mjs';
@@ -95,6 +97,9 @@ describe('core postinstall — one authoritative source, no drift', () => {
95
97
  expect(AGENT_DOC_PATHS).toEqual(cliLeaf.AGENT_DOC_PATHS);
96
98
  expect(INIT_MARKERS).toEqual(cliLeaf.INIT_MARKERS);
97
99
  expect(SETUP_NUDGE).toBe(cliLeaf.SETUP_NUDGE);
100
+ expect(setupNudge('pnpm exec astryx')).toBe(
101
+ cliLeaf.setupNudge('pnpm exec astryx'),
102
+ );
98
103
  });
99
104
 
100
105
  it('agrees with the CLI leaf on a real project', () => {
@@ -181,3 +186,31 @@ describe('core postinstall — shouldNudge', () => {
181
186
  }
182
187
  });
183
188
  });
189
+
190
+ describe('core postinstall — the nudge names the command the CLI would print', () => {
191
+ it('words the nudge around the stem it is given', () => {
192
+ expect(setupNudge('pnpm exec astryx')).toContain(
193
+ 'run `pnpm exec astryx init` to finish setup',
194
+ );
195
+ expect(SETUP_NUDGE).toContain('run `npx @astryxdesign/cli init`');
196
+ });
197
+
198
+ it("asks the project's installed CLI for its stem", async () => {
199
+ write('package.json', '{"name":"app"}');
200
+ write('pnpm-lock.yaml', '');
201
+ write('node_modules/.bin/astryx', '');
202
+ fs.mkdirSync(path.join(tmp, 'node_modules/@astryxdesign'), {
203
+ recursive: true,
204
+ });
205
+ fs.symlinkSync(
206
+ path.resolve(import.meta.dirname, '../../cli'),
207
+ path.join(tmp, 'node_modules/@astryxdesign/cli'),
208
+ );
209
+ expect(await cliInvocation(tmp)).toBe('pnpm exec astryx');
210
+ });
211
+
212
+ it('has no stem to offer when the CLI is not installed', async () => {
213
+ write('package.json', '{"name":"app"}');
214
+ expect(await cliInvocation(tmp)).toBeUndefined();
215
+ });
216
+ });