@astryxdesign/core 0.7.2 → 0.7.3-canary.02f5fe4

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 (237) 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/DropdownMenuItem.doc.mjs +66 -7
  159. package/src/DropdownMenu/DropdownMenuItem.spec.md +209 -0
  160. package/src/DropdownMenu/DropdownMenuSubMenu.doc.mjs +16 -1
  161. package/src/DropdownMenu/DropdownMenuSubMenu.test.tsx +623 -1
  162. package/src/DropdownMenu/DropdownMenuSubMenu.tsx +556 -99
  163. package/src/DropdownMenu/__tests__/DropdownMenuItemAudit.a11y.chromium.spec.ts +366 -0
  164. package/src/DropdownMenu/__tests__/SubMenuHeaderFooter.a11y.chromium.spec.ts +267 -0
  165. package/src/DropdownMenu/menuRegionStops.ts +2 -2
  166. package/src/DropdownMenu/modules/useMenuPress.spec.md +9 -0
  167. package/src/FieldStatus/FieldStatus.spec.md +3 -2
  168. package/src/FileInput/FileInput.test.tsx +30 -7
  169. package/src/FileInput/FileInput.tsx +21 -11
  170. package/src/Link/Link.test.tsx +21 -7
  171. package/src/Link/Link.tsx +16 -24
  172. package/src/Link/__tests__/Link.a11y.states.ts +1 -1
  173. package/src/Markdown/Markdown.rerender.test.tsx +143 -0
  174. package/src/Markdown/Markdown.tsx +89 -35
  175. package/src/MultiSelector/MultiSelector.disabledFocus.a11y.chromium.spec.ts +71 -0
  176. package/src/MultiSelector/MultiSelector.doc.mjs +5 -5
  177. package/src/MultiSelector/MultiSelector.focusableDisabled.test.tsx +121 -0
  178. package/src/MultiSelector/MultiSelector.test.tsx +49 -7
  179. package/src/MultiSelector/MultiSelector.tsx +53 -29
  180. package/src/Pagination/Pagination.test.tsx +5 -1
  181. package/src/Pagination/Pagination.tsx +3 -1
  182. package/src/Popover/Popover.test.tsx +15 -0
  183. package/src/Popover/Popover.tsx +19 -8
  184. package/src/PowerSearch/PowerSearchFilterEditor.test.tsx +3 -2
  185. package/src/PowerSearch/PowerSearchToken.test.tsx +7 -5
  186. package/src/Selector/Selector.doc.mjs +4 -4
  187. package/src/Selector/Selector.focusableDisabled.test.tsx +127 -0
  188. package/src/Selector/Selector.spec.md +11 -11
  189. package/src/Selector/Selector.test.tsx +64 -6
  190. package/src/Selector/Selector.tsx +51 -21
  191. package/src/Selector/SelectorOption.tsx +4 -0
  192. package/src/SideNav/SideNav.test.tsx +35 -0
  193. package/src/Slider/Slider.spec.md +11 -11
  194. package/src/Slider/Slider.test.tsx +3 -3
  195. package/src/Slider/Slider.tsx +8 -15
  196. package/src/Switch/Switch.test.tsx +85 -8
  197. package/src/Switch/Switch.tsx +26 -16
  198. package/src/Switch/__tests__/Switch.a11y.states.ts +2 -2
  199. package/src/Switch/switch.markers.stylex.ts +4 -0
  200. package/src/Table/Table.spec.md +2 -0
  201. package/src/Table/plugins/infiniteScroll/useTableInfiniteScroll.spec.md +244 -0
  202. package/src/Table/plugins/selection/useTableSelection.test.tsx +5 -3
  203. package/src/Table/plugins/virtualization/useTableVirtualization.spec.md +266 -0
  204. package/src/TimeInput/NativeTimeInput.test.tsx +25 -1
  205. package/src/TimeInput/Presentation.test.tsx +23 -0
  206. package/src/TimeInput/TimeInput.doc.mjs +13 -10
  207. package/src/TimeInput/TimeInput.test.tsx +139 -7
  208. package/src/TimeInput/TimeInput.tsx +53 -24
  209. package/src/TimeInput/TouchTimeField.tsx +27 -5
  210. package/src/Timestamp/Timestamp.doc.mjs +1 -1
  211. package/src/Timestamp/Timestamp.test.tsx +168 -0
  212. package/src/Timestamp/Timestamp.tsx +62 -11
  213. package/src/Token/Token.test.tsx +26 -4
  214. package/src/Token/Token.tsx +6 -2
  215. package/src/Tokenizer/Tokenizer.spec.md +28 -28
  216. package/src/Tooltip/Tooltip.test.tsx +51 -6
  217. package/src/Tooltip/Tooltip.tsx +41 -18
  218. package/src/Tooltip/__tests__/Tooltip.a11y.chromium.spec.ts +129 -0
  219. package/src/Tooltip/__tests__/Tooltip.a11y.renders.tsx +86 -0
  220. package/src/Tooltip/__tests__/Tooltip.a11y.states.ts +132 -0
  221. package/src/Tooltip/__tests__/Tooltip.a11y.test.tsx +126 -0
  222. package/src/Tooltip/__tests__/TooltipHoverBridge.a11y.chromium.spec.ts +74 -0
  223. package/src/Tooltip/useTooltip.test.tsx +23 -0
  224. package/src/Tooltip/useTooltip.tsx +22 -9
  225. package/src/TopNav/TopNav.tsx +11 -1
  226. package/src/TopNav/TopNavHeading.tsx +1 -0
  227. package/src/TopNav/__tests__/TopNavMobileLongHeading.a11y.chromium.spec.ts +74 -0
  228. package/src/TreeList/TreeList.test.tsx +28 -0
  229. package/src/TreeList/TreeListItem.tsx +6 -2
  230. package/src/Typeahead/Typeahead.test.tsx +89 -0
  231. package/src/Typeahead/Typeahead.tsx +15 -2
  232. package/src/__tests__/DisabledTriggerFocus.a11y.chromium.spec.ts +86 -0
  233. package/src/hooks/useGridFocus.ts +16 -1
  234. package/src/hooks/useMenuPress.ts +45 -7
  235. package/src/utils/disabledInteraction.ts +40 -0
  236. package/src/utils/timeParser.test.ts +11 -0
  237. package/src/utils/timeParser.ts +9 -2
