@astryxdesign/core 0.7.2-canary.fdd384f → 0.7.2

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 (132) hide show
  1. package/CHANGELOG.md +156 -2
  2. package/README.md +4 -4
  3. package/dist/Avatar/Avatar.d.ts.map +1 -1
  4. package/dist/Avatar/Avatar.js +8 -1
  5. package/dist/AvatarGroup/AvatarGroupOverflow.d.ts.map +1 -1
  6. package/dist/AvatarGroup/AvatarGroupOverflow.js +4 -1
  7. package/dist/Chat/ChatLayoutScrollButton.d.ts.map +1 -1
  8. package/dist/Chat/ChatLayoutScrollButton.js +1 -0
  9. package/dist/CheckboxList/CheckboxList.d.ts +1 -1
  10. package/dist/CheckboxList/CheckboxList.d.ts.map +1 -1
  11. package/dist/CheckboxList/CheckboxList.js +6 -2
  12. package/dist/DropdownMenu/DropdownMenu.d.ts +1 -1
  13. package/dist/DropdownMenu/DropdownMenu.d.ts.map +1 -1
  14. package/dist/DropdownMenu/DropdownMenu.js +38 -110
  15. package/dist/DropdownMenu/DropdownMenuCheckboxItem.d.ts +1 -1
  16. package/dist/DropdownMenu/DropdownMenuGroup.d.ts +6 -4
  17. package/dist/DropdownMenu/DropdownMenuGroup.d.ts.map +1 -1
  18. package/dist/DropdownMenu/DropdownMenuGroup.js +4 -3
  19. package/dist/DropdownMenu/DropdownMenuRadioItem.d.ts +1 -1
  20. package/dist/DropdownMenu/lazyMenuBottomSheet.d.ts +24 -0
  21. package/dist/DropdownMenu/lazyMenuBottomSheet.d.ts.map +1 -0
  22. package/dist/DropdownMenu/lazyMenuBottomSheet.js +46 -0
  23. package/dist/DropdownMenu/menuRegionStops.d.ts +38 -0
  24. package/dist/DropdownMenu/menuRegionStops.d.ts.map +1 -0
  25. package/dist/DropdownMenu/menuRegionStops.js +131 -0
  26. package/dist/FormLayout/FormLayout.d.ts +5 -4
  27. package/dist/FormLayout/FormLayout.d.ts.map +1 -1
  28. package/dist/FormLayout/FormLayout.js +1 -1
  29. package/dist/FormLayout/FormLayoutContext.d.ts +3 -3
  30. package/dist/FormLayout/FormLayoutContext.js +3 -3
  31. package/dist/Grid/Grid.d.ts +8 -7
  32. package/dist/Grid/Grid.d.ts.map +1 -1
  33. package/dist/Grid/Grid.js +5 -4
  34. package/dist/Grid/GridSpan.d.ts +1 -1
  35. package/dist/Markdown/parser.d.ts.map +1 -1
  36. package/dist/Markdown/parser.js +8 -2
  37. package/dist/Markdown/plugins/headingLinks.d.ts.map +1 -1
  38. package/dist/Markdown/plugins/headingLinks.js +9 -1
  39. package/dist/PowerSearch/PowerSearch.d.ts.map +1 -1
  40. package/dist/PowerSearch/PowerSearch.js +5 -0
  41. package/dist/PowerSearch/PowerSearchValueEditor.d.ts.map +1 -1
  42. package/dist/PowerSearch/PowerSearchValueEditor.js +45 -11
  43. package/dist/SideNav/SideNavItem.d.ts.map +1 -1
  44. package/dist/SideNav/SideNavItem.js +6 -2
  45. package/dist/Token/Token.js +1 -1
  46. package/dist/TopNav/TopNavMegaMenu.js +2 -0
  47. package/dist/TopNav/TopNavMenu.d.ts.map +1 -1
  48. package/dist/TopNav/TopNavMenu.js +2 -0
  49. package/dist/astryx.css +1 -1
  50. package/dist/reset.a11y.chromium.spec.js +50 -1
  51. package/docs.mjs +4 -4
  52. package/package.json +4 -4
  53. package/scripts/agent-doc-state.mjs +66 -6
  54. package/scripts/postinstall.mjs +44 -3
  55. package/src/Avatar/Avatar.tsx +11 -2
  56. package/src/AvatarGroup/AvatarGroupOverflow.test.tsx +24 -1
  57. package/src/AvatarGroup/AvatarGroupOverflow.tsx +2 -0
  58. package/src/Chat/ChatLayoutScrollButton.test.tsx +13 -0
  59. package/src/Chat/ChatLayoutScrollButton.tsx +4 -2
  60. package/src/Chat/__tests__/ChatLayoutScrollButton.a11y.chromium.spec.ts +64 -19
  61. package/src/CheckboxList/CheckboxList.fr10.test.tsx +29 -2
  62. package/src/CheckboxList/CheckboxList.tsx +4 -1
  63. package/src/Code/Code.doc.mjs +1 -0
  64. package/src/CommandPalette/CommandPalette.doc.mjs +2 -0
  65. package/src/ContextMenu/ContextMenu.test.tsx +12 -2
  66. package/src/Drawer/Drawer.doc.mjs +1 -0
  67. package/src/DropdownMenu/DropdownMenu.ssr.test.tsx +57 -0
  68. package/src/DropdownMenu/DropdownMenu.test.tsx +17 -5
  69. package/src/DropdownMenu/DropdownMenu.tsx +43 -142
  70. package/src/DropdownMenu/DropdownMenuCheckboxItem.doc.mjs +1 -1
  71. package/src/DropdownMenu/DropdownMenuCheckboxItem.tsx +1 -1
  72. package/src/DropdownMenu/DropdownMenuGroup.doc.mjs +169 -10
  73. package/src/DropdownMenu/DropdownMenuGroup.tsx +6 -4
  74. package/src/DropdownMenu/DropdownMenuLazySheet.test.tsx +126 -0
  75. package/src/DropdownMenu/DropdownMenuRadioItem.doc.mjs +1 -1
  76. package/src/DropdownMenu/DropdownMenuRadioItem.tsx +1 -1
  77. package/src/DropdownMenu/__tests__/DropdownMenuGroupAudit.a11y.chromium.spec.ts +334 -0
  78. package/src/DropdownMenu/__tests__/warmMenuBottomSheet.tsx +43 -0
  79. package/src/DropdownMenu/lazyMenuBottomSheet.ts +71 -0
  80. package/src/DropdownMenu/menuRegionStops.ts +167 -0
  81. package/src/Field/FieldLabel.doc.mjs +133 -26
  82. package/src/Field/FieldLabel.spec.md +172 -0
  83. package/src/Field/__tests__/FieldLabelAudit.a11y.chromium.spec.ts +296 -0
  84. package/src/FormLayout/FormLayout.doc.mjs +6 -3
  85. package/src/FormLayout/FormLayout.spec.md +192 -0
  86. package/src/FormLayout/FormLayout.test.tsx +15 -0
  87. package/src/FormLayout/FormLayout.tsx +7 -6
  88. package/src/FormLayout/FormLayoutContext.ts +3 -3
  89. package/src/Grid/Grid.doc.mjs +7 -1
  90. package/src/Grid/Grid.spec.md +93 -113
  91. package/src/Grid/Grid.test.tsx +22 -0
  92. package/src/Grid/Grid.tsx +10 -9
  93. package/src/Grid/GridSpan.doc.mjs +3 -3
  94. package/src/Grid/GridSpan.tsx +1 -1
  95. package/src/Icon/Icon.doc.mjs +36 -0
  96. package/src/Indicator/Indicator.doc.mjs +3 -3
  97. package/src/Layout/LayoutHeader.doc.mjs +5 -0
  98. package/src/Markdown/parser.ts +8 -2
  99. package/src/Markdown/plugins/headingLinks.ts +9 -1
  100. package/src/MetadataList/MetadataList.doc.mjs +1 -1
  101. package/src/MoreMenu/MoreMenu.test.tsx +5 -1
  102. package/src/PowerSearch/PowerSearch.test.tsx +62 -0
  103. package/src/PowerSearch/PowerSearch.tsx +9 -0
  104. package/src/PowerSearch/PowerSearchValueEditor.test.tsx +71 -0
  105. package/src/PowerSearch/PowerSearchValueEditor.tsx +45 -0
  106. package/src/ProgressBar/ProgressBar.doc.mjs +1 -0
  107. package/src/Resizable/useResizable.doc.mjs +2 -0
  108. package/src/ScrollableArea/ScrollableArea.doc.mjs +1 -1
  109. package/src/SideNav/SideNav.spec.md +227 -0
  110. package/src/SideNav/SideNav.test.tsx +41 -0
  111. package/src/SideNav/SideNavItem.tsx +5 -2
  112. package/src/Spinner/Spinner.doc.mjs +1 -1
  113. package/src/Table/Table.spec.md +6 -1
  114. package/src/Table/plugins/activation/useTableActivation.spec.md +254 -0
  115. package/src/Timestamp/Timestamp.doc.mjs +1 -1
  116. package/src/ToggleButton/ToggleButtonGroup.doc.mjs +4 -0
  117. package/src/Token/Token.tsx +1 -1
  118. package/src/Token/__tests__/TokenRemoveTarget.a11y.chromium.spec.ts +86 -0
  119. package/src/Toolbar/Toolbar.test.tsx +0 -8
  120. package/src/Toolbar/__tests__/Toolbar.a11y.chromium.spec.ts +163 -0
  121. package/src/Toolbar/__tests__/Toolbar.a11y.renders.tsx +63 -0
  122. package/src/Toolbar/__tests__/Toolbar.a11y.states.ts +142 -0
  123. package/src/Toolbar/__tests__/Toolbar.a11y.test.tsx +126 -0
  124. package/src/TopNav/TopNavMegaMenu.test.tsx +25 -0
  125. package/src/TopNav/TopNavMegaMenu.tsx +2 -0
  126. package/src/TopNav/TopNavMenu.test.tsx +27 -1
  127. package/src/TopNav/TopNavMenu.tsx +2 -0
  128. package/src/postinstall.test.mjs +79 -0
  129. package/src/reset.a11y.chromium.spec.ts +52 -1
  130. package/src/reset.css +9 -0
  131. package/src/reset.test.ts +11 -0
  132. package/src/unicodePropertyEscapes.source-build.test.mjs +69 -0
