@astryxdesign/core 0.7.2 → 0.7.3-canary.0bf23bd

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 (183) hide show
  1. package/dist/BottomSheet/BottomSheet.d.ts.map +1 -1
  2. package/dist/BottomSheet/BottomSheet.js +8 -4
  3. package/dist/BottomSheet/BottomSheetSwitcher.d.ts.map +1 -1
  4. package/dist/BottomSheet/BottomSheetSwitcher.js +7 -0
  5. package/dist/CheckboxInput/CheckboxInput.d.ts.map +1 -1
  6. package/dist/CheckboxInput/CheckboxInput.js +19 -24
  7. package/dist/ClickableCard/ClickableCard.d.ts +2 -2
  8. package/dist/ClickableCard/ClickableCard.d.ts.map +1 -1
  9. package/dist/ClickableCard/ClickableCard.js +11 -9
  10. package/dist/CodeBlock/CodeBlock.d.ts.map +1 -1
  11. package/dist/CodeBlock/CodeBlock.js +11 -1
  12. package/dist/ComplexSelector/ComplexSelector.d.ts.map +1 -1
  13. package/dist/ComplexSelector/ComplexSelector.js +23 -5
  14. package/dist/DateInput/DateInput.d.ts +4 -4
  15. package/dist/DateInput/DateInput.d.ts.map +1 -1
  16. package/dist/DateInput/DateInput.js +27 -20
  17. package/dist/DateInput/NativeDateField.d.ts.map +1 -1
  18. package/dist/DateInput/NativeDateField.js +14 -8
  19. package/dist/DateInput/TouchDateField.d.ts.map +1 -1
  20. package/dist/DateInput/TouchDateField.js +14 -10
  21. package/dist/Dialog/Dialog.d.ts.map +1 -1
  22. package/dist/Dialog/Dialog.js +9 -4
  23. package/dist/DropdownMenu/DropdownMenu.d.ts.map +1 -1
  24. package/dist/DropdownMenu/DropdownMenu.js +52 -10
  25. package/dist/DropdownMenu/DropdownMenuContext.d.ts +34 -1
  26. package/dist/DropdownMenu/DropdownMenuContext.d.ts.map +1 -1
  27. package/dist/DropdownMenu/DropdownMenuContext.js +15 -0
  28. package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts +21 -2
  29. package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts.map +1 -1
  30. package/dist/DropdownMenu/DropdownMenuSubMenu.js +375 -42
  31. package/dist/DropdownMenu/menuRegionStops.d.ts +2 -2
  32. package/dist/DropdownMenu/menuRegionStops.js +2 -2
  33. package/dist/FileInput/FileInput.d.ts.map +1 -1
  34. package/dist/FileInput/FileInput.js +14 -12
  35. package/dist/Link/Link.d.ts +2 -3
  36. package/dist/Link/Link.d.ts.map +1 -1
  37. package/dist/Link/Link.js +11 -19
  38. package/dist/Markdown/Markdown.d.ts.map +1 -1
  39. package/dist/Markdown/Markdown.js +48 -15
  40. package/dist/MultiSelector/MultiSelector.d.ts +4 -4
  41. package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
  42. package/dist/MultiSelector/MultiSelector.disabledFocus.a11y.chromium.spec.d.ts +2 -0
  43. package/dist/MultiSelector/MultiSelector.disabledFocus.a11y.chromium.spec.d.ts.map +1 -0
  44. package/dist/MultiSelector/MultiSelector.disabledFocus.a11y.chromium.spec.js +48 -0
  45. package/dist/MultiSelector/MultiSelector.js +25 -18
  46. package/dist/Popover/Popover.d.ts.map +1 -1
  47. package/dist/Popover/Popover.js +8 -4
  48. package/dist/Selector/Selector.d.ts +4 -4
  49. package/dist/Selector/Selector.d.ts.map +1 -1
  50. package/dist/Selector/Selector.js +23 -14
  51. package/dist/Selector/SelectorOption.d.ts.map +1 -1
  52. package/dist/Selector/SelectorOption.js +1 -0
  53. package/dist/Slider/Slider.d.ts.map +1 -1
  54. package/dist/Slider/Slider.js +7 -14
  55. package/dist/Switch/Switch.d.ts.map +1 -1
  56. package/dist/Switch/Switch.js +25 -17
  57. package/dist/Switch/switch.markers.stylex.d.ts +2 -0
  58. package/dist/Switch/switch.markers.stylex.d.ts.map +1 -1
  59. package/dist/Switch/switch.markers.stylex.js +6 -0
  60. package/dist/TimeInput/TimeInput.d.ts +2 -1
  61. package/dist/TimeInput/TimeInput.d.ts.map +1 -1
  62. package/dist/Token/Token.d.ts.map +1 -1
  63. package/dist/Token/Token.js +8 -3
  64. package/dist/Tooltip/Tooltip.d.ts.map +1 -1
  65. package/dist/Tooltip/Tooltip.js +36 -14
  66. package/dist/Tooltip/useTooltip.d.ts.map +1 -1
  67. package/dist/Tooltip/useTooltip.js +11 -2
  68. package/dist/TopNav/TopNav.d.ts.map +1 -1
  69. package/dist/TopNav/TopNav.js +2 -2
  70. package/dist/TopNav/TopNavHeading.d.ts.map +1 -1
  71. package/dist/TopNav/TopNavHeading.js +1 -0
  72. package/dist/Typeahead/Typeahead.d.ts.map +1 -1
  73. package/dist/Typeahead/Typeahead.js +14 -2
  74. package/dist/astryx.css +8 -2
  75. package/dist/hooks/useMenuPress.d.ts +11 -1
  76. package/dist/hooks/useMenuPress.d.ts.map +1 -1
  77. package/dist/hooks/useMenuPress.js +43 -5
  78. package/dist/utils/disabledInteraction.d.ts +21 -0
  79. package/dist/utils/disabledInteraction.d.ts.map +1 -0
  80. package/dist/utils/disabledInteraction.js +33 -0
  81. package/dist/utils/timeParser.d.ts +4 -0
  82. package/dist/utils/timeParser.d.ts.map +1 -1
  83. package/dist/utils/timeParser.js +9 -2
  84. package/package.json +4 -4
  85. package/scripts/agent-doc-state.mjs +35 -8
  86. package/src/Banner/Banner.spec.md +37 -32
  87. package/src/BottomSheet/BottomSheet.spec.md +13 -6
  88. package/src/BottomSheet/BottomSheet.test.tsx +29 -0
  89. package/src/BottomSheet/BottomSheet.tsx +8 -4
  90. package/src/BottomSheet/BottomSheetSwitcher.spec.md +121 -51
  91. package/src/BottomSheet/BottomSheetSwitcher.test.tsx +26 -0
  92. package/src/BottomSheet/BottomSheetSwitcher.tsx +6 -0
  93. package/src/CheckboxInput/CheckboxInput.test.tsx +44 -5
  94. package/src/CheckboxInput/CheckboxInput.tsx +16 -26
  95. package/src/CheckboxInput/__tests__/Checkbox.a11y.states.ts +3 -3
  96. package/src/CheckboxList/CheckboxList.test.tsx +9 -6
  97. package/src/ClickableCard/ClickableCard.doc.mjs +2 -2
  98. package/src/ClickableCard/ClickableCard.test.tsx +5 -4
  99. package/src/ClickableCard/ClickableCard.tsx +9 -9
  100. package/src/CodeBlock/CodeBlock.highlightStyles.test.tsx +87 -0
  101. package/src/CodeBlock/CodeBlock.tsx +11 -1
  102. package/src/ComplexSelector/ComplexSelector.focusableDisabled.test.tsx +76 -0
  103. package/src/ComplexSelector/ComplexSelector.tsx +21 -4
  104. package/src/ContextMenu/ContextMenu.test.tsx +41 -0
  105. package/src/DateInput/DateInput.doc.mjs +9 -8
  106. package/src/DateInput/DateInput.test.tsx +88 -13
  107. package/src/DateInput/DateInput.tsx +49 -21
  108. package/src/DateInput/DateInputTouch.test.tsx +22 -1
  109. package/src/DateInput/NativeDateField.test.tsx +29 -3
  110. package/src/DateInput/NativeDateField.tsx +20 -7
  111. package/src/DateInput/TouchDateField.tsx +24 -9
  112. package/src/DateRangeInput/DateRangeInput.spec.md +13 -13
  113. package/src/Dialog/Dialog.test.tsx +39 -0
  114. package/src/Dialog/Dialog.tsx +9 -4
  115. package/src/DropdownMenu/DropdownMenu.spec.md +33 -2
  116. package/src/DropdownMenu/DropdownMenu.tsx +79 -8
  117. package/src/DropdownMenu/DropdownMenuContext.public.test.ts +22 -0
  118. package/src/DropdownMenu/DropdownMenuContext.tsx +39 -1
  119. package/src/DropdownMenu/DropdownMenuSubMenu.doc.mjs +16 -1
  120. package/src/DropdownMenu/DropdownMenuSubMenu.test.tsx +623 -1
  121. package/src/DropdownMenu/DropdownMenuSubMenu.tsx +556 -99
  122. package/src/DropdownMenu/__tests__/SubMenuHeaderFooter.a11y.chromium.spec.ts +267 -0
  123. package/src/DropdownMenu/menuRegionStops.ts +2 -2
  124. package/src/DropdownMenu/modules/useMenuPress.spec.md +9 -0
  125. package/src/FieldStatus/FieldStatus.spec.md +3 -2
  126. package/src/FileInput/FileInput.test.tsx +30 -7
  127. package/src/FileInput/FileInput.tsx +21 -11
  128. package/src/Link/Link.test.tsx +21 -7
  129. package/src/Link/Link.tsx +16 -24
  130. package/src/Link/__tests__/Link.a11y.states.ts +1 -1
  131. package/src/Markdown/Markdown.rerender.test.tsx +143 -0
  132. package/src/Markdown/Markdown.tsx +89 -35
  133. package/src/MultiSelector/MultiSelector.disabledFocus.a11y.chromium.spec.ts +71 -0
  134. package/src/MultiSelector/MultiSelector.doc.mjs +5 -5
  135. package/src/MultiSelector/MultiSelector.focusableDisabled.test.tsx +121 -0
  136. package/src/MultiSelector/MultiSelector.test.tsx +49 -7
  137. package/src/MultiSelector/MultiSelector.tsx +53 -29
  138. package/src/Popover/Popover.test.tsx +15 -0
  139. package/src/Popover/Popover.tsx +19 -8
  140. package/src/PowerSearch/PowerSearchFilterEditor.test.tsx +3 -2
  141. package/src/PowerSearch/PowerSearchToken.test.tsx +7 -5
  142. package/src/Selector/Selector.doc.mjs +4 -4
  143. package/src/Selector/Selector.focusableDisabled.test.tsx +127 -0
  144. package/src/Selector/Selector.spec.md +11 -11
  145. package/src/Selector/Selector.test.tsx +64 -6
  146. package/src/Selector/Selector.tsx +51 -21
  147. package/src/Selector/SelectorOption.tsx +4 -0
  148. package/src/SideNav/SideNav.test.tsx +35 -0
  149. package/src/Slider/Slider.spec.md +11 -11
  150. package/src/Slider/Slider.test.tsx +3 -3
  151. package/src/Slider/Slider.tsx +8 -15
  152. package/src/Switch/Switch.test.tsx +85 -8
  153. package/src/Switch/Switch.tsx +26 -16
  154. package/src/Switch/__tests__/Switch.a11y.states.ts +2 -2
  155. package/src/Switch/switch.markers.stylex.ts +4 -0
  156. package/src/Table/Table.spec.md +2 -0
  157. package/src/Table/plugins/infiniteScroll/useTableInfiniteScroll.spec.md +244 -0
  158. package/src/Table/plugins/selection/useTableSelection.test.tsx +5 -3
  159. package/src/Table/plugins/virtualization/useTableVirtualization.spec.md +266 -0
  160. package/src/TimeInput/TimeInput.doc.mjs +5 -3
  161. package/src/TimeInput/TimeInput.test.tsx +20 -0
  162. package/src/TimeInput/TimeInput.tsx +2 -1
  163. package/src/Token/Token.test.tsx +26 -4
  164. package/src/Token/Token.tsx +6 -2
  165. package/src/Tokenizer/Tokenizer.spec.md +28 -28
  166. package/src/Tooltip/Tooltip.test.tsx +51 -6
  167. package/src/Tooltip/Tooltip.tsx +41 -18
  168. package/src/Tooltip/__tests__/Tooltip.a11y.chromium.spec.ts +129 -0
  169. package/src/Tooltip/__tests__/Tooltip.a11y.renders.tsx +86 -0
  170. package/src/Tooltip/__tests__/Tooltip.a11y.states.ts +132 -0
  171. package/src/Tooltip/__tests__/Tooltip.a11y.test.tsx +126 -0
  172. package/src/Tooltip/__tests__/TooltipHoverBridge.a11y.chromium.spec.ts +74 -0
  173. package/src/Tooltip/useTooltip.test.tsx +23 -0
  174. package/src/Tooltip/useTooltip.tsx +22 -9
  175. package/src/TopNav/TopNav.tsx +11 -1
  176. package/src/TopNav/TopNavHeading.tsx +1 -0
  177. package/src/TopNav/__tests__/TopNavMobileLongHeading.a11y.chromium.spec.ts +74 -0
  178. package/src/Typeahead/Typeahead.test.tsx +89 -0
  179. package/src/Typeahead/Typeahead.tsx +15 -2
  180. package/src/hooks/useMenuPress.ts +45 -7
  181. package/src/utils/disabledInteraction.ts +40 -0
  182. package/src/utils/timeParser.test.ts +11 -0
  183. package/src/utils/timeParser.ts +9 -2