@@ -278,7 +278,7 @@ describe('CheckboxInput', () => {
278
278
  );
279
279
  const checkbox = screen.getByRole('checkbox');
280
280
  expect(checkbox).toBeChecked();
281
- expect(checkbox).toBeDisabled();
281
+ expect(checkbox).toHaveAttribute('aria-disabled', 'true');
282
282
  expect(checkbox).toHaveAttribute('type', 'checkbox');
283
283
  });
284
284
 
@@ -588,16 +588,55 @@ describe('CheckboxInput', () => {
588
588
  expect(checkbox).not.toBeChecked();
589
589
  });
590
590
 
591
- it('remains natively disabled when disabled without a reason', () => {
591
+ it('stays focusable via aria-disabled when disabled without a reason', async () => {
592
+ const user = userEvent.setup();
593
+ const onChange = vi.fn();
594
+ const onClick = vi.fn();
595
+ render(
596
+ <form>
597
+ <CheckboxInput
598
+ label="Accept terms"
599
+ value={false}
600
+ onChange={onChange}
601
+ onClick={onClick}
602
+ htmlName="terms"
603
+ isRequired
604
+ isDisabled
605
+ />
606
+ </form>,
607
+ );
608
+ const checkbox = screen.getByRole('checkbox');
609
+ expect(checkbox).not.toHaveAttribute('disabled');
610
+ expect(checkbox).toHaveAttribute('aria-disabled', 'true');
611
+ await user.tab();
612
+ expect(checkbox).toHaveFocus();
613
+ await user.keyboard(' ');
614
+ await user.click(checkbox);
615
+ expect(onChange).not.toHaveBeenCalled();
616
+ expect(onClick).not.toHaveBeenCalled();
617
+ expect(checkbox).not.toBeChecked();
618
+ const form = checkbox.closest('form')!;
619
+ expect(form.checkValidity()).toBe(true);
620
+ expect([...new FormData(form).keys()]).toEqual([]);
621
+ });
622
+
623
+ it('keeps a disabled indeterminate checkbox mixed after Space or a click', async () => {
624
+ const user = userEvent.setup();
592
625
  render(
593
626
  <CheckboxInput
594
- label="Accept terms"
595
- value={false}
627
+ label="Select all"
628
+ value="indeterminate"
596
629
  onChange={() => {}}
597
630
  isDisabled
598
631
  />,
599
632
  );
600
- expect(screen.getByRole('checkbox')).toBeDisabled();
633
+ const checkbox = screen.getByRole<HTMLInputElement>('checkbox');
634
+ expect(checkbox.indeterminate).toBe(true);
635
+ await user.tab();
636
+ await user.keyboard(' ');
637
+ fireEvent.click(checkbox);
638
+ expect(checkbox.indeterminate).toBe(true);
639
+ expect(checkbox).not.toBeChecked();
601
640
  });
602
641
  });