@@ -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,
@@ -11,17 +11,22 @@
11
11
  */
12
12
 
13
13
  import {describe, it, expect, beforeEach, afterEach} from 'vitest';
14
+ import {spawnSync} from 'node:child_process';
14
15
  import * as fs from 'node:fs';
15
16
  import * as os from 'node:os';
16
17
  import * as path from 'node:path';
18
+ import {fileURLToPath} from 'node:url';
17
19
  // Core's own package-local contract copy — what actually runs at install time.
18
20
  import {
19
21
  isAstryxInitialized,
22
+ isAstryxPromptInReach,
20
23
  shouldNudge,
21
24
  AGENT_DOC_PATHS,
22
25
  INIT_MARKERS,
23
26
  SETUP_NUDGE,
27
+ setupNudge,
24
28
  } from '../scripts/agent-doc-state.mjs';
29
+ import {cliInvocation} from '../scripts/postinstall.mjs';
25
30
  // Dev-only, monorepo-relative: core must not depend on the CLI at runtime (the
26
31
  // CLI peer-depends on core), but the test can read the source and the generator.
27
32
  import * as cliLeaf from '../../cli/foundation/agent-docs/agent-doc-state.mjs';
@@ -92,12 +97,58 @@ describe('core postinstall — one authoritative source, no drift', () => {
92
97
  expect(AGENT_DOC_PATHS).toEqual(cliLeaf.AGENT_DOC_PATHS);
93
98
  expect(INIT_MARKERS).toEqual(cliLeaf.INIT_MARKERS);
94
99
  expect(SETUP_NUDGE).toBe(cliLeaf.SETUP_NUDGE);
100
+ expect(setupNudge('pnpm exec astryx')).toBe(
101
+ cliLeaf.setupNudge('pnpm exec astryx'),
102
+ );
95
103
  });
