@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
@@ -129,7 +129,7 @@ describe('ClickableCard', () => {
129
129
  expect(link).toHaveAttribute('target', '_blank');
130
130
  });
131
131
 
132
- it('disabled button is disabled and the surface runs nothing', () => {
132
+ it('disabled button stays focusable and the surface runs nothing', () => {
133
133
  const handleClick = vi.fn();
134
134
  render(
135
135
  <ClickableCard label="Disabled" onClick={handleClick} isDisabled>
@@ -137,13 +137,14 @@ describe('ClickableCard', () => {
137
137
  </ClickableCard>,
138
138
  );
139
139
  const button = screen.getByRole('button', {name: 'Disabled'});
140
- expect(button).toBeDisabled();
140
+ expect(button).not.toHaveAttribute('disabled');
141
+ expect(button).toHaveAttribute('aria-disabled', 'true');
141
142
  fireEvent.click(screen.getByText('Content'));
142
143
  fireEvent.click(button);
143
144
  expect(handleClick).not.toHaveBeenCalled();
144
145
  });
145
146
 
146
- it('disabled link has no live destination, leaves the tab order, and runs nothing', () => {
147
+ it('disabled link has no live destination, keeps its tab stop, and runs nothing', () => {
147
148
  const handleClick = vi.fn();
148
149
  render(
149
150
  <ClickableCard
@@ -157,7 +158,7 @@ describe('ClickableCard', () => {
157
158
  );
158
159
  const link = screen.getByRole('link', {name: 'Disabled link'});
159
160
  expect(link).toHaveAttribute('aria-disabled', 'true');
160
- expect(link).toHaveAttribute('tabindex', '-1');
161
+ expect(link).toHaveAttribute('tabindex', '0');
161
162
  expect(link).not.toHaveAttribute('href');
162
163
  expect(link).not.toHaveAttribute('target');
163
164
  expect(fireEvent.click(link)).toBe(false);
@@ -51,6 +51,7 @@ import {interactionOverlayStyles} from '../utils/interactionOverlay.stylex';
51
51
  import {usePressFeedback} from '../hooks/usePressFeedback';
52
52
 
53
53
  import {useMergedRefs} from '../hooks/useMergedRefs';
54
+ import {disabledInteractionProps} from '../utils/disabledInteraction';
54
55
  // =============================================================================
55
56
  // Styles — only the interactive layer, Card handles everything else
56
57
  // =============================================================================
@@ -210,8 +211,8 @@ export interface ClickableCardProps extends BaseProps {
210
211
 
211
212
  /**
212
213
  * Set to true to disable the card.
213
- * Disabled action cards use a native disabled button. Disabled link cards
214
- * render no live destination, leave the tab order, and expose aria-disabled.
214
+ * Disabled cards keep their tab stop and expose aria-disabled; activation is
215
+ * blocked, and disabled link cards render no live destination.
215
216
  */
216
217
  isDisabled?: boolean;
217
218
 
@@ -255,10 +256,6 @@ export interface ClickableCardProps extends BaseProps {
255
256
  // Component
256
257
  // =============================================================================
257
258
 
258
- function preventDisabledLinkClick(event: MouseEvent<HTMLAnchorElement>): void {
259
- event.preventDefault();
260
- }
261
-
262
259
  /**
263
260
  * An interactive card that acts as a single navigation or action target.
264
261
  *
@@ -376,11 +373,12 @@ export function ClickableCard({
376
373
  ref={interactiveRef as React.Ref<HTMLAnchorElement>}
377
374
  href={isDisabled ? undefined : href}
378
375
  target={isDisabled ? undefined : target}
379
- onClick={isDisabled ? preventDisabledLinkClick : undefined}
380
376
  role={isDisabled ? 'link' : undefined}
377
+ {...(isDisabled ? disabledInteractionProps() : null)}
381
378
  aria-label={label}
382
379
  aria-disabled={isDisabled || undefined}
383
- tabIndex={isDisabled ? -1 : 0}
380
+ // A disabled card keeps its Tab stop (spec:AST-085 FR1).
381
+ tabIndex={0}
384
382
  {...stylex.props(styles.srOnly)}
385
383
  />
386
384
  ) : (
@@ -388,8 +386,10 @@ export function ClickableCard({
388
386
  ref={interactiveRef as React.Ref<HTMLButtonElement>}
389
387
  type="button"
390
388
  aria-label={label}
391
- disabled={isDisabled}
389
+ aria-disabled={isDisabled || undefined}
392
390
  onClick={onClickProp}
391
+ // Focusable while disabled (FR1), but inert (FR2).
392
+ {...(isDisabled ? disabledInteractionProps() : null)}
393
393
  {...stylex.props(styles.srOnly)}
394
394
  />
395
395
  )}
@@ -0,0 +1,87 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file CodeBlock.highlightStyles.test.tsx
5
+ * @input Uses vitest, @testing-library/react, CodeBlock, a mocked CSS Custom Highlight API
6
+ * @output Tests that the ::highlight() rules exist before a commit's layout effects run
7
+ * @position Performance regression evidence. Rules inserted after layout
8
+ * effects make the browser restyle the whole document a second time once
9
+ * any layout effect has read style, so their insertion point is pinned here.
10
+ */
11
+
12
+ import {useLayoutEffect} from 'react';
13
+ import {cleanup, render} from '@testing-library/react';
14
+ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
15
+
16
+ class MockHighlight extends Set<Range> {}
17
+ const realCss = globalThis.CSS;
18
+
19
+ const STYLE_SELECTOR = 'style[data-astryx-highlight-styles]';
20
+
21
+ beforeEach(() => {
22
+ // A fresh module instance, so the once-per-document insertion flag starts
23
+ // unset in every test.
24
+ vi.resetModules();
25
+ document.head.querySelectorAll(STYLE_SELECTOR).forEach(el => el.remove());
26
+ const highlights = new Map<string, MockHighlight>();
27
+ globalThis.CSS = {
28
+ escape: realCss.escape,
29
+ highlights: {
30
+ get: (name: string) => highlights.get(name),
31
+ set: (name: string, h: MockHighlight) => highlights.set(name, h),
32
+ } as unknown as HighlightRegistry,
33
+ } as typeof CSS;
34
+ vi.stubGlobal('Highlight', MockHighlight);
35
+ });
36
+
37
+ afterEach(() => {
38
+ cleanup();
39
+ globalThis.CSS = realCss;
40
+ vi.unstubAllGlobals();
41
+ });
42
+
43
+ describe('CodeBlock ::highlight() rules', () => {
44
+ it('are in the document before layout effects in the same commit run', async () => {
45
+ const {CodeBlock} = await import('./CodeBlock');
46
+ const seen: boolean[] = [];
47
+ function LayoutReader() {
48
+ useLayoutEffect(() => {
49
+ seen.push(document.head.querySelector(STYLE_SELECTOR) != null);
50
+ }, []);
51
+ return null;
52
+ }
53
+ render(
54
+ <>
55
+ <CodeBlock code="const a = 1;" language="ts" highlightMode="ranges" />
56
+ <LayoutReader />
57
+ </>,
58
+ );
59
+ expect(seen).toEqual([true]);
60
+ });
61
+
62
+ it('are inserted once for many code blocks', async () => {
63
+ const {CodeBlock} = await import('./CodeBlock');
64
+ render(
65
+ <>
66
+ {Array.from({length: 20}, (_, i) => (
67
+ <CodeBlock
68
+ key={i}
69
+ code={`const v${i} = ${i};`}
70
+ language="ts"
71
+ highlightMode="ranges"
72
+ />
73
+ ))}
74
+ </>,
75
+ );
76
+ expect(document.head.querySelectorAll(STYLE_SELECTOR)).toHaveLength(1);
77
+ });
78
+
79
+ it('are not inserted by range highlighting without the Highlight API', async () => {
80
+ vi.stubGlobal('Highlight', undefined);
81
+ const {CodeBlock} = await import('./CodeBlock');
82
+ render(
83
+ <CodeBlock code="const a = 1;" language="ts" highlightMode="ranges" />,
84
+ );
85
+ expect(document.head.querySelector(STYLE_SELECTOR)).toBeNull();
86
+ });
87
+ });
@@ -803,11 +803,21 @@ function RangeCodeContent({
803
803
  }) {
804
804
  const codeRef = useRef<HTMLElement>(null);
805
805
 
806
+ // Insert the ::highlight() rules before the commit's layout reads, as the
807
+ // span path does. Inserted later, the new rules restyle the whole document
808
+ // a second time after any layout read in the same commit has already
809
+ // styled it, which on a long page full of code blocks costs a full style
810
+ // recalculation.
811
+ useInsertionEffect(() => {
812
+ if (hasHighlightAPI()) {
813
+ ensureHighlightStyles();
814
+ }
815
+ }, []);
816
+
806
817
  useEffect(() => {
807
818
  if (!hasHighlightAPI()) {
808
819
  return;
809
820
  }
810
- ensureHighlightStyles();
811
821
 
812
822
  const codeEl = codeRef.current;
813
823
  if (!codeEl || tokenLines.length === 0) {
@@ -0,0 +1,76 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file ComplexSelector.focusableDisabled.test.tsx
5
+ * @input Uses vitest, @testing-library/react, ComplexSelector
6
+ * @output Conformance tests for spec:AST-085 FR1, FR2 and FR4 on ComplexSelector
7
+ * @position Testing; validates the disabled trigger of ComplexSelector.tsx
8
+ */
9
+
10
+ import {describe, it, expect, vi} from 'vitest';
11
+ import {act, render, screen} from '@testing-library/react';
12
+ import userEvent from '@testing-library/user-event';
13
+ import {ComplexSelector} from './ComplexSelector';
14
+
15
+ function Harness(props: {
16
+ isDisabled: boolean;
17
+ onClick?: () => void;
18
+ onKeyDown?: () => void;
19
+ }) {
20
+ return (
21
+ <>
22
+ <button type="button">Before</button>
23
+ <ComplexSelector label="View options" value={[]} {...props}>
24
+ {() => <button type="button">Apply</button>}
25
+ </ComplexSelector>
26
+ <button type="button">After</button>
27
+ </>
28
+ );
29
+ }
30
+
31
+ const trigger = () => screen.getByRole('button', {name: 'View options'});
32
+
33
+ describe('ComplexSelector disabled (spec:AST-085)', () => {
34
+ it('FR1: renders no native disabled and keeps its tab stop', async () => {
35
+ const user = userEvent.setup();
36
+ const {container} = render(<Harness isDisabled />);
37
+ expect(container.querySelector('[disabled]')).toBeNull();
38
+ expect(trigger()).toHaveAttribute('aria-disabled', 'true');
39
+ await user.click(screen.getByRole('button', {name: 'Before'}));
40
+ await user.tab();
41
+ expect(trigger()).toHaveFocus();
42
+ await user.tab();
43
+ expect(screen.getByRole('button', {name: 'After'})).toHaveFocus();
44
+ });
45
+
46
+ it('FR2: no press or key opens it or reaches a caller or ancestor handler', async () => {
47
+ const user = userEvent.setup();
48
+ const onClick = vi.fn();
49
+ const onKeyDown = vi.fn();
50
+ const onAncestorClick = vi.fn();
51
+ render(
52
+ <div onClick={onAncestorClick}>
53
+ <Harness isDisabled onClick={onClick} onKeyDown={onKeyDown} />
54
+ </div>,
55
+ );
56
+ await user.click(trigger());
57
+ await user.click(trigger().parentElement!);
58
+ trigger().focus();
59
+ for (const key of ['{Enter}', ' ', '{ArrowDown}']) {
60
+ await user.keyboard(key);
61
+ }
62
+ expect(trigger()).toHaveAttribute('aria-expanded', 'false');
63
+ expect(screen.queryByRole('dialog')).toBeNull();
64
+ expect(onClick).not.toHaveBeenCalled();
65
+ expect(onKeyDown).not.toHaveBeenCalled();
66
+ expect(onAncestorClick).not.toHaveBeenCalled();
67
+ });
68
+
69
+ it('FR4: keeps focus when it becomes disabled while focused', () => {
70
+ const {rerender} = render(<Harness isDisabled={false} />);
71
+ act(() => trigger().focus());
72
+ rerender(<Harness isDisabled />);
73
+ expect(trigger()).toHaveFocus();
74
+ expect(trigger()).toHaveAttribute('aria-disabled', 'true');
75
+ });
76
+ });
@@ -609,10 +609,24 @@ export function ComplexSelector<Value>({
609
609
  event: React.KeyboardEvent<HTMLElement>,
610
610
  ) => {
611
611
  handleTriggerKeyDown(event);
612
- onKeyDownProp?.(event);
612
+ // A disabled trigger keeps its tab stop but invokes no callback
613
+ // (`spec:AST-085` FR2).
614
+ if (!isDisabled) {
615
+ onKeyDownProp?.(event);
616
+ }
617
+ };
618
+ // A press on a disabled control reaches no handler, the component's or an
619
+ // ancestor's, as with a natively disabled button (`spec:AST-085` FR2).
620
+ const swallowDisabledPress = (event: React.MouseEvent<HTMLElement>) => {
621
+ event.preventDefault();
622
+ event.stopPropagation();
613
623
  };
614
624
  const handleControlBoxClick = useCallback(
615
625
  (event: React.MouseEvent<HTMLDivElement>) => {
626
+ if (isDisabled) {
627
+ event.stopPropagation();
628
+ return;
629
+ }
616
630
  if (triggerRef.current?.contains(event.target as Node)) {
617
631
  return;
618
632
  }
@@ -620,7 +634,7 @@ export function ComplexSelector<Value>({
620
634
  // press on the trigger itself, caller `onClick` included.
621
635
  triggerRef.current?.click();
622
636
  },
623
- [],
637
+ [isDisabled],
624
638
  );
625
639
 
626
640
  useImperativeHandle(
@@ -767,8 +781,11 @@ export function ComplexSelector<Value>({
767
781
  aria-required={isEffectivelyRequired ? 'true' : undefined}
768
782
  aria-invalid={status?.type === 'error' ? 'true' : undefined}
769
783
  aria-busy={isBusy || undefined}
770
- disabled={isDisabled}
771
- onClick={handleTriggerButtonClick}
784
+ // A disabled trigger keeps its tab stop and says so through
785
+ // aria-disabled (`spec:AST-085` FR1). Its press runs neither the
786
+ // caller's handler nor the toggle (FR2).
787
+ aria-disabled={isDisabled ? 'true' : undefined}
788
+ onClick={isDisabled ? swallowDisabledPress : handleTriggerButtonClick}
772
789
  onKeyDown={handleTriggerButtonKeyDown}
773
790
  {...stylex.props(styles.trigger)}>
774
791
  <span {...stylex.props(styles.triggerText)}>{triggerContent}</span>
@@ -24,6 +24,7 @@ import {
24
24
  ContextMenuRadioItem,
25
25
  } from './index';
26
26
  import {DropdownMenuItem} from '../DropdownMenu/DropdownMenuItem';
27
+ import {DropdownMenuSubMenu} from '../DropdownMenu/DropdownMenuSubMenu';
27
28
  import {Divider} from '../Divider';
28
29
  import {useLayerDismissal} from '../Layer/useLayerDismissal';
29
30
  import {warmMenuBottomSheet} from '../DropdownMenu/__tests__/warmMenuBottomSheet';
@@ -706,6 +707,46 @@ describe('ContextMenu dividers', () => {
706
707
  });
707
708
 
708
709
  describe('ContextMenu compound mode', () => {
710
+ it('shows a sub-menu with a header or footer as its rows alone, and says why in development', async () => {
711
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
712
+ try {
713
+ render(
714
+ <ContextMenu
715
+ menuContent={
716
+ <>
717
+ <DropdownMenuItem label="Rename" onClick={() => {}} />
718
+ <DropdownMenuSubMenu
719
+ label="Labels"
720
+ header={<input aria-label="Filter labels" />}
721
+ footer={<button type="button">Create label</button>}>
722
+ <DropdownMenuItem label="Bug" onClick={() => {}} />
723
+ </DropdownMenuSubMenu>
724
+ </>
725
+ }>
726
+ <div>Right-click me</div>
727
+ </ContextMenu>,
728
+ );
729
+ fireEvent.contextMenu(screen.getByText('Right-click me'));
730
+ const row = screen.getByRole('menuitem', {name: /Labels/, hidden: true});
731
+ // No region can sit outside this menu, so none renders inside it.
732
+ expect(row).toHaveAttribute('aria-haspopup', 'menu');
733
+ fireEvent.click(row, {detail: 1});
734
+ await waitFor(() => expect(row).toHaveAttribute('aria-expanded', 'true'));
735
+ expect(screen.queryByRole('dialog', {hidden: true})).toBeNull();
736
+ expect(
737
+ screen.queryByRole('textbox', {name: 'Filter labels', hidden: true}),
738
+ ).toBeNull();
739
+ expect(
740
+ screen.getByRole('menuitem', {name: 'Bug', hidden: true}),
741
+ ).toBeInTheDocument();
742
+ expect(warn).toHaveBeenCalledWith(
743
+ expect.stringContaining('DropdownMenuSubMenu: header and footer'),
744
+ );
745
+ } finally {
746
+ warn.mockRestore();
747
+ }
748
+ });
749
+
709
750
  it('renders menuContent as menu items', () => {
710
751
  render(
711
752
  <ContextMenu
@@ -49,14 +49,15 @@ export const docs = {
49
49
  {
50
50
  name: 'isDisabled',
51
51
  type: 'boolean',
52
- description: 'Disable the input and calendar.',
52
+ description:
53
+ 'Disable the input and calendar. The field stays focusable and exposes `aria-disabled`; typing and opening are blocked.',
53
54
  default: 'false',
54
55
  },
55
56
  {
56
57
  name: 'disabledMessage',
57
58
  type: 'string',
58
59
  description:
59
- 'Explains why the input is disabled. With isDisabled, shows a tooltip on hover/keyboard focus and keeps the field focusable via aria-disabled (activation stays blocked). Use this instead of wrapping a disabled DateInput in Tooltip. Disabled controls swallow the hover events an external Tooltip needs.',
60
+ 'Explains why the input is disabled. With isDisabled, shows a tooltip on hover/keyboard focus (activation stays blocked). Use this instead of wrapping a disabled DateInput in Tooltip.',
60
61
  },
61
62
  {
62
63
  name: 'value',
@@ -275,7 +276,7 @@ export const docs = {
275
276
  {
276
277
  guidance: false,
277
278
  description:
278
- 'Wrap a disabled DateInput in Tooltip to explain why it is disabled; disabled triggers swallow the hover events the wrapper needs. Use the disabledMessage prop instead.',
279
+ 'Wrap a disabled DateInput in Tooltip to explain why it is disabled. Use the disabledMessage prop instead, which ties the reason to the field.',
279
280
  },
280
281
  ],
281
282
  anatomy: [
@@ -368,7 +369,7 @@ export const docsZh = {
368
369
  {
369
370
  guidance: false,
370
371
  description:
371
- 'Wrap a disabled DateInput in Tooltip to explain why it is disabled; disabled triggers swallow the hover events the wrapper needs. Use the disabledMessage prop instead.',
372
+ 'Wrap a disabled DateInput in Tooltip to explain why it is disabled. Use the disabledMessage prop instead, which ties the reason to the field.',
372
373
  },
373
374
  ],
374
375
  },
@@ -400,14 +401,14 @@ export const docsZh = {
400
401
  {
401
402
  name: 'isDisabled',
402
403
  type: 'boolean',
403
- description: '禁用输入框和日历。',
404
+ description: '禁用输入框和日历。输入框仍可聚焦并暴露 `aria-disabled`,但不能输入或打开日历。',
404
405
  default: 'false',
405
406
  },
406
407
  {
407
408
  name: 'disabledMessage',
408
409
  type: 'string',
409
410
  description:
410
- '说明输入框为何被禁用。与 isDisabled 一起使用时,在悬停/键盘聚焦时显示提示,并通过 aria-disabled 保持输入框可聚焦(仍阻止输入和激活)。请使用此属性,而不是用 Tooltip 包裹已禁用的 DateInput。',
411
+ '说明输入框为何被禁用。与 isDisabled 一起使用时,在悬停/键盘聚焦时显示提示(仍阻止输入和激活)。请使用此属性,而不是用 Tooltip 包裹已禁用的 DateInput。',
411
412
  },
412
413
  {
413
414
  name: 'value',
@@ -621,7 +622,7 @@ export const docsDense = {
621
622
  {
622
623
  guidance: false,
623
624
  description:
624
- 'Wrap a disabled DateInput in Tooltip to explain why it is disabled; disabled triggers swallow the hover events the wrapper needs. Use the disabledMessage prop instead.',
625
+ 'Wrap a disabled DateInput in Tooltip to explain why it is disabled. Use the disabledMessage prop instead, which ties the reason to the field.',
625
626
  },
626
627
  ],
627
628
  },
@@ -633,7 +634,7 @@ export const docsDense = {
633
634
  isRequired: 'mark field required',
634
635
  isDisabled: 'disable input+calendar',
635
636
  disabledMessage:
636
- 'reason shown in a tooltip on hover/focus when disabled; keeps field focusable via aria-disabled',
637
+ "reason shown in a tooltip on hover/focus when disabled; describes the field's focusable controls",
637
638
  value: 'selected date YYYY-MM-DD',
638
639
  onChange: 'callback on date change',
639
640
  changeAction: 'async action after onChange; drives optimistic UI',
@@ -84,9 +84,14 @@ describe('DateInput', () => {
84
84
  expect(screen.getByRole('combobox')).not.toHaveAttribute('aria-required');
85
85
  });
86
86
 
87
- it('sets disabled attribute when isDisabled is true', () => {
87
+ it('keeps a disabled input focusable without native disabled (AST-085 FR1)', () => {
88
88
  render(<DateInput label="Date" isDisabled onChange={() => {}} />);
89
- expect(screen.getByRole('combobox')).toBeDisabled();
89
+ const input = screen.getByRole('combobox');
90
+ expect(input).not.toHaveAttribute('disabled');
91
+ expect(input).toHaveAttribute('aria-disabled', 'true');
92
+ expect(input).toHaveAttribute('readonly');
93
+ input.focus();
94
+ expect(input).toHaveFocus();
90
95
  });
91
96
 
92
97
  it('is not disabled by default', () => {
@@ -115,10 +120,16 @@ describe('DateInput', () => {
115
120
  expect(button).not.toBeDisabled();
116
121
  });
117
122
 
118
- it('calendar button is disabled when isDisabled is true', () => {
123
+ it('calendar button is focusable-disabled when isDisabled is true', () => {
119
124
  render(<DateInput label="Date" isDisabled onChange={() => {}} />);
120
125
  const button = getButton('Open calendar');
121
- expect(button).toBeDisabled();
126
+ expect(button).not.toHaveAttribute('disabled');
127
+ expect(button).toHaveAttribute('aria-disabled', 'true');
128
+ fireEvent.click(button);
129
+ expect(screen.getByRole('combobox')).toHaveAttribute(
130
+ 'aria-expanded',
131
+ 'false',
132
+ );
122
133
  });
123
134
 
124
135
  it('does not call onChange while typing invalid input', async () => {
@@ -349,10 +360,15 @@ describe('DateInput', () => {
349
360
 
350
361
  // --- P1: Input disabled during isBusy (isLoading) ---
351
362
 
352
- it('disables input and button when isLoading is true', () => {
363
+ it('blocks input and button without native disabled when isLoading is true', () => {
353
364
  render(<DateInput label="Date" isLoading onChange={() => {}} />);
354
- expect(screen.getByRole('combobox')).toBeDisabled();
355
- expect(getButton('Open calendar')).toBeDisabled();
365
+ expect(screen.getByRole('combobox')).not.toHaveAttribute('disabled');
366
+ expect(screen.getByRole('combobox')).toHaveAttribute(
367
+ 'aria-disabled',
368
+ 'true',
369
+ );
370
+ expect(getButton('Open calendar')).not.toHaveAttribute('disabled');
371
+ expect(getButton('Open calendar')).toHaveAttribute('aria-disabled', 'true');
356
372
  });
357
373
 
358
374
  it('shows spinner when isLoading is true', () => {
@@ -526,7 +542,8 @@ describe('DateInput', () => {
526
542
  render(<DateInput label="Date" isDisabled onChange={() => {}} />);
527
543
 
528
544
  const input = screen.getByRole('combobox');
529
- expect(input).toBeDisabled();
545
+ expect(input).toHaveAttribute('aria-disabled', 'true');
546
+ expect(input).not.toHaveAttribute('disabled');
530
547
  });
531
548
 
532
549
  // --- P1: aria-busy on input ---
@@ -818,7 +835,10 @@ describe('DateInput', () => {
818
835
  expect(input).toHaveAttribute('aria-disabled', 'true');
819
836
  expect(input.getAttribute('aria-describedby')).toContain(tooltip.id);
820
837
  expect(tooltip).toHaveTextContent('Scheduling is locked');
821
- expect(getButton('Open calendar')).toBeDisabled();
838
+ expect(getButton('Open calendar')).toHaveAttribute(
839
+ 'aria-disabled',
840
+ 'true',
841
+ );
822
842
  });
823
843
  });
824
844
 
@@ -875,6 +895,10 @@ describe('DateInput', () => {
875
895
  );
876
896
 
877
897
  const tooltip = screen.getByRole('tooltip', h);
898
+ // The calendar button keeps its enabled tab stop (spec:AST-085 FR1),
899
+ // so the input is the second stop.
900
+ await user.tab();
901
+ expect(getButton('Open calendar')).toHaveFocus();
878
902
  await user.tab();
879
903
  expect(screen.getByRole('combobox')).toHaveFocus();
880
904
  await waitFor(() => {
@@ -908,6 +932,50 @@ describe('DateInput', () => {
908
932
  expect(input).toHaveAttribute('readonly');
909
933
  });
910
934
 
935
+ it('does not call a caller onClick while disabled (AST-085 FR2)', () => {
936
+ const onClick = vi.fn();
937
+ render(<DateInput label="Date" isDisabled onClick={onClick} />);
938
+ fireEvent.click(screen.getByRole('combobox'));
939
+ expect(onClick).not.toHaveBeenCalled();
940
+ });
941
+
942
+ it('links the reason tooltip from the calendar button too (AST-085 FR5)', () => {
943
+ render(
944
+ <DateInput
945
+ label="Date"
946
+ isDisabled
947
+ disabledMessage="You need the Editor role"
948
+ />,
949
+ );
950
+ const tooltip = screen.getByRole('tooltip', h);
951
+ expect(
952
+ getButton('Open calendar').getAttribute('aria-describedby'),
953
+ ).toContain(tooltip.id);
954
+ });
955
+
956
+ it('drops a draft instead of committing it when the field becomes disabled (AST-085 FR2)', async () => {
957
+ const user = userEvent.setup();
958
+ const onChange = vi.fn();
959
+ const {rerender} = render(
960
+ <DateInput label="Date" value="2026-03-10" onChange={onChange} />,
961
+ );
962
+ const input = screen.getByRole('combobox');
963
+ await user.click(input);
964
+ await user.clear(input);
965
+ rerender(
966
+ <DateInput
967
+ label="Date"
968
+ value="2026-03-10"
969
+ onChange={onChange}
970
+ isDisabled
971
+ />,
972
+ );
973
+ expect(input).toHaveFocus();
974
+ await user.keyboard('{Enter}');
975
+ await user.tab();
976
+ expect(onChange).not.toHaveBeenCalled();
977
+ });
978
+
911
979
  it('links the reason tooltip from the input via aria-describedby', () => {
912
980
  render(
913
981
  <DateInput
@@ -941,11 +1009,18 @@ describe('DateInput', () => {
941
1009
  expect(onChange).not.toHaveBeenCalled();
942
1010
  });
943
1011
 
944
- it('remains natively disabled when disabled without a reason', () => {
945
- render(<DateInput label="Date" isDisabled />);
1012
+ it('blocks value changes and opening when disabled without a reason (AST-085 FR1, FR2)', async () => {
1013
+ const user = userEvent.setup();
1014
+ const onChange = vi.fn();
1015
+ render(<DateInput label="Date" onChange={onChange} isDisabled />);
946
1016
  const input = screen.getByRole('combobox');
947
- expect(input).toBeDisabled();
948
- expect(input).not.toHaveAttribute('aria-disabled');
1017
+ expect(input).not.toHaveAttribute('disabled');
1018
+ await user.click(input);
1019
+ await user.type(input, '2026-03-15');
1020
+ await user.keyboard('{ArrowDown}');
1021
+ expect(input).toHaveValue('');
1022
+ expect(input).toHaveAttribute('aria-expanded', 'false');
1023
+ expect(onChange).not.toHaveBeenCalled();
949
1024
  });
950
1025
  });
951
1026