@astryxdesign/core 0.7.2 → 0.7.3-canary.04c7468

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 (234) 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/Calendar/Calendar.js +3 -2
  6. package/dist/CheckboxInput/CheckboxInput.d.ts.map +1 -1
  7. package/dist/CheckboxInput/CheckboxInput.js +19 -24
  8. package/dist/ClickableCard/ClickableCard.d.ts +2 -2
  9. package/dist/ClickableCard/ClickableCard.d.ts.map +1 -1
  10. package/dist/ClickableCard/ClickableCard.js +11 -9
  11. package/dist/CodeBlock/CodeBlock.d.ts.map +1 -1
  12. package/dist/CodeBlock/CodeBlock.js +11 -1
  13. package/dist/ComplexSelector/ComplexSelector.d.ts.map +1 -1
  14. package/dist/ComplexSelector/ComplexSelector.js +23 -5
  15. package/dist/DateInput/DateInput.d.ts +4 -4
  16. package/dist/DateInput/DateInput.d.ts.map +1 -1
  17. package/dist/DateInput/DateInput.js +27 -20
  18. package/dist/DateInput/NativeDateField.d.ts.map +1 -1
  19. package/dist/DateInput/NativeDateField.js +14 -8
  20. package/dist/DateInput/TouchDateField.d.ts.map +1 -1
  21. package/dist/DateInput/TouchDateField.js +14 -10
  22. package/dist/DateRangeInput/DateRangeInput.d.ts +5 -5
  23. package/dist/DateRangeInput/DateRangeInput.d.ts.map +1 -1
  24. package/dist/DateRangeInput/DateRangeInput.js +27 -18
  25. package/dist/DateTimeInput/DateTimeInput.d.ts +5 -5
  26. package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
  27. package/dist/DateTimeInput/DateTimeInput.js +25 -27
  28. package/dist/DateTimeInput/NativeDateSegment.d.ts +3 -2
  29. package/dist/DateTimeInput/NativeDateSegment.d.ts.map +1 -1
  30. package/dist/DateTimeInput/NativeDateSegment.js +5 -5
  31. package/dist/DateTimeInput/NativeTimeSegment.d.ts +3 -2
  32. package/dist/DateTimeInput/NativeTimeSegment.d.ts.map +1 -1
  33. package/dist/DateTimeInput/NativeTimeSegment.js +5 -5
  34. package/dist/DateTimeInput/TouchDateTimeField.d.ts.map +1 -1
  35. package/dist/DateTimeInput/TouchDateTimeField.js +12 -9
  36. package/dist/Dialog/Dialog.d.ts.map +1 -1
  37. package/dist/Dialog/Dialog.js +9 -4
  38. package/dist/DropdownMenu/DropdownMenu.d.ts.map +1 -1
  39. package/dist/DropdownMenu/DropdownMenu.js +52 -10
  40. package/dist/DropdownMenu/DropdownMenuContext.d.ts +34 -1
  41. package/dist/DropdownMenu/DropdownMenuContext.d.ts.map +1 -1
  42. package/dist/DropdownMenu/DropdownMenuContext.js +15 -0
  43. package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts +21 -2
  44. package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts.map +1 -1
  45. package/dist/DropdownMenu/DropdownMenuSubMenu.js +375 -42
  46. package/dist/DropdownMenu/menuRegionStops.d.ts +2 -2
  47. package/dist/DropdownMenu/menuRegionStops.js +2 -2
  48. package/dist/FileInput/FileInput.d.ts.map +1 -1
  49. package/dist/FileInput/FileInput.js +14 -12
  50. package/dist/Link/Link.d.ts +2 -3
  51. package/dist/Link/Link.d.ts.map +1 -1
  52. package/dist/Link/Link.js +11 -19
  53. package/dist/Markdown/Markdown.d.ts.map +1 -1
  54. package/dist/Markdown/Markdown.js +48 -15
  55. package/dist/MultiSelector/MultiSelector.d.ts +4 -4
  56. package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
  57. package/dist/MultiSelector/MultiSelector.disabledFocus.a11y.chromium.spec.d.ts +2 -0
  58. package/dist/MultiSelector/MultiSelector.disabledFocus.a11y.chromium.spec.d.ts.map +1 -0
  59. package/dist/MultiSelector/MultiSelector.disabledFocus.a11y.chromium.spec.js +48 -0
  60. package/dist/MultiSelector/MultiSelector.js +25 -18
  61. package/dist/Pagination/Pagination.d.ts.map +1 -1
  62. package/dist/Pagination/Pagination.js +5 -2
  63. package/dist/Popover/Popover.d.ts.map +1 -1
  64. package/dist/Popover/Popover.js +8 -4
  65. package/dist/Selector/Selector.d.ts +4 -4
  66. package/dist/Selector/Selector.d.ts.map +1 -1
  67. package/dist/Selector/Selector.js +23 -14
  68. package/dist/Selector/SelectorOption.d.ts.map +1 -1
  69. package/dist/Selector/SelectorOption.js +1 -0
  70. package/dist/Slider/Slider.d.ts.map +1 -1
  71. package/dist/Slider/Slider.js +7 -14
  72. package/dist/Switch/Switch.d.ts.map +1 -1
  73. package/dist/Switch/Switch.js +25 -17
  74. package/dist/Switch/switch.markers.stylex.d.ts +2 -0
  75. package/dist/Switch/switch.markers.stylex.d.ts.map +1 -1
  76. package/dist/Switch/switch.markers.stylex.js +6 -0
  77. package/dist/TimeInput/TimeInput.d.ts +7 -6
  78. package/dist/TimeInput/TimeInput.d.ts.map +1 -1
  79. package/dist/TimeInput/TimeInput.js +37 -20
  80. package/dist/TimeInput/TouchTimeField.d.ts.map +1 -1
  81. package/dist/TimeInput/TouchTimeField.js +17 -6
  82. package/dist/Timestamp/Timestamp.d.ts.map +1 -1
  83. package/dist/Timestamp/Timestamp.js +41 -8
  84. package/dist/Token/Token.d.ts.map +1 -1
  85. package/dist/Token/Token.js +8 -3
  86. package/dist/Tooltip/Tooltip.d.ts.map +1 -1
  87. package/dist/Tooltip/Tooltip.js +36 -14
  88. package/dist/Tooltip/useTooltip.d.ts.map +1 -1
  89. package/dist/Tooltip/useTooltip.js +11 -2
  90. package/dist/TopNav/TopNav.d.ts.map +1 -1
  91. package/dist/TopNav/TopNav.js +2 -2
  92. package/dist/TopNav/TopNavHeading.d.ts.map +1 -1
  93. package/dist/TopNav/TopNavHeading.js +1 -0
  94. package/dist/TreeList/TreeListItem.d.ts.map +1 -1
  95. package/dist/TreeList/TreeListItem.js +8 -3
  96. package/dist/Typeahead/Typeahead.d.ts.map +1 -1
  97. package/dist/Typeahead/Typeahead.js +14 -2
  98. package/dist/astryx.css +8 -2
  99. package/dist/hooks/useGridFocus.d.ts.map +1 -1
  100. package/dist/hooks/useGridFocus.js +10 -1
  101. package/dist/hooks/useMenuPress.d.ts +11 -1
  102. package/dist/hooks/useMenuPress.d.ts.map +1 -1
  103. package/dist/hooks/useMenuPress.js +43 -5
  104. package/dist/utils/disabledInteraction.d.ts +21 -0
  105. package/dist/utils/disabledInteraction.d.ts.map +1 -0
  106. package/dist/utils/disabledInteraction.js +33 -0
  107. package/dist/utils/timeParser.d.ts +4 -0
  108. package/dist/utils/timeParser.d.ts.map +1 -1
  109. package/dist/utils/timeParser.js +9 -2
  110. package/package.json +4 -4
  111. package/scripts/agent-doc-state.mjs +58 -9
  112. package/src/Banner/Banner.spec.md +37 -32
  113. package/src/BottomSheet/BottomSheet.spec.md +34 -54
  114. package/src/BottomSheet/BottomSheet.test.tsx +29 -0
  115. package/src/BottomSheet/BottomSheet.tsx +8 -4
  116. package/src/BottomSheet/BottomSheetSwitcher.spec.md +125 -58
  117. package/src/BottomSheet/BottomSheetSwitcher.test.tsx +26 -0
  118. package/src/BottomSheet/BottomSheetSwitcher.tsx +6 -0
  119. package/src/Calendar/Calendar.test.tsx +68 -24
  120. package/src/Calendar/Calendar.tsx +2 -1
  121. package/src/CheckboxInput/CheckboxInput.test.tsx +44 -5
  122. package/src/CheckboxInput/CheckboxInput.tsx +16 -26
  123. package/src/CheckboxInput/__tests__/Checkbox.a11y.states.ts +3 -3
  124. package/src/CheckboxList/CheckboxList.test.tsx +9 -6
  125. package/src/ClickableCard/ClickableCard.doc.mjs +2 -2
  126. package/src/ClickableCard/ClickableCard.test.tsx +5 -4
  127. package/src/ClickableCard/ClickableCard.tsx +9 -9
  128. package/src/CodeBlock/CodeBlock.highlightStyles.test.tsx +87 -0
  129. package/src/CodeBlock/CodeBlock.tsx +11 -1
  130. package/src/ComplexSelector/ComplexSelector.focusableDisabled.test.tsx +76 -0
  131. package/src/ComplexSelector/ComplexSelector.tsx +21 -4
  132. package/src/ContextMenu/ContextMenu.test.tsx +41 -0
  133. package/src/DateInput/DateInput.doc.mjs +9 -8
  134. package/src/DateInput/DateInput.test.tsx +88 -13
  135. package/src/DateInput/DateInput.tsx +49 -21
  136. package/src/DateInput/DateInputTouch.test.tsx +22 -1
  137. package/src/DateInput/NativeDateField.test.tsx +29 -3
  138. package/src/DateInput/NativeDateField.tsx +20 -7
  139. package/src/DateInput/TouchDateField.tsx +24 -9
  140. package/src/DateRangeInput/DateRangeInput.doc.mjs +6 -5
  141. package/src/DateRangeInput/DateRangeInput.spec.md +13 -13
  142. package/src/DateRangeInput/DateRangeInput.test.tsx +82 -16
  143. package/src/DateRangeInput/DateRangeInput.tsx +40 -20
  144. package/src/DateTimeInput/DateTimeInput.doc.mjs +9 -8
  145. package/src/DateTimeInput/DateTimeInput.test.tsx +126 -11
  146. package/src/DateTimeInput/DateTimeInput.tsx +42 -26
  147. package/src/DateTimeInput/DateTimeInputTouch.test.tsx +24 -0
  148. package/src/DateTimeInput/NativeDateSegment.tsx +9 -6
  149. package/src/DateTimeInput/NativePickerSegments.test.tsx +31 -0
  150. package/src/DateTimeInput/NativeTimeSegment.tsx +9 -6
  151. package/src/DateTimeInput/TouchDateTimeField.tsx +22 -7
  152. package/src/Dialog/Dialog.test.tsx +39 -0
  153. package/src/Dialog/Dialog.tsx +9 -4
  154. package/src/DropdownMenu/DropdownMenu.spec.md +33 -2
  155. package/src/DropdownMenu/DropdownMenu.tsx +79 -8
  156. package/src/DropdownMenu/DropdownMenuContext.public.test.ts +22 -0
  157. package/src/DropdownMenu/DropdownMenuContext.tsx +39 -1
  158. package/src/DropdownMenu/DropdownMenuSubMenu.doc.mjs +16 -1
  159. package/src/DropdownMenu/DropdownMenuSubMenu.test.tsx +623 -1
  160. package/src/DropdownMenu/DropdownMenuSubMenu.tsx +556 -99
  161. package/src/DropdownMenu/__tests__/SubMenuHeaderFooter.a11y.chromium.spec.ts +267 -0
  162. package/src/DropdownMenu/menuRegionStops.ts +2 -2
  163. package/src/DropdownMenu/modules/useMenuPress.spec.md +9 -0
  164. package/src/FieldStatus/FieldStatus.spec.md +3 -2
  165. package/src/FileInput/FileInput.test.tsx +30 -7
  166. package/src/FileInput/FileInput.tsx +21 -11
  167. package/src/Link/Link.test.tsx +21 -7
  168. package/src/Link/Link.tsx +16 -24
  169. package/src/Link/__tests__/Link.a11y.states.ts +1 -1
  170. package/src/Markdown/Markdown.rerender.test.tsx +143 -0
  171. package/src/Markdown/Markdown.tsx +89 -35
  172. package/src/MultiSelector/MultiSelector.disabledFocus.a11y.chromium.spec.ts +71 -0
  173. package/src/MultiSelector/MultiSelector.doc.mjs +5 -5
  174. package/src/MultiSelector/MultiSelector.focusableDisabled.test.tsx +121 -0
  175. package/src/MultiSelector/MultiSelector.test.tsx +49 -7
  176. package/src/MultiSelector/MultiSelector.tsx +53 -29
  177. package/src/Pagination/Pagination.test.tsx +5 -1
  178. package/src/Pagination/Pagination.tsx +3 -1
  179. package/src/Popover/Popover.test.tsx +15 -0
  180. package/src/Popover/Popover.tsx +19 -8
  181. package/src/PowerSearch/PowerSearchFilterEditor.test.tsx +3 -2
  182. package/src/PowerSearch/PowerSearchToken.test.tsx +7 -5
  183. package/src/Selector/Selector.doc.mjs +4 -4
  184. package/src/Selector/Selector.focusableDisabled.test.tsx +127 -0
  185. package/src/Selector/Selector.spec.md +11 -11
  186. package/src/Selector/Selector.test.tsx +64 -6
  187. package/src/Selector/Selector.tsx +51 -21
  188. package/src/Selector/SelectorOption.tsx +4 -0
  189. package/src/SideNav/SideNav.test.tsx +35 -0
  190. package/src/Slider/Slider.spec.md +11 -11
  191. package/src/Slider/Slider.test.tsx +3 -3
  192. package/src/Slider/Slider.tsx +8 -15
  193. package/src/Switch/Switch.test.tsx +85 -8
  194. package/src/Switch/Switch.tsx +26 -16
  195. package/src/Switch/__tests__/Switch.a11y.states.ts +2 -2
  196. package/src/Switch/switch.markers.stylex.ts +4 -0
  197. package/src/Table/Table.spec.md +2 -0
  198. package/src/Table/plugins/infiniteScroll/useTableInfiniteScroll.spec.md +244 -0
  199. package/src/Table/plugins/selection/useTableSelection.test.tsx +5 -3
  200. package/src/Table/plugins/virtualization/useTableVirtualization.spec.md +266 -0
  201. package/src/TimeInput/NativeTimeInput.test.tsx +25 -1
  202. package/src/TimeInput/Presentation.test.tsx +23 -0
  203. package/src/TimeInput/TimeInput.doc.mjs +13 -10
  204. package/src/TimeInput/TimeInput.test.tsx +139 -7
  205. package/src/TimeInput/TimeInput.tsx +53 -24
  206. package/src/TimeInput/TouchTimeField.tsx +27 -5
  207. package/src/Timestamp/Timestamp.doc.mjs +1 -1
  208. package/src/Timestamp/Timestamp.test.tsx +168 -0
  209. package/src/Timestamp/Timestamp.tsx +62 -11
  210. package/src/Token/Token.test.tsx +26 -4
  211. package/src/Token/Token.tsx +6 -2
  212. package/src/Tokenizer/Tokenizer.spec.md +28 -28
  213. package/src/Tooltip/Tooltip.test.tsx +51 -6
  214. package/src/Tooltip/Tooltip.tsx +41 -18
  215. package/src/Tooltip/__tests__/Tooltip.a11y.chromium.spec.ts +129 -0
  216. package/src/Tooltip/__tests__/Tooltip.a11y.renders.tsx +86 -0
  217. package/src/Tooltip/__tests__/Tooltip.a11y.states.ts +132 -0
  218. package/src/Tooltip/__tests__/Tooltip.a11y.test.tsx +126 -0
  219. package/src/Tooltip/__tests__/TooltipHoverBridge.a11y.chromium.spec.ts +74 -0
  220. package/src/Tooltip/useTooltip.test.tsx +23 -0
  221. package/src/Tooltip/useTooltip.tsx +22 -9
  222. package/src/TopNav/TopNav.tsx +11 -1
  223. package/src/TopNav/TopNavHeading.tsx +1 -0
  224. package/src/TopNav/__tests__/TopNavMobileLongHeading.a11y.chromium.spec.ts +74 -0
  225. package/src/TreeList/TreeList.test.tsx +28 -0
  226. package/src/TreeList/TreeListItem.tsx +6 -2
  227. package/src/Typeahead/Typeahead.test.tsx +89 -0
  228. package/src/Typeahead/Typeahead.tsx +15 -2
  229. package/src/__tests__/DisabledTriggerFocus.a11y.chromium.spec.ts +86 -0
  230. package/src/hooks/useGridFocus.ts +16 -1
  231. package/src/hooks/useMenuPress.ts +45 -7
  232. package/src/utils/disabledInteraction.ts +40 -0
  233. package/src/utils/timeParser.test.ts +11 -0
  234. package/src/utils/timeParser.ts +9 -2