96
104
 
97
105
  it('agrees with the CLI leaf on a real project', () => {
98
106
  write('AGENTS.md', `# doc\n${MARKER}\nbody`);
99
107
  expect(isAstryxInitialized(tmp)).toBe(cliLeaf.isAstryxInitialized(tmp));
100
108
  });
109
+
110
+ it('agrees with the CLI leaf on a monorepo package', () => {
111
+ fs.mkdirSync(path.join(tmp, '.git'));
112
+ write('AGENTS.md', MARKER);
113
+ write('packages/app/package.json', '{"name":"app"}');
114
+ const app = path.join(tmp, 'packages/app');
115
+ expect(isAstryxPromptInReach(app)).toBe(true);
116
+ expect(isAstryxPromptInReach(app)).toBe(cliLeaf.isAstryxPromptInReach(app));
117
+ });
118
+ });
119
+
120
+ // Install time, end to end: core's real script and its contract copy, installed
121
+ // under a monorepo's node_modules, run the way npm runs them for a package.
122
+ describe('core postinstall — inside a monorepo package', () => {
123
+ const CORE = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
124
+ const install = () => {
125
+ const installed = path.join(tmp, 'node_modules/@astryxdesign/core/scripts');
126
+ fs.mkdirSync(installed, {recursive: true});
127
+ for (const file of ['postinstall.mjs', 'agent-doc-state.mjs']) {
128
+ fs.copyFileSync(path.join(CORE, 'scripts', file), path.join(installed, file));
129
+ }
130
+ return spawnSync(process.execPath, [path.join(installed, 'postinstall.mjs')], {
131
+ env: {...process.env, INIT_CWD: path.join(tmp, 'packages/app'), npm_command: 'install'},
132
+ encoding: 'utf8',
133
+ });
134
+ };
135
+ beforeEach(() => {
136
+ fs.mkdirSync(path.join(tmp, '.git'));
137
+ write('packages/app/package.json', '{"name":"app"}');
138
+ });
139
+
140
+ it('stays quiet when init ran at the repository root', () => {
141
+ write('AGENTS.md', MARKER);
142
+ const r = install();
143
+ expect(r.status).toBe(0);
144
+ expect(r.stdout).toBe('');
145
+ });
146
+
147
+ it('still nudges when no folder up to the repository root ran init', () => {
148
+ const r = install();
149
+ expect(r.status).toBe(0);
150
+ expect(r.stdout).toMatch(/finish setup and install the Astryx agent prompt/);
151
+ });
101
152
  });