603
642
  describe('form participation', () => {
@@ -19,7 +19,6 @@
19
19
  import {
20
20
  useId,
21
21
  useCallback,
22
- use,
23
22
  useOptimistic,
24
23
  useRef,
25
24
  useTransition,
@@ -52,7 +51,6 @@ import {usePressFeedback} from '../hooks/usePressFeedback';
52
51
  import {useResolvedRequired} from '../hooks/useResolvedRequired';
53
52
  import {useIndicator} from '../Indicator';
54
53
  import {themeProps} from '../utils/themeProps';
55
- import {CheckboxListContext} from '../CheckboxList/CheckboxListContext';
56
54
 
57
55
  import {useMergedRefs} from '../hooks/useMergedRefs';
58
56
 
@@ -354,21 +352,10 @@ export function CheckboxInput({
354
352
  const [optimisticValue, setOptimisticValue] = useOptimistic(value);
355
353
  const isBusy = isLoading || optimisticValue !== value;
356
354
 
357
- // Disabled-reason tooltip. Disabled controls swallow pointer events, so the
358
- // tooltip listeners attach to the checkbox row (which already exists) and the
359
- // native checkbox stays perceivable via aria-disabled instead of the disabled
360
- // attribute. Value mutation is blocked by the isDisabled guard in onChange.
355
+ // A disabled checkbox stays focusable via aria-disabled instead of the
356
+ // native disabled attribute (spec:AST-085 FR1). Value mutation is blocked by
357
+ // the click and onChange guards on the input.
361
358
  const showsDisabledMessage = isDisabled && !!disabledMessage;
362
- // Keep the native checkbox focusable via aria-disabled either when it renders
363
- // its own reason tooltip, or when it sits in a CheckboxList whose whole-group
364
- // `disabledMessage` (shown on the group container) needs each checkbox to
365
- // stay keyboard-perceivable. The group signals this through context rather
366
- // than a public prop.
367
- const checkboxListContext = use(CheckboxListContext);
368
- const isFocusableDisabled =
369
- isDisabled &&
370
- (showsDisabledMessage ||
371
- (checkboxListContext?.hasDisabledMessage ?? false));
372
359
  const disabledMessageTooltip = useTooltip({
373
360
  placement: 'above',
374
361
  // The container row is not naturally focusable; focusin bubbles up from the
@@ -384,7 +371,7 @@ export function CheckboxInput({
384
371
  // `opacity: 0`, and only the indicator's own element can shape the outline
385
372
  // to match it. See useIndicatorFocusRing.
386
373
  const indicatorRef = useRef<HTMLSpanElement>(null);
387
- const {focusProps} = useIndicatorFocusRing(indicatorRef, isDisabled);
374
+ const {focusProps} = useIndicatorFocusRing(indicatorRef);
388
375
 
389
376
  const isIndeterminate = optimisticValue === 'indeterminate';
390
377
  const isChecked = optimisticValue === true;
@@ -471,22 +458,25 @@ export function CheckboxInput({
471
458
  )}
472
459
  id={id}
473
460
  type="checkbox"
474
- // Withhold the name while disabled: with a disabledMessage the
475
- // input stays focusable (not natively disabled), and a disabled
476
- // control must not submit.
461
+ // Withhold the name while disabled: the input stays focusable (not
462
+ // natively disabled), and a disabled control must not submit.
477
463
  name={isDisabled ? undefined : htmlName}
478
464
  checked={isChecked}
479
- // With a disabledMessage the checkbox keeps focusability via
480
- // aria-disabled so the reason is focus-discoverable; toggling is
481
- // still blocked by the isDisabled guard in onChange below.
482
- disabled={isDisabled && !isFocusableDisabled}
483
- aria-disabled={isFocusableDisabled ? 'true' : undefined}
484
- form={isFocusableDisabled ? '' : undefined}
465
+ aria-disabled={isDisabled ? 'true' : undefined}
466
+ // Detached from its form while disabled, so it neither submits nor
467
+ // blocks submission through `required` (spec:AST-085 FR3).
468
+ form={isDisabled ? '' : undefined}
469
+ // Native `disabled` suppressed onClick (FR2); preventDefault here
470
+ // would desync React's controlled checked state.
471
+ onClick={isDisabled ? undefined : rest.onClick}
485
472
  readOnly={isReadOnly}
486
473
  required={isRequired}
487
474
  aria-required={isEffectivelyRequired ? 'true' : undefined}
488
475
  onChange={e => {
489
476
  if (isDisabled || isBusy || isReadOnly) {
477
+ // Activation clears the native mixed state; React restores
478
+ // only `checked`, so restore `indeterminate` too.
479
+ e.target.indeterminate = isIndeterminate;
490
480
  return;
491
481
  }
492
482
  const checked = e.target.checked;
@@ -125,8 +125,8 @@ export const CHECKBOX_BINDING_STATES = [
125
125
  {
126
126
  id: 'input-disabled',
127
127
  binding: 'CheckboxInput',
128
- summary: 'a natively disabled checkbox outside the tab sequence',
129
- facts: facts({operable: false, focusable: false, disabled: true}),
128
+ summary: 'a disabled checkbox that keeps its tab stop',
129
+ facts: facts({operable: false, focusable: true, disabled: true}),
130
130
  visibleLabel: 'Managed setting',
131
131
  storyId: 'a11y-checkbox-pattern--input-disabled',
132
132
  },
@@ -269,7 +269,7 @@ export const CHECKBOX_BINDING_STATES = [
269
269
  id: 'list-item-disabled',
270
270
  binding: 'CheckboxListItem',
271
271
  summary: 'an individually disabled list item',
272
- facts: facts({operable: false, focusable: false, disabled: true}),
272
+ facts: facts({operable: false, focusable: true, disabled: true}),
273
273
  visibleLabel: 'SMS',
274
274
  visibleLabelSelector: '[data-a11y-visible-label]',
275
275
  storyId: 'a11y-checkbox-pattern--list-item-disabled',
@@ -288,8 +288,9 @@ describe('CheckboxList', () => {
288
288
  </CheckboxList>,
289
289
  );
290
290
  const checkboxes = screen.getAllByRole('checkbox');
291
- expect(checkboxes[0]).toBeDisabled();
292
- expect(checkboxes[1]).toBeDisabled();
291
+ expect(checkboxes[0]).toHaveAttribute('aria-disabled', 'true');
292
+ expect(checkboxes[1]).toHaveAttribute('aria-disabled', 'true');
293
+ expect(checkboxes[0]).not.toHaveAttribute('disabled');
293
294
  });
294
295
 
295
296
  it('does not call onChange when group is disabled', () => {
@@ -317,8 +318,9 @@ describe('CheckboxList', () => {
317
318
  </CheckboxList>,
318
319
  );
319
320
  const checkboxes = screen.getAllByRole('checkbox');
320
- expect(checkboxes[0]).not.toBeDisabled();
321
- expect(checkboxes[1]).toBeDisabled();
321
+ expect(checkboxes[0]).not.toHaveAttribute('aria-disabled');
322
+ expect(checkboxes[1]).toHaveAttribute('aria-disabled', 'true');
323
+ expect(checkboxes[1]).not.toHaveAttribute('disabled');
322
324
  });
323
325
 
324
326
  it('throws when item has no value prop inside collection-mode CheckboxList', () => {
@@ -1137,7 +1139,7 @@ describe('CheckboxListItem ARIA props', () => {
1137
1139
  expect(onChange).not.toHaveBeenCalled();
1138
1140
  });
1139
1141
 
1140
- it('keeps checkboxes natively disabled when disabled without a reason', () => {
1142
+ it('keeps checkboxes focusable via aria-disabled when disabled without a reason', () => {
1141
1143
  render(
1142
1144
  <CheckboxList
1143
1145
  label="Notifications"
@@ -1149,7 +1151,8 @@ describe('CheckboxListItem ARIA props', () => {
1149
1151
  </CheckboxList>,
1150
1152
  );
1151
1153
  for (const checkbox of screen.getAllByRole('checkbox', h)) {
1152
- expect(checkbox).toBeDisabled();
1154
+ expect(checkbox).not.toHaveAttribute('disabled');
1155
+ expect(checkbox).toHaveAttribute('aria-disabled', 'true');
1153
1156
  }
1154
1157
  });
1155
1158
  });
@@ -24,7 +24,7 @@ export const docs = {
24
24
  {name: 'onClick', type: '(event: MouseEvent) => void', description: 'Click handler: fires on card surface only.'},
25
25
  {name: 'href', type: 'string', description: 'Navigation URL. Plain, new-tab, Cmd/Ctrl-click, and middle-click activation all follow the shared navigation rule described on the Link `href` prop.'},
26
26
  {name: 'target', type: 'string', description: 'Link target.', default: "'_self'"},
27
- {name: 'isDisabled', type: 'boolean', description: 'Disables the card, removes a link destination, and leaves the card out of the tab order.', default: 'false'},
27
+ {name: 'isDisabled', type: 'boolean', description: 'Disables the card and removes a link destination; the card keeps its tab stop and is announced as disabled.', default: 'false'},
28
28
  {name: 'children', type: 'ReactNode', description: 'Card content.'},
29
29
  {name: 'padding', type: '0 | 0.5 | 1 | 1.5 | 2 | 3 | 4 | 5 | 6 | 8 | 10', description: 'Inner padding.', default: '4'},
30
30
  {name: 'variant', type: "'default' | 'transparent' | 'muted' | 'blue' | 'cyan' | 'gray' | 'green' | 'orange' | 'pink' | 'purple' | 'red' | 'teal' | 'yellow'", description: 'Background color variant.', default: "'default'"},
@@ -75,7 +75,7 @@ export const docsDense = {
75
75
  onClick: 'click handler: fires on card surface only',
76
76
  href: 'navigation URL; every activation follows the shared navigation rule (see Link href)',
77
77
  target: 'link target',
78
- isDisabled: 'disables card, removes link destination, and leaves tab order',
78
+ isDisabled: 'disables card, removes link destination, keeps tab stop',
79
79
  padding: 'inner padding',
80
80
  variant: 'background color variant',
81
81
  elevation: 'resting shadow depth: none|low|med|high; often raised to signal clickability',
@@ -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