@@ -0,0 +1,267 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file SubMenuHeaderFooter.a11y.chromium.spec.ts
5
+ * @input The built Storybook "Submenu / header and footer" story (a Labels
6
+ * sub-menu with a filter field above its checkbox rows and a "Create label"
7
+ * button below them) and real Chromium, once as a laptop (fine pointer) and
8
+ * once as a phone (coarse pointer, touch).
9
+ * @output Evidence for component:DropdownMenu FR20: a sub-menu's header and
10
+ * footer sit with its rows in a modal dialog named by its row, beside every
11
+ * menu element rather than inside one; the row says it opens a dialog; Tab
12
+ * and Shift+Tab walk field, rows and button and wrap; typing filters the
13
+ * rows and Escape returns to the row; a pick keeps the sub-menu open. On a
14
+ * phone the drilled-in view holds the same regions while the root menu
15
+ * steps aside. On the "Submenu / header with a choice" story, a flyout the
16
+ * pointer has left stays open while an arrow moves the header's radio choice.
17
+ * @position Browser proof behind the jsdom suite: the accessibility tree, real
18
+ * Tab order and the pointer decision are what jsdom cannot exercise.
19
+ */
20
+
21
+ import {devices, expect, test, type Locator, type Page} from '@playwright/test';
22
+ import {
23
+ DEFAULT_STORYBOOK_DIR,
24
+ serveStorybook,
25
+ type StaticServer,
26
+ } from '@astryxdesign/a11y-spec/storybook';
27
+
28
+ let storybook: StaticServer;
29
+ const storyId = 'core-dropdownmenu--sub-menu-header-and-footer';
30
+
31
+ test.beforeAll(async () => {
32
+ storybook = await serveStorybook(
33
+ process.env.ASTRYX_STORYBOOK_DIR ?? DEFAULT_STORYBOOK_DIR,
34
+ );
35
+ });
36
+ test.afterAll(async () => {
37
+ await storybook?.close();
38
+ });
39
+
40
+ async function mount(page: Page) {
41
+ await page.goto(
42
+ `${storybook.origin}/iframe.html?id=${storyId}&viewMode=story`,
43
+ );
44
+ await expect(page.getByTestId('submenu-regions-fixture')).toBeVisible();
45
+ }
46
+
47
+ /** Whether the element sits inside any role="menu" element. */
48
+ async function isInsideAMenu(page: Page, testName: string): Promise<boolean> {
49
+ return page
50
+ .getByRole('dialog', {name: testName})
51
+ .evaluate(el => el.parentElement?.closest('[role="menu"]') != null);
52
+ }
53
+
54
+ async function bounds(locator: Locator) {
55
+ const rect = await locator.boundingBox();
56
+ if (rect == null) {
57
+ throw new Error('no rendered bounds');
58
+ }
59
+ return rect;
60
+ }
61
+
62
+ test.describe('on a laptop (fine pointer)', () => {
63
+ test('the sub-menu opens a dialog beside the menu, named by its row, holding field, rows and button', async ({
64
+ page,
65
+ }) => {
66
+ await mount(page);
67
+ await page.getByRole('button', {name: 'Session'}).click();
68
+ const row = page.getByRole('menuitem', {name: 'Labels'});
69
+ await expect(row).toHaveAttribute('aria-haspopup', 'dialog');
70
+ await row.click();
71
+
72
+ const dialog = page.getByRole('dialog', {name: 'Labels'});
73
+ await expect(dialog).toBeVisible();
74
+ await expect(dialog).toHaveAttribute('aria-modal', 'true');
75
+ await expect(row).toHaveAttribute(
76
+ 'aria-controls',
77
+ (await dialog.getAttribute('id')) ?? '',
78
+ );
79
+ expect(await isInsideAMenu(page, 'Labels')).toBe(false);
80
+ // The sub-menu's own menu holds only its rows.
81
+ const menu = dialog.getByRole('menu', {name: 'Labels'});
82
+ expect(
83
+ await menu.evaluate(el =>
84
+ Array.from(el.querySelectorAll('[role]')).map(child =>
85
+ child.getAttribute('role'),
86
+ ),
87
+ ),
88
+ ).toEqual(Array(5).fill('menuitemcheckbox'));
89
+ await expect(
90
+ dialog.getByRole('textbox', {name: 'Filter labels'}),
91
+ ).toBeVisible();
92
+ await expect(
93
+ dialog.getByRole('button', {name: 'Create label'}),
94
+ ).toBeVisible();
95
+ });
96
+
97
+ test('Tab and Shift+Tab walk field, rows and button and wrap; typing filters; Escape returns to the row', async ({
98
+ page,
99
+ }) => {
100
+ await mount(page);
101
+ const trigger = page.getByRole('button', {name: 'Session'});
102
+ await trigger.focus();
103
+ await page.keyboard.press('Enter');
104
+ // A keyboard open lands on the first row; let it land before moving on.
105
+ await expect(page.getByRole('menuitem', {name: 'Rename'})).toBeFocused();
106
+ const row = page.getByRole('menuitem', {name: 'Labels'});
107
+ await page.keyboard.press('ArrowDown');
108
+ await expect(row).toBeFocused();
109
+ await page.keyboard.press('ArrowRight');
110
+
111
+ const dialog = page.getByRole('dialog', {name: 'Labels'});
112
+ const field = dialog.getByRole('textbox', {name: 'Filter labels'});
113
+ const create = dialog.getByRole('button', {name: 'Create label'});
114
+ const menu = dialog.getByRole('menu', {name: 'Labels'});
115
+ // A keyboard open lands on the first row, not the field.
116
+ await expect(
117
+ dialog.getByRole('menuitemcheckbox', {name: 'Bug'}),
118
+ ).toBeFocused();
119
+
120
+ await page.keyboard.press('Tab');
121
+ await expect(create).toBeFocused();
122
+ await page.keyboard.press('Tab');
123
+ await expect(field).toBeFocused();
124
+ await page.keyboard.press('Tab');
125
+ await expect(menu).toBeFocused();
126
+ await page.keyboard.press('Shift+Tab');
127
+ await expect(field).toBeFocused();
128
+ await page.keyboard.press('Shift+Tab');
129
+ await expect(create).toBeFocused();
130
+ await expect(dialog).toBeVisible();
131
+
132
+ // The field keeps its keys: letters type and filter the rows, and the
133
+ // arrow that closes a sub-menu from a row moves the caret instead.
134
+ await field.focus();
135
+ await page.keyboard.type('doc');
136
+ await page.keyboard.press('ArrowLeft');
137
+ await expect(field).toHaveValue('doc');
138
+ await expect(dialog.getByRole('menuitemcheckbox')).toHaveText([
139
+ 'Documentation',
140
+ ]);
141
+
142
+ await page.keyboard.press('Escape');
143
+ await expect(dialog).toBeHidden();
144
+ await expect(row).toBeFocused();
145
+ await expect(page.getByRole('menu', {name: 'Session'})).toBeVisible();
146
+ });
147
+
148
+ test('a pick keeps the sub-menu open; the button acts without closing anything', async ({
149
+ page,
150
+ }) => {
151
+ await mount(page);
152
+ await page.getByRole('button', {name: 'Session'}).click();
153
+ await page.getByRole('menuitem', {name: 'Labels'}).click();
154
+ const dialog = page.getByRole('dialog', {name: 'Labels'});
155
+ await dialog.getByRole('menuitemcheckbox', {name: 'Feature'}).click();
156
+ await expect(page.getByTestId('picked')).toHaveText('Bug, Feature');
157
+ await expect(dialog).toBeVisible();
158
+ await dialog.getByRole('button', {name: 'Create label'}).click();
159
+ await expect(page.getByTestId('created')).toHaveText('1');
160
+ await expect(dialog).toBeVisible();
161
+ });
162
+ });
163
+
164
+ test.describe('the press model', () => {
165
+ test('a press on the row, dragged into the flyout and released on a row, picks it', async ({
166
+ page,
167
+ }) => {
168
+ await mount(page);
169
+ await page.getByRole('button', {name: 'Session'}).click();
170
+ const row = page.getByRole('menuitem', {name: 'Labels'});
171
+ await row.hover();
172
+ const dialog = page.getByRole('dialog', {name: 'Labels'});
173
+ await expect(dialog).toBeVisible();
174
+ const feature = dialog.getByRole('menuitemcheckbox', {name: 'Feature'});
175
+ const [from, to] = await Promise.all([row, feature].map(bounds));
176
+ await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2);
177
+ await page.mouse.down();
178
+ await page.mouse.move(from.x + from.width - 4, from.y + from.height / 2, {
179
+ steps: 4,
180
+ });
181
+ await page.mouse.move(to.x + to.width / 2, to.y + to.height / 2, {
182
+ steps: 8,
183
+ });
184
+ await page.mouse.up();
185
+ await expect(page.getByTestId('picked')).toHaveText('Bug, Feature');
186
+ // A checkbox pick keeps the sub-menu, and the menu, open.
187
+ await expect(dialog).toBeVisible();
188
+ });
189
+ });
190
+
191
+ test.describe('on a phone (coarse pointer)', () => {
192
+ const {defaultBrowserType: _browser, ...phone} = devices['Pixel 7'];
193
+ test.use(phone);
194
+
195
+ test('the drilled-in view holds field, rows and button beside the root menu, which steps aside', async ({
196
+ page,
197
+ }) => {
198
+ await mount(page);
199
+ const trigger = page.getByRole('button', {name: 'Session'});
200
+ await trigger.tap();
201
+ const rootMenu = page.getByRole('menu', {name: 'Session'});
202
+ await expect(rootMenu).toBeVisible();
203
+ await page.getByRole('menuitem', {name: 'Labels'}).tap();
204
+
205
+ const dialog = page.getByRole('dialog', {name: 'Labels'});
206
+ await expect(dialog).toBeVisible();
207
+ await expect(rootMenu).toBeHidden();
208
+ expect(await isInsideAMenu(page, 'Labels')).toBe(false);
209
+ // Same popover: the view replaces the root's rows in place.
210
+ expect(
211
+ await dialog.evaluate(
212
+ el =>
213
+ el.closest('[popover]') ===
214
+ document
215
+ .querySelector('[role="menu"][aria-label="Session"]')
216
+ ?.closest('[popover]'),
217
+ ),
218
+ ).toBe(true);
219
+ await expect(
220
+ dialog.getByRole('menuitem', {name: 'Back to Session'}),
221
+ ).toBeVisible();
222
+ await expect(
223
+ dialog.getByRole('textbox', {name: 'Filter labels'}),
224
+ ).toBeVisible();
225
+ await expect(
226
+ dialog.getByRole('button', {name: 'Create label'}),
227
+ ).toBeVisible();
228
+
229
+ await dialog.getByRole('menuitem', {name: 'Back to Session'}).tap();
230
+ await expect(dialog).toBeHidden();
231
+ await expect(rootMenu).toBeVisible();
232
+ });
233
+ });
234
+
235
+ test.describe('a header with a choice', () => {
236
+ test('a flyout the pointer has left stays open while an arrow moves the choice in its header', async ({
237
+ page,
238
+ }) => {
239
+ await page.goto(
240
+ `${storybook.origin}/iframe.html?id=core-dropdownmenu--sub-menu-header-choice&viewMode=story`,
241
+ );
242
+ await expect(page.getByTestId('submenu-choice-fixture')).toBeVisible();
243
+ await page.getByRole('button', {name: 'Filters'}).click();
244
+ const row = page.getByRole('menuitem', {name: 'Labels'});
245
+ await row.hover();
246
+ const dialog = page.getByRole('dialog', {name: 'Labels'});
247
+ await expect(dialog).toBeVisible();
248
+ const anyLabel = dialog.getByRole('radio', {name: 'Any label'});
249
+ const allLabels = dialog.getByRole('radio', {name: 'All labels'});
250
+ await anyLabel.click();
251
+ await expect(anyLabel).toBeFocused();
252
+
253
+ // The pointer leaves the flyout and the menu, and its hide delay runs out
254
+ // with focus still inside.
255
+ const viewport = page.viewportSize() ?? {width: 1280, height: 720};
256
+ await page.mouse.move(viewport.width - 8, viewport.height - 8, {steps: 4});
257
+ await page.waitForTimeout(500);
258
+ await expect(dialog).toBeVisible();
259
+
260
+ // The browser moves focus and the choice for the arrow itself.
261
+ await page.keyboard.press('ArrowDown');
262
+ await expect(allLabels).toBeFocused();
263
+ await expect(allLabels).toBeChecked();
264
+ await expect(page.getByTestId('match')).toHaveText('all');
265
+ await expect(dialog).toBeVisible();
266
+ });
267
+ });
@@ -8,8 +8,8 @@
8
8
  * @output `tabbablesIn`, `focusRegionControl`, `moveRegionsTab`: the Tab stops