102
153
 
103
154
  describe('core postinstall — shouldNudge', () => {
@@ -135,3 +186,31 @@ describe('core postinstall — shouldNudge', () => {
135
186
  }
136
187
  });
137
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
+ });
@@ -4,7 +4,8 @@
4
4
  * @file reset.a11y.chromium.spec.ts
5
5
  * @input packages/core/src/reset.css, alone on a blank Chromium page
6
6
  * @output Browser evidence that the reset reaches ::before, ::after,
7
- * ::placeholder and the search field's cancel button
7
+ * ::placeholder, the search field's cancel button and the dropdown marker
8
+ * of a field with a list
8
9
  * @position reset.css integration proof. A pseudo-element inside :where() is
9
10
  * invalid, and the forgiving list drops it without an error, so only a
10
11
  * shipping engine shows which of the reset's rules still match.
@@ -13,6 +14,8 @@
13
14
  import * as fs from 'node:fs';
14
15
  import {fileURLToPath} from 'node:url';
15
16
  import {expect, test, type Page} from '@playwright/test';
17
+ // @ts-expect-error -- pngjs ships no declarations; runtime support is pinned.
18
+ import {PNG} from 'pngjs';
16
19
 
17
20
  const RESET_CSS = fs.readFileSync(
18
21
  fileURLToPath(new URL('./reset.css', import.meta.url)),
@@ -154,6 +157,54 @@ test('a search field has no cancel button to clear it', async ({page}) => {
154
157
  await expect(query).toHaveValue('query');
155
158
  });
156
159
 
160
+ test('the dropdown marker of a field with a list sits in its middle', async ({
161
+ page,
162
+ }) => {
163
+ // Chromium shows the marker while the pointer is over the field and draws
164
+ // it as a glyph set on the field's line-height, the 1.5 the reset inherits.
165
+ await page.evaluate(() => {
166
+ const field = document.createElement('input');
167
+ field.id = 'fruit';
168
+ field.setAttribute('list', 'fruits');
169
+ const options = document.createElement('datalist');
170
+ options.id = 'fruits';
171
+ options.append(new Option('Apple'));
172
+ document.body.append(field, options);
173
+ });
174
+ await page.addStyleTag({
175
+ content: `
176
+ #fruit {
177
+ display: block;
178
+ width: 240px;
179
+ padding: 7px 11px;
180
+ border: 1px solid;
181
+ line-height: 1.5;
182
+ }
183
+ `,
184
+ });
185
+ const field = page.locator('#fruit');
186
+ await field.hover();
187
+ const png = PNG.sync.read(await field.screenshot({caret: 'hide'}));
188
+ // The marker is the only ink in the field's last 32px, inside the border.
189
+ const inkRows: number[] = [];
190
+ for (let y = 2; y < png.height - 2; y++) {
191
+ for (let x = png.width - 32; x < png.width - 2; x++) {
192
+ const offset = (y * png.width + x) * 4;
193
+ const luminance =
194
+ (png.data[offset] + png.data[offset + 1] + png.data[offset + 2]) / 3;
195
+ if (luminance < 160) {
196
+ inkRows.push(y);
197
+ break;
198
+ }
199
+ }
200
+ }
201
+ expect(inkRows.length, 'the hovered field shows its marker').toBeGreaterThan(
202
+ 0,
203
+ );
204
+ const markerMiddle = (inkRows[0] + inkRows[inkRows.length - 1] + 1) / 2;
205
+ expect(Math.abs(markerMiddle - png.height / 2)).toBeLessThanOrEqual(1);
206
+ });
207
+
157
208
  test('a later layer and an unlayered rule still override the reset', async ({
158
209
  page,
159
210
  }) => {
package/src/reset.css CHANGED
@@ -265,6 +265,15 @@
265
265
  color: var(--color-text-secondary, light-dark(#4E606F, #AAAFB5));
266
266
  }
267
267
 
268
+ /*
269
+ * Chromium draws the dropdown marker of a field with a `list` as a glyph
270
+ * set on the line-height the field inherits, so with any line-height above
271
+ * 1 the marker sits below the middle of the field.
272
+ */
273
+ :where([list])::-webkit-calendar-picker-indicator {
274
+ line-height: 1;
275
+ }
276
+
268
277
  /*
269
278
  * Remove the inner padding and cancel buttons in search inputs
270
279
  * for Chrome and Safari.
package/src/reset.test.ts CHANGED
@@ -172,6 +172,17 @@ describe('reset.css pseudo-element rules', () => {
172
172
  ).toBe(true);
173
173
  });
174
174
 
175
+ it('sets the dropdown marker of a field with a list on a single line', () => {
176
+ const marker = ruleBlocks.filter(({selector}) =>
177
+ topLevelSelectors(selector).some(entry =>
178
+ entry.endsWith('::-webkit-calendar-picker-indicator'),
179
+ ),
180
+ );
181
+ expect(marker.map(({body}) => body).join('\n')).toMatch(
182
+ /line-height:\s*1;/,
183
+ );
184
+ });
185
+
175
186
  it('falls back to the --color-text-secondary default for placeholders', () => {
176
187
  // A page that loads only the reset never defines the token. The fallback
177
188
  // repeats the token's own default, so that page shows the placeholder an