@@ -0,0 +1,74 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file TopNavMobileLongHeading.a11y.chromium.spec.ts
5
+ * @input The `Core/AppShell` Long Heading On Mobile story in a built Storybook
6
+ * at a 320px phone viewport.
7
+ * @output Real-engine proof that a long TopNav heading gives way on a narrow
8
+ * screen: the menu toggle stays inside the compact bar, and the drawer's
9
+ * close button stays inside the drawer.
10
+ * @position Run with `pnpm test:a11y-contract`. jsdom has no layout, so where
11
+ * the toggle lands is only observable here.
12
+ */
13
+
14
+ import {expect, test, type Locator} from '@playwright/test';
15
+ import {
16
+ DEFAULT_STORYBOOK_DIR,
17
+ serveStorybook,
18
+ type StaticServer,
19
+ } from '@astryxdesign/a11y-spec/storybook';
20
+
21
+ let storybook: StaticServer;
22
+
23
+ test.beforeAll(async () => {
24
+ storybook = await serveStorybook(
25
+ process.env.ASTRYX_STORYBOOK_DIR ?? DEFAULT_STORYBOOK_DIR,
26
+ );
27
+ });
28
+
29
+ test.afterAll(async () => {
30
+ await storybook?.close();
31
+ });
32
+
33
+ const STORY = 'core-appshell--long-heading-on-mobile';
34
+
35
+ async function boxOf(locator: Locator) {
36
+ const box = await locator.boundingBox();
37
+ if (box == null) {
38
+ throw new Error('the element has no layout box');
39
+ }
40
+ return box;
41
+ }
42
+
43
+ test('a long heading keeps the menu toggle and the drawer close button on screen', async ({
44
+ page,
45
+ }) => {
46
+ await page.setViewportSize({width: 320, height: 568});
47
+ await page.goto(`${storybook.origin}/iframe.html?id=${STORY}&viewMode=story`);
48
+
49
+ const bar = page.getByRole('navigation', {name: 'Main navigation'});
50
+ const toggle = page.getByRole('button', {name: 'Open navigation'});
51
+ await expect(toggle).toBeVisible();
52
+
53
+ const barBox = await boxOf(bar);
54
+ const toggleBox = await boxOf(toggle);
55
+ expect(toggleBox.x).toBeGreaterThanOrEqual(barBox.x);
56
+ expect(toggleBox.x + toggleBox.width).toBeLessThanOrEqual(
57
+ barBox.x + barBox.width,
58
+ );
59
+
60
+ await toggle.click();
61
+
62
+ const drawer = page.getByRole('dialog', {name: 'Navigation'});
63
+ const close = drawer.getByRole('button', {name: 'Close navigation'});
64
+ await expect(close).toBeVisible();
65
+
66
+ // The drawer slides in from the inline end; measure once it has settled.
67
+ await expect(close).toBeInViewport({ratio: 1});
68
+ const drawerBox = await boxOf(drawer);
69
+ const closeBox = await boxOf(close);
70
+ expect(closeBox.x).toBeGreaterThanOrEqual(drawerBox.x);
71
+ expect(closeBox.x + closeBox.width).toBeLessThanOrEqual(
72
+ drawerBox.x + drawerBox.width,
73
+ );
74
+ });
@@ -942,6 +942,34 @@ describe('TreeList', () => {
942
942
  expect(screen.queryByText('File')).not.toBeInTheDocument();
943
943
  });