9
9
  * of a header or footer region and the walk through a menu with regions,
10
10
  * with a named radio group as one stop
11
- * @position Internal; DropdownMenu's walk through its header and footer, in a
12
- * module of its own so another menu level with regions can share it.
11
+ * @position Internal; used by DropdownMenu (the root menu's header and footer)
12
+ * and DropdownMenuSubMenu (a sub-menu's header and footer).
13
13
  *
14
14
  * A menu with a header or footer is a dialog: Tab and Shift+Tab walk the
15
15
  * header's controls, the menu as one stop, then the footer's controls, and
@@ -163,6 +163,14 @@ Consumer migration instructions belong in consumer docs and release notes.
163
163
  claimed by the innermost root carrying `data-astryx-menu-press` (a flyout
164
164
  before its parent); the outermost root decides what counts as "inside the
165
165
  menu" for the release.
166
+ - **ORD1a (proposed) — A menu beside the root.** A root carrying
167
+ `data-astryx-menu-press-surface`, which names another root's `id`, is nested
168
+ in that root for ORD1, as a flyout inside the root's DOM is: a release on
169
+ its rows is inside the menu. Only the menu of a sub-menu with a header or
170
+ footer, which the root renders beside itself (`component:DropdownMenu`
171
+ FR20), carries it. Anything else beside the root, a header field or a picker
172
+ placed in a region say, is outside the menu, as the root's own regions are,
173
+ and a picker there stays its own root. Pending owner review.
166
174
  - **ORD2 — Release resolution.** Enabled row under the point → `act`; inside
167
175
  the menu or on the trigger with no row → `settle` (stray click swallowed,
168
176
  menu stays); outside → `settle` with `dismiss` only for a mouse.
@@ -238,6 +246,7 @@ owned by the item components.
238
246
  | FR4 (scroll) | `MenuPress.a11y.chromium.spec.ts` (real Chromium) | Chromium touch pan in an overflowing menu; touch-action by overflow; finger slide and its stray click | Not provable in jsdom; a pan that acts on a row, or a slide that acts on the press row, fails |
239
247
  | FR4 (pinch) | `MenuPinchZoom.a11y.chromium.spec.ts` (real Chromium, phone emulation) | two-finger pinch over a menu whose rows fit, over one that scrolls, and over a picker listbox | Not provable in jsdom; a pinch that leaves the page at scale 1, or acts on a row, fails |
240
248
  | FR8, FR9 (proposed) | `menuPressGesture.test.ts` trigger states; `useMenuPress.test.tsx` trigger suite; `DropdownMenu.test.tsx` press-open cases | mouse opens at once, finger tap, held finger, settle before/after, cancel; press-open + drag-release, unsettled release, trigger toggle, held touch, tap | A menu that does not open on a mouse press, an unsettled release that acts, or a trigger that reopens in the same gesture fails |
249
+ | ORD1a (proposed) | `DropdownMenuSubMenu.test.tsx` press cases; `SubMenuHeaderFooter.a11y.chromium.spec.ts` press-drag case | a mouse press on a menu row released on a row of a sub-menu rendered beside the menu; the same press released on that sub-menu's header field | A release on such a row that acts on nothing or closes the menu, or a release on its header field that acts, fails. |
241
250
 
242
251
  ## Decision log
243
252
 
@@ -38,7 +38,8 @@ detached presentations; consumer usage remains documented in
38
38
 
39
39
  - Released default preserved: `yes`
40
40
  - Compatibility class: additive documentation only; runtime, DOM, styling, and
41
- public API remain unchanged
41
+ public API remain unchanged, except that delivery inside a mounted live
42
+ container follows `spec:AST-086` FR4
42
43
  - Controlled/uncontrolled behavior: not applicable
43
44
  - Migration decision: direct `FieldStatus variant="tooltip"` support is rejected.
44
45
  `tooltip` remains a field-family sentinel that MUST be handled before rendering
@@ -127,7 +128,7 @@ intentional decision.
127
128
  - **AR1 — Programmatic announcement channel.** Errors MUST update the persistent
128
129
  assertive region; warnings and successes MUST update the persistent polite
129
130
  region, including on first mount and when message or type changes, without
130
- moving focus.
131
+ moving focus. Inside a mounted live container, `spec:AST-086` FR4 applies.
131
132
  - **AR5 — Assistive-technology output.** Claims about whether, when, or how the
132
133
  message is spoken or presented in braille MUST be verified through the real-AT
133
134
  evidence process in `spec:AST-009`; DOM and accessibility-tree checks prove
@@ -243,12 +243,35 @@ describe('FileInput', () => {
243
243
  expect(trigger).not.toHaveAttribute('aria-required');
244
244
  });
245
245
 
246
- it('sets disabled attribute when isDisabled is true', () => {
246
+ it('keeps a disabled input focusable and out of form submission', async () => {
247
+ const user = userEvent.setup();
247
248
  render(
248
- <FileInput label="Upload" isDisabled value={null} onChange={() => {}} />,
249
+ <form>
250
+ <FileInput
251
+ label="Upload"
252
+ isRequired
253
+ isDisabled
254
+ value={null}
255
+ onChange={() => {}}
256
+ />
257
+ </form>,
249
258
  );
250
- const input = document.querySelector('input[type="file"]')!;
251
- expect(input).toBeDisabled();
259
+ const input =
260
+ document.querySelector<HTMLInputElement>('input[type="file"]')!;
261
+ const trigger = screen.getByRole('button', {name: 'Upload'});
262
+ expect(input).not.toHaveAttribute('disabled');
263
+ expect(trigger).not.toHaveAttribute('disabled');
264
+ expect(trigger).toHaveAttribute('aria-disabled', 'true');
265
+ const openPicker = vi.spyOn(input, 'click');
266
+ await user.tab();
267
+ expect(trigger).toHaveFocus();
268
+ await user.keyboard('{Enter}');
269
+ expect(openPicker).not.toHaveBeenCalled();
270
+ expect(input.form).toBeNull();
271
+ expect(trigger.closest('form')!.checkValidity()).toBe(true);
272
+ // A label click activates the hidden input directly; it is canceled so
273
+ // the picker never opens (fireEvent returns false when prevented).
274
+ expect(fireEvent.click(input)).toBe(false);
252
275
  });
253
276
 
254
277
  it('sets aria-invalid when status type is error', () => {
@@ -1095,7 +1118,7 @@ describe('FileInput', () => {
1095
1118
  expect(clickSpy).not.toHaveBeenCalled();
1096
1119
  });
1097
1120
 
1098
- it('keeps the trigger non-focusable when disabled without a reason', () => {
1121
+ it('keeps the trigger focusable when disabled without a reason', () => {
1099
1122
  render(
1100
1123
  <FileInput
1101
1124
  label="Resume"
@@ -1105,8 +1128,8 @@ describe('FileInput', () => {
1105
1128
  />,
1106
1129
  );
1107
1130
  const trigger = screen.getByRole('button');
1108
- expect(trigger).toHaveAttribute('tabindex', '-1');
1109
- expect(trigger).not.toHaveAttribute('aria-disabled');
1131
+ expect(trigger).toHaveAttribute('tabindex', '0');
1132
+ expect(trigger).toHaveAttribute('aria-disabled', 'true');
1110
1133
  });
1111
1134
  });
1112
1135
  });
@@ -179,7 +179,12 @@ const styles = stylex.create({
179
179
  dropzoneDisabled: {
180
180
  cursor: 'default',
181
181
  opacity: 0.5,
182
- borderColor: colorVars['--color-border-emphasized'],
182
+ // Keeps the :focus-within border: a focused disabled trigger still shows
183
+ // focus (spec:AST-085 FR6).
184
+ borderColor: {
185
+ default: colorVars['--color-border-emphasized'],
186
+ ':focus-within': colorVars['--color-accent'],
187
+ },
183
188
  },
184
189
  compact: {
185
190
  boxSizing: 'border-box',
@@ -221,7 +226,12 @@ const styles = stylex.create({
221
226
  compactDisabled: {
222
227
  cursor: 'default',
223
228
  opacity: 0.5,
224
- borderColor: colorVars['--color-border-emphasized'],
229
+ // Keeps the :focus-within border: a focused disabled trigger still shows
230
+ // focus (spec:AST-085 FR6).
231
+ borderColor: {
232
+ default: colorVars['--color-border-emphasized'],
233
+ ':focus-within': colorVars['--color-accent'],
234
+ },
225
235
  },
226
236
  hiddenInput: {
227
237
  position: 'absolute',
@@ -857,14 +867,9 @@ export function FileInput({
857
867
  <VisuallyHidden>
858
868
  <button
859
869
  type="button"
860
- // With a disabledMessage the trigger keeps focusability via
861
- // aria-disabled so the reason is focus-discoverable; opening the
862
- // picker is still blocked by the isDisabled guards in the handlers.
863
- // Otherwise the native disabled attribute removes it from the tab
864
- // order.
865
- disabled={isDisabled && !showsDisabledMessage}
866
- tabIndex={isDisabled && !showsDisabledMessage ? -1 : 0}
867
- aria-disabled={showsDisabledMessage ? 'true' : undefined}
870
+ // A disabled trigger keeps its Tab stop (spec:AST-085 FR1).
871
+ tabIndex={0}
872
+ aria-disabled={isDisabled ? 'true' : undefined}
868
873
  onClick={handleClick}
869
874
  onKeyDown={handleKeyDown}
870
875
  // aria-label suppresses child content from the accessible name, so
@@ -888,7 +893,12 @@ export function FileInput({
888
893
  type="file"
889
894
  accept={accept}
890
895
  multiple={isMultiple}
891
- disabled={isDisabled}
896
+ // Detached from its form while disabled, so selected files are not
897
+ // submitted and `required` does not block submission (FR3).
898
+ form={isDisabled ? '' : undefined}
899
+ // A label or description click activates this input directly; cancel
900
+ // it so a disabled input never opens the picker (FR2).
901
+ onClick={isDisabled ? event => event.preventDefault() : rest.onClick}
892
902
  onChange={handleInputChange}
893
903
  // The visually-hidden native input is never focused (tabIndex={-1});
894
904
  // its describedby/required/invalid live on the trigger button.
@@ -58,10 +58,25 @@ describe('Link', () => {
58
58
  expect(handleClick).toHaveBeenCalledTimes(1);
59
59
  });
60
60
 
61
- it('button fallback supports isDisabled', () => {
62
- render(<Link isDisabled>Disabled Action</Link>);
61
+ it('button fallback stays focusable and inert when isDisabled', async () => {
62
+ const user = userEvent.setup();
63
+ const onClick = vi.fn();
64
+ render(
65
+ <Link isDisabled onClick={onClick}>
66
+ Disabled Action
67
+ </Link>,
68
+ );
63
69
  const button = screen.getByRole('button');
64
- expect(button).toBeDisabled();
70
+ expect(button).not.toHaveAttribute('disabled');
71
+ expect(button).toHaveAttribute('aria-disabled', 'true');
72
+ const nativeClick = vi.fn();
73
+ button.addEventListener('click', nativeClick);
74
+ await user.tab();
75
+ expect(button).toHaveFocus();
76
+ await user.keyboard('{Enter} ');
77
+ expect(nativeClick).not.toHaveBeenCalled();
78
+ fireEvent.click(button);
79
+ expect(onClick).not.toHaveBeenCalled();
65
80
  });
66
81
 
67
82
  it('button fallback has type="button"', () => {
@@ -156,11 +171,10 @@ describe('Link', () => {
156
171
  Disabled Link
157
172
  </Link>,
158
173
  );
159
- // An href-less anchor has no implicit `link` role, so query by text.
160
- const link = screen.getByText('Disabled Link').closest('a');
161
- expect(link).not.toBeNull();
174
+ // The href-less anchor keeps a Tab stop and an explicit link role.
175
+ const link = screen.getByRole('link', {name: 'Disabled Link'});
162
176
  expect(link).toHaveAttribute('aria-disabled', 'true');
163
- expect(link).toHaveAttribute('tabIndex', '-1');
177
+ expect(link).toHaveAttribute('tabIndex', '0');
164
178
  // Visual classes are preserved in the disabled state.
165
179
  expect(link?.className).toContain('astryx-link');
166
180
  });
package/src/Link/Link.tsx CHANGED
@@ -48,6 +48,7 @@ import {focusOutlineProps} from '../utils/focusOutline.stylex';
48
48
  import {interactionOverlayStyles} from '../utils/interactionOverlay.stylex';
49
49
  import {usePressFeedback} from '../hooks/usePressFeedback';
50
50
  import {useTranslator} from '../i18n';
51
+ import {disabledInteractionProps} from '../utils/disabledInteraction';
51
52
 
52
53
  /**
53
54
  * Base link styles
@@ -174,9 +175,8 @@ export interface LinkProps extends BaseProps<
174
175
  /**
175
176
  * Whether the link is disabled.
176
177
  * A disabled link renders as a plain anchor without an href (and without
177
- * target/rel/onClick), so it cannot be focused or activated — no
178
- * navigation and no onClick, even via programmatic focus or assistive
179
- * technology activation.
178
+ * target/rel/onClick), so it cannot navigate or run onClick. It keeps a tab
179
+ * stop and is announced as a disabled link.
180
180
  * @default false
181
181
  */
182
182
  isDisabled?: boolean;
@@ -264,15 +264,6 @@ export interface LinkProps extends BaseProps<
264
264
  children: ReactNode;
265
265
  }
266
266
 
267
- /**
268
- * Click handler for disabled links. The disabled anchor renders without an
269
- * href, so there is no navigation to block in practice; preventDefault is a
270
- * defensive guard against synthetic/programmatic clicks.
271
- */
272
- function preventDefaultClick(event: React.MouseEvent<HTMLAnchorElement>): void {
273
- event.preventDefault();
274
- }
275
-
276
267
  /**
277
268
  * A styled anchor link component.
278
269
  *
@@ -367,8 +358,6 @@ export function Link({
367
358
  {...(isDisabled ? undefined : pressable)}
368
359
  aria-label={label || undefined}
369
360
  aria-disabled={isDisabled || undefined}
370
- tabIndex={isDisabled ? -1 : undefined}
371
- disabled={isDisabled}
372
361
  {...mergeProps(
373
362
  themeProps('link', {color}),
374
363
  focusOutlineProps.focusVisible(
@@ -386,24 +375,26 @@ export function Link({
386
375
  className,
387
376
  style,
388
377
  )}
389
- {...props}>
378
+ {...props}
379
+ // Focusable while disabled (spec:AST-085 FR1), but inert (FR2).
380
+ {...(isDisabled ? disabledInteractionProps(props.onKeyDown) : null)}>
390
381
  {sharedContent}
391
382
  </button>
392
383
  );
393
384
  } else if (isDisabled) {
394
- // A disabled link renders as a plain <a> with no href: an href-less
395
- // anchor is not focusable and exposes no link affordance, so programmatic
396
- // focus + Enter, AT activation commands, and middle-click cannot navigate
397
- // or fire the consumer onClick. The router LinkComponent is deliberately
398
- // skipped — a disabled link performs no navigation, and custom router
399
- // links may require a live href. target/rel are omitted with the href.
385
+ // A disabled link renders as an <a> with no href, so nothing can navigate
386
+ // and the consumer onClick never fires. It keeps a Tab stop and the link
387
+ // role so keyboard and screen-reader users still find it (spec:AST-085
388
+ // FR1, FR5). The router LinkComponent is deliberately skipped — a disabled
389
+ // link performs no navigation, and custom router links may require a live
390
+ // href. target/rel are omitted with the href.
400
391
  linkElement = (
401
392
  <a
402
393
  ref={ref as React.Ref<HTMLAnchorElement>}
403
- onClick={preventDefaultClick}
394
+ role="link"
404
395
  aria-label={label || undefined}
405
396
  aria-disabled={true}
406
- tabIndex={-1}
397
+ tabIndex={0}
407
398
  {...mergeProps(
408
399
  themeProps('link', {color}),
409
400
  focusOutlineProps.focusVisible(
@@ -417,7 +408,8 @@ export function Link({
417
408
  className,
418
409
  style,
419
410
  )}
420
- {...props}>
411
+ {...props}
412
+ {...disabledInteractionProps(props.onKeyDown)}>
421
413
  {sharedContent}
422
414
  </a>
423
415
  );
@@ -73,7 +73,7 @@ export const LINK_A11Y_EXCLUSIONS = [
73
73
  {
74
74
  owner: 'Link component tests and family:navigation-destinations FR7',
75
75
  reason:
76
- 'A disabled Link renders an href-less anchor that is neither a link nor focusable. That disabled semantics is member-owned, so disabled states are not bound to this available-link contract.',
76
+ 'A disabled Link renders a focusable, href-less anchor with an explicit link role and aria-disabled, so it cannot navigate. That disabled semantics is member-owned, so disabled states are not bound to this available-link contract.',
77
77
  },
78
78
  {
79
79
  owner: 'Link component tests (button rendering)',