944
944
 
945
+ it("exposes a disabled row's toggle and action as aria-disabled and keeps them inert", () => {
946
+ const onClick = vi.fn();
947
+ const {container} = render(
948
+ <TreeList
949
+ items={underRoot([
950
+ {id: 'run', label: 'Run', onClick, isDisabled: true},
951
+ {...folder, isDisabled: true},
952
+ ])}
953
+ />,
954
+ );
955
+ // spec:AST-085 FR1/FR2: no native disabled anywhere, and a click
956
+ // dispatched straight on the inner controls does nothing.
957
+ expect(container.querySelector('[disabled]')).toBeNull();
958
+ const toggle = container.querySelector<HTMLElement>(
959
+ '[data-tree-toggle][aria-disabled="true"]',
960
+ )!;
961
+ expect(toggle).not.toBeNull();
962
+ toggle.click();
963
+ fireEvent.keyDown(toggle, {key: 'Enter'});
964
+ expect(screen.queryByText('File')).not.toBeInTheDocument();
965
+ const action = Array.from(container.querySelectorAll('button')).find(
966
+ button => button.textContent?.includes('Run'),
967
+ )!;
968
+ expect(action).toHaveAttribute('aria-disabled', 'true');
969
+ action.click();
970
+ expect(onClick).not.toHaveBeenCalled();
971
+ });
972
+
945
973
  it('leaves a role-based control in start or end content to itself', () => {
946
974
  // Native and role-based controls alike keep their own activation,
947
975
  // the same rule link rows get from the shared enlarged-surface hook.
@@ -38,6 +38,7 @@ import {TreeListBranches} from './TreeListBranches';
38
38
  import type {TreeListDensity, TreeListVariant} from './TreeListTypes';
39
39
  import {themeProps} from '../utils/themeProps';
40
40
  import {useTranslator} from '../i18n';
41
+ import {disabledInteractionProps} from '../utils/disabledInteraction';
41
42
 
42
43
  // =============================================================================
43
44
  // Styles
@@ -552,11 +553,13 @@ export function TreeListItem({
552
553
  // Stable identity for TreeList's activateItem selector — do not
553
554
  // remove without also updating TreeList.tsx.
554
555
  data-tree-toggle=""
555
- disabled={isDisabled}
556
+ // Disabled stays focusable (spec:AST-085 FR1) but inert (FR2).
557
+ aria-disabled={isDisabled || undefined}
556
558
  // Roving tabindex lives on the treeitem row; the chevron toggle is not
557
559
  // a separate tab stop. Row-level Enter/Space forwards to this button.
558
560
  tabIndex={-1}
559
561
  onClick={handleToggle}
562
+ {...(isDisabled ? disabledInteractionProps() : null)}
560
563
  {...mergeProps(
561
564
  // Stable theme target for the expand/collapse control. `data-tree-toggle`
562
565
  // stays as the functional activation hook; this adds a themeable
@@ -608,7 +611,8 @@ export function TreeListItem({
608
611
  <button
609
612
  type="button"
610
613
  onClick={onClick}
611
- disabled={isDisabled}
614
+ aria-disabled={isDisabled || undefined}
615
+ {...(isDisabled ? disabledInteractionProps() : null)}
612
616
  aria-labelledby={labelId}
613
617
  aria-describedby={description != null ? descriptionId : undefined}
614
618
  // Roving tabindex lives on the treeitem row; inner action is not a
@@ -1637,6 +1637,95 @@ describe('Typeahead edit mode', () => {
1637
1637
  expect(onChange).not.toHaveBeenCalled();
1638
1638
  });
1639
1639
 
1640
+ it('empties the input when cleared while editing the selected value (#7038)', async () => {
1641
+ const onChangeQuery = vi.fn();
1642
+ function Controlled() {
1643
+ const [value, setValue] = useState<SearchableItem | null>(fruits[0]);
1644
+ return (
1645
+ <Typeahead
1646
+ label="Fruit"
1647
+ searchSource={fruitSource}
1648
+ value={value}
1649
+ onChange={setValue}
1650
+ onChangeQuery={onChangeQuery}
1651
+ hasClear
1652
+ />
1653
+ );
1654
+ }
1655
+ render(<Controlled />);
1656
+ const input = screen.getByRole<HTMLInputElement>('combobox');
1657
+
1658
+ // Enter edit mode: the token gives way to the label as editable text
1659
+ fireEvent.click(screen.getByText(fruits[0].label).closest('div')!);
1660
+ await waitFor(() => expect(input.value).toBe(fruits[0].label));
1661
+
1662
+ fireEvent.click(screen.getByRole('button', {name: 'Clear selection'}));
1663
+
1664
+ // The value and the visible text agree: nothing selected, nothing typed
1665
+ await waitFor(() => expect(input.value).toBe(''));
1666
+ expect(onChangeQuery).toHaveBeenLastCalledWith('');
1667
+ expect(input).toHaveFocus();
1668
+ });
1669
+
1670
+ // Escape and blur restore the token but leave the edit-mode label in the
1671
+ // base's query, so a clear from the restored token faces the same stale text.
1672
+ it.each([
1673
+ [
1674
+ 'Escape',
1675
+ (input: HTMLElement) => fireEvent.keyDown(input, {key: 'Escape'}),
1676
+ ],
1677
+ ['blur', (input: HTMLElement) => fireEvent.blur(input)],
1678
+ ])(
1679
+ 'empties the input when cleared after %s restored the token (#7038)',
1680
+ async (_name, leaveEditMode) => {
1681
+ const onChangeQuery = vi.fn();
1682
+ function Controlled() {
1683
+ const [value, setValue] = useState<SearchableItem | null>(fruits[0]);
1684
+ return (
1685
+ <Typeahead
1686
+ label="Fruit"
1687
+ searchSource={fruitSource}
1688
+ value={value}
1689
+ onChange={setValue}
1690
+ onChangeQuery={onChangeQuery}
1691
+ hasClear
1692
+ />
1693
+ );
1694
+ }
1695
+ render(<Controlled />);
1696
+ const input = screen.getByRole<HTMLInputElement>('combobox');
1697
+
1698
+ fireEvent.click(screen.getByText(fruits[0].label).closest('div')!);
1699
+ await waitFor(() => expect(input.value).toBe(fruits[0].label));
1700
+
1701
+ leaveEditMode(input);
1702
+ await screen.findByRole('button', {name: 'Clear selection'});
1703
+
1704
+ fireEvent.click(screen.getByRole('button', {name: 'Clear selection'}));
1705
+
1706
+ await waitFor(() => expect(input.value).toBe(''));
1707
+ expect(onChangeQuery).toHaveBeenLastCalledWith('');
1708
+ },
1709
+ );
1710
+
1711
+ it('does not report a query change when cleared outside edit mode', () => {
1712
+ const onChangeQuery = vi.fn();
1713
+ render(
1714
+ <Typeahead
1715
+ label="Fruit"
1716
+ searchSource={fruitSource}
1717
+ value={fruits[0]}
1718
+ onChange={vi.fn()}
1719
+ onChangeQuery={onChangeQuery}
1720
+ hasClear
1721
+ />,
1722
+ );
1723
+
1724
+ fireEvent.click(screen.getByRole('button', {name: 'Clear selection'}));
1725
+
1726
+ expect(onChangeQuery).not.toHaveBeenCalled();
1727
+ });
1728
+
1640
1729
  it('restores token on blur without action', async () => {
1641
1730
  const onChange = vi.fn();
1642
1731
  render(
@@ -519,12 +519,25 @@ export function Typeahead<T extends SearchableItem>({
519
519
  [onChange],
520
520
  );
521
521
 
522
- // Handle clear (explicit X button on token)
522
+ // Handle clear (explicit X button on token). Edit mode copied the label into
523
+ // the base's query, and the base only resets that query on a selection, so
524
+ // clearing the value has to empty it the way entering edit mode filled it
525
+ // (#7038): a native set plus a synthetic input event, which reaches the
526
+ // base's own change handler and its onChangeQuery.
523
527
  const handleClear = useCallback(() => {
528
+ const input = inputRef.current;
529
+ if (input && input.value !== '') {
530
+ const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
531
+ window.HTMLInputElement.prototype,
532
+ 'value',
533
+ )?.set;
534
+ nativeInputValueSetter?.call(input, '');
535
+ input.dispatchEvent(new Event('input', {bubbles: true}));
536
+ }
524
537
  setIsEditing(false);
525
538
  setEditingValue(null);
526
539
  onChange(null);
527
- inputRef.current?.focus();
540
+ input?.focus();
528
541
  }, [onChange]);
529
542
 
530
543
  // Handle Escape during edit mode — restore token
@@ -0,0 +1,86 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file DisabledTriggerFocus.a11y.chromium.spec.ts
5
+ * @input The `a11y/Disabled trigger focus` stories in a built Storybook.
6
+ * @output Real-engine proof of `spec:AST-085` FR4 for Selector and
7
+ * ComplexSelector: a trigger disabled while it holds keyboard focus keeps
8
+ * focus and reports itself disabled.
9
+ * @position Run in `pr-a11y`; a DOM emulator does not move focus off an
10
+ * element that becomes disabled, so it cannot show the loss.
11
+ */
12
+
13
+ import {expect, test} from '@playwright/test';
14
+ import {
15
+ DEFAULT_STORYBOOK_DIR,
16
+ serveStorybook,
17
+ type StaticServer,
18
+ } from '@astryxdesign/a11y-spec/storybook';
19
+
20
+ let storybook: StaticServer;
21
+
22
+ test.beforeAll(async () => {
23
+ storybook = await serveStorybook(
24
+ process.env.ASTRYX_STORYBOOK_DIR ?? DEFAULT_STORYBOOK_DIR,
25
+ );
26
+ });
27
+
28
+ test.afterAll(async () => {
29
+ await storybook?.close();
30
+ });
31
+
32
+ const CASES = [
33
+ {
34
+ storyId: 'a11y-disabled-trigger-focus--selector-trigger',
35
+ role: 'combobox',
36
+ name: 'Fruit',
37
+ },
38
+ {
39
+ storyId: 'a11y-disabled-trigger-focus--complex-selector-trigger',
40
+ role: 'button',
41
+ name: 'View options',
42
+ },
43
+ ] as const;
44
+
45
+ for (const {storyId, role, name} of CASES) {
46
+ test(`${name}: a focused trigger keeps focus when it becomes disabled`, async ({
47
+ page,
48
+ }) => {
49
+ await page.goto(
50
+ `${storybook.origin}/iframe.html?id=${storyId}&viewMode=story`,
51
+ );
52
+ const trigger = page.getByRole(role, {name});
53
+ await trigger.focus();
54
+ await expect(trigger).toBeFocused();
55
+
56
+ await page.evaluate(id => {
57
+ const channel = (
58
+ window as unknown as {
59
+ __STORYBOOK_ADDONS_CHANNEL__?: {
60
+ emit: (event: string, payload: unknown) => void;
61
+ };
62
+ }
63
+ ).__STORYBOOK_ADDONS_CHANNEL__;
64
+ if (channel == null) {
65
+ throw new Error('Storybook channel is unavailable');
66
+ }
67
+ channel.emit('updateStoryArgs', {
68
+ storyId: id,
69
+ updatedArgs: {isDisabled: true},
70
+ });
71
+ }, storyId);
72
+
73
+ // Wait for the disabled re-render in either form, so focus is checked
74
+ // after it lands.
75
+ await expect
76
+ .poll(async () =>
77
+ trigger.evaluate(
78
+ el =>
79
+ el.hasAttribute('disabled') ||
80
+ el.getAttribute('aria-disabled') === 'true',
81
+ ),
82
+ )
83
+ .toBe(true);
84
+ await expect(trigger).toBeFocused();
85
+ });
86
+ }
@@ -298,7 +298,22 @@ export function useGridFocus<T extends HTMLElement = HTMLElement>(
298
298
  for (const el of targets) {
299
299
  setTabIndex(el, el === tabbable ? 0 : -1);
300
300
  }
301
- }, [getFocusTargets, setTabIndex]);
301
+ // A cell that stopped being focusable (an aria-disabled day, for example,
302
+ // which stays reachable by focus) can still hold the stop from earlier
303
+ // navigation; it never stays tabbable, so the grid keeps one Tab stop.
304
+ for (const cell of getCells()) {
305
+ const el = cellFocusable(cell) ? null : resolveFocusTarget(cell);
306
+ if (el != null) {
307
+ setTabIndex(el, -1);
308
+ }
309
+ }
310
+ }, [
311
+ getFocusTargets,
312
+ getCells,
313
+ cellFocusable,
314
+ resolveFocusTarget,
315
+ setTabIndex,
316
+ ]);
302
317
 
303
318
  // Keep the tab stop valid across renders (cells added/removed, focusability
304
319
  // toggled). Runs after every commit but only when roving tabindex is on.
@@ -36,7 +36,9 @@
36
36
  *
37
37
  * The menu root is marked `data-astryx-menu-press` so a nested flyout (a DOM
38
38
  * descendant of its parent menu) can tell which level owns a press, and so a
39
- * surface can be recognised as carrying this model.
39
+ * surface can be recognised as carrying this model. A menu the root renders
40
+ * beside itself names the root with `data-astryx-menu-press-surface` and counts
41
+ * as nested in it.
40
42
  *
41
43
  * SYNC: When modified, update:
42
44
  * - /packages/core/src/hooks/index.ts
@@ -60,6 +62,14 @@ import {useLongPress, type UseLongPressHandlers} from './useLongPress';
60
62
  /** Marks a menu root that carries this press model. */
61
63
  export const MENU_PRESS_MARKER = 'data-astryx-menu-press';
62
64
  const MENU_PRESS_ROOT_SELECTOR = `[${MENU_PRESS_MARKER}]`;
65
+ /**
66
+ * Carried by a menu root that another root renders beside itself rather than
67
+ * inside it, naming that root's `id`: the menu of a sub-menu with a header or
68
+ * footer, in a dialog beside the root's menu, is nested in that root for the
69
+ * press model, as a flyout inside the root's DOM is. Nothing else beside the
70
+ * root carries it, so a picker placed in a header or footer stays its own root.
71
+ */
72
+ export const MENU_PRESS_SURFACE_ATTR = 'data-astryx-menu-press-surface';
63
73
 
64
74
  /** How long after a tracked release the browser's click is still swallowed. */
65
75
  export const MENU_PRESS_STRAY_CLICK_MS = 400;
@@ -246,16 +256,44 @@ function isRowFocusable(row: HTMLElement): boolean {
246
256
  function getOutermostRoot(menu: HTMLElement): HTMLElement {
247
257
  let root = menu;
248
258
  for (;;) {
249
- const parent = root.parentElement?.closest<HTMLElement>(
250
- MENU_PRESS_ROOT_SELECTOR,
251
- );
252
- if (parent == null) {
259
+ const parent =
260
+ root.parentElement?.closest<HTMLElement>(MENU_PRESS_ROOT_SELECTOR) ??
261
+ getSurfaceOwner(root);
262
+ if (parent == null || parent === root) {
253
263
  return root;
254
264
  }
255
265
  root = parent;
256
266
  }
257
267
  }
258
268
 
269
+ /**
270
+ * The menu root that renders `menu` beside itself, if any (see
271
+ * `MENU_PRESS_SURFACE_ATTR`). Read off the menu itself, never an ancestor, so
272
+ * a root placed in a region of that surface is not adopted.
273
+ */
274
+ function getSurfaceOwner(menu: HTMLElement): HTMLElement | null {
275
+ const ownerId = menu.getAttribute(MENU_PRESS_SURFACE_ATTR);
276
+ if (!ownerId) {
277
+ return null;
278
+ }
279
+ const owner = menu.ownerDocument.getElementById(ownerId);
280
+ return owner?.matches(MENU_PRESS_ROOT_SELECTOR) === true ? owner : null;
281
+ }
282
+
283
+ /**
284
+ * Whether `element` is inside the menu tree whose outermost root is `root`:
285
+ * inside the root itself, or inside a menu nested in it through a surface
286
+ * the root renders beside itself. Anything else in such a surface (a header
287
+ * field, say) is outside the menu, as the root's own regions are.
288
+ */
289
+ function isInMenuTree(element: Element, root: HTMLElement): boolean {
290
+ if (root.contains(element)) {
291
+ return true;
292
+ }
293
+ const owner = element.closest<HTMLElement>(MENU_PRESS_ROOT_SELECTOR);
294
+ return owner != null && getOutermostRoot(owner) === root;
295
+ }
296
+
259
297
  interface ResolvedPoint {
260
298
  row: HTMLElement | null;
261
299
  isInMenu: boolean;
@@ -332,14 +370,14 @@ export function useMenuPress(options: UseMenuPressOptions): UseMenuPressReturn {
332
370
  }
333
371
  const root = menu == null ? null : getOutermostRoot(menu);
334
372
  const isInMenu =
335
- root != null && element != null && root.contains(element);
373
+ root != null && element != null && isInMenuTree(element, root);
336
374
  const trigger = triggerRef?.current ?? null;
337
375
  const isOnTrigger =
338
376
  trigger != null && element != null && trigger.contains(element);
339
377
  let row: HTMLElement | null = null;
340
378
  if (isInMenu && element != null) {
341
379
  row = element.closest<HTMLElement>(itemSelector);
342
- if (row != null && !root.contains(row)) {
380
+ if (row != null && !isInMenuTree(row, root)) {
343
381
  row = null;
344
382
  }
345
383
  }
@@ -0,0 +1,40 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file disabledInteraction.ts
5
+ * @input An optional caller keydown handler
6
+ * @output Event props that keep a focusable, aria-disabled control inert
7
+ * @position Shared by controls that replace native `disabled` with
8
+ * `aria-disabled` under spec:AST-085 (FR1, FR2)
9
+ */
10
+
11
+ import type {KeyboardEvent, KeyboardEventHandler, MouseEvent} from 'react';
12
+
13
+ /**
14
+ * Event props for a disabled control that stays focusable. Native `disabled`
15
+ * dispatched no click at all; without it, Enter and Space still synthesize a
16
+ * click that native listeners on the element (a wrapping Popover's trigger)
17
+ * receive. Cancelling those keys keeps the click from existing, and the click
18
+ * handler cancels pointer clicks and keeps them from reaching ancestors. Other
19
+ * keys still reach the caller's handler.
20
+ */
21
+ export function disabledInteractionProps<T extends HTMLElement>(
22
+ onKeyDown?: KeyboardEventHandler<T>,
23
+ ): {
24
+ onClick: (event: MouseEvent<T>) => void;
25
+ onKeyDown: (event: KeyboardEvent<T>) => void;
26
+ } {
27
+ return {
28
+ onClick: event => {
29
+ event.preventDefault();
30
+ event.stopPropagation();
31
+ },
32
+ onKeyDown: event => {
33
+ if (event.key === 'Enter' || event.key === ' ') {
34
+ event.preventDefault();
35
+ } else {
36
+ onKeyDown?.(event);
37
+ }
38
+ },
39
+ };
40
+ }
@@ -260,6 +260,17 @@ describe('adjustTime', () => {
260
260
  expect(adjustTime('10:00' as ISOTimeString, NaN)).toBe('10:00');
261
261
  });
262
262
 
263
+ it('returns the input unchanged for a fractional delta', () => {
264
+ // `increment` is documented as minutes and typed `number`, so a caller can
265
+ // hand ArrowUp a fraction. A fractional minute reaching formatISOTime is
266
+ // not rounded: it pads to "30.5" and the field is left holding a string
267
+ // that is no longer a parseable time.
268
+ expect(adjustTime('10:30' as ISOTimeString, 0.5)).toBe('10:30');
269
+ expect(adjustTime('10:30:45' as ISOTimeString, -0.5, true)).toBe(
270
+ '10:30:45',
271
+ );
272
+ });
273
+
263
274
  it('wraps large negative deltas in constant time', () => {
264
275
  expect(adjustTime('10:00' as ISOTimeString, -1440 * 1e7)).toBe('10:00');
265
276
  expect(adjustTime('10:00' as ISOTimeString, -1440 * 1e7 - 30)).toBe(
@@ -372,6 +372,10 @@ export function clampTime(
372
372
 
373
373
  /**
374
374
  * Adjusts a time by adding/subtracting minutes.
375
+ *
376
+ * `deltaMinutes` is a whole number of minutes. A fractional delta leaves the
377
+ * time unchanged rather than returning a value that is no longer a time —
378
+ * `HH:MM` has no place for a fraction of a minute.
375
379
  */
376
380
  export function adjustTime(
377
381
  time: ISOTimeString,
@@ -384,8 +388,11 @@ export function adjustTime(
384
388
  }
385
389
 
386
390
  // A non-finite delta would spin the wrap-around forever (-Infinity) or
387
- // produce "NaN:NaN" (NaN) — return the input unchanged instead.
388
- if (!Number.isFinite(deltaMinutes)) {
391
+ // produce "NaN:NaN" (NaN), and a fractional one is never rounded on the way
392
+ // out — formatISOTime pads "30.5" to two characters and leaves it, so the
393
+ // caller gets back a value that is no longer a time. Steps are whole
394
+ // minutes: return the input unchanged instead.
395
+ if (!Number.isInteger(deltaMinutes)) {
389
396
  return time;
390
397
  }
391
398