@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
@@ -22,6 +22,7 @@
22
22
  * SYNC: When Switch.tsx changes, update tests to match new behavior
23
23
  */
24
24
 
25
+ import {StrictMode} from 'react';
25
26
  import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest';
26
27
  import {render, screen, fireEvent, waitFor} from '@testing-library/react';
27
28
  import userEvent from '@testing-library/user-event';
@@ -417,6 +418,61 @@ describe('Switch', () => {
417
418
  });
418
419
  });
419
420
 
421
+ it('announces one busy start once under StrictMode, from the switch alone', async () => {
422
+ // Count every message the shared polite region is given. Each announce
423
+ // writes its text in a later frame, so two announces add it twice.
424
+ const written: string[] = [];
425
+ const observer = new MutationObserver(records => {
426
+ for (const record of records) {
427
+ const target = record.target as Element;
428
+ if (target.getAttribute?.('data-astryx-live-region') !== 'polite') {
429
+ continue;
430
+ }
431
+ for (const node of record.addedNodes) {
432
+ if (node.textContent) {written.push(node.textContent);}
433
+ }
434
+ }
435
+ });
436
+ observer.observe(document.body, {childList: true, subtree: true});
437
+
438
+ const {container} = render(
439
+ <StrictMode>
440
+ <Switch
441
+ label="Sync photos"
442
+ value={false}
443
+ onChange={() => {}}
444
+ isLoading
445
+ />
446
+ </StrictMode>,
447
+ );
448
+ await waitFor(() => {
449
+ expect(written).toContain('Loading');
450
+ });
451
+ await new Promise(resolve => setTimeout(resolve, 50));
452
+ observer.disconnect();
453
+
454
+ expect(written.filter(text => text === 'Loading')).toHaveLength(1);
455
+ // The thumb's spinner is a silent indicator: no live role reaches
456
+ // assistive technology from inside the switch.
457
+ for (const status of container.querySelectorAll('[role="status"]')) {
458
+ expect(status.closest('[aria-hidden="true"]')).not.toBeNull();
459
+ }
460
+ expect(container.querySelector('[aria-live]')).toBeNull();
461
+ });
462
+
463
+ it('announces again when the switch becomes busy again after settling', async () => {
464
+ const polite = () =>
465
+ document.querySelector('[data-astryx-live-region="polite"]');
466
+ const props = {label: 'Sync photos', value: false, onChange: () => {}};
467
+ const {rerender} = render(<Switch {...props} isLoading />);
468
+ await waitFor(() => expect(polite()).toHaveTextContent('Loading'));
469
+
470
+ rerender(<Switch {...props} />);
471
+ polite()!.textContent = '';
472
+ rerender(<Switch {...props} isLoading />);
473
+ await waitFor(() => expect(polite()).toHaveTextContent('Loading'));
474
+ });
475
+
420
476
  it('localizes the loading announcement through the i18n catalog', async () => {
421
477
  render(
422
478
  <InternationalizationProvider
@@ -624,16 +680,37 @@ describe('Switch', () => {
624
680
  expect(onChange).not.toHaveBeenCalled();
625
681
  });
626
682
 
627
- it('remains natively disabled when disabled without a reason', () => {
683
+ it('stays focusable via aria-disabled when disabled without a reason', async () => {
684
+ const user = userEvent.setup();
685
+ const onChange = vi.fn();
686
+ const onClick = vi.fn();
628
687
  render(
629
- <Switch
630
- label="Enable notifications"
631
- value={false}
632
- onChange={() => {}}
633
- isDisabled
634
- />,
688
+ <form>
689
+ <Switch
690
+ label="Enable notifications"
691
+ value={false}
692
+ onChange={onChange}
693
+ onClick={onClick}
694
+ htmlName="notifications"
695
+ isRequired
696
+ isDisabled
697
+ />
698
+ </form>,
635
699
  );
636
- expect(screen.getByRole('switch')).toBeDisabled();
700
+ const control = screen.getByRole('switch');
701
+ expect(control).not.toHaveAttribute('disabled');
702
+ expect(control).toHaveAttribute('aria-disabled', 'true');
703
+ await user.tab();
704
+ expect(control).toHaveFocus();
705
+ await user.keyboard(' ');
706
+ await user.click(control);
707
+ await user.click(screen.getByText('Enable notifications'));
708
+ expect(onChange).not.toHaveBeenCalled();
709
+ expect(onClick).not.toHaveBeenCalled();
710
+ expect(control).not.toBeChecked();
711
+ const form = control.closest('form')!;
712
+ expect(form.checkValidity()).toBe(true);
713
+ expect([...new FormData(form).keys()]).toEqual([]);
637
714
  });
638
715
  });
639
716
  describe('labelSpacing', () => {
@@ -21,6 +21,7 @@ import {
21
21
  useOptimistic,
22
22
  useTransition,
23
23
  useEffect,
24
+ useRef,
24
25
  type ChangeEvent,
25
26
  type FocusEvent,
26
27
  type ReactNode,
@@ -43,7 +44,7 @@ import type {InputStatus} from '../Field/types';
43
44
  import {Spinner} from '../Spinner';
44
45
  import {useTooltip} from '../Tooltip';
45
46
  import {mergeProps, mergeRefs, rtlStyles} from '../utils';
46
- import {switchScope} from './switch.markers.stylex';
47
+ import {switchFocusScope, switchScope} from './switch.markers.stylex';
47
48
  import {interactionOverlayStyles} from '../utils/interactionOverlay.stylex';
48
49
  import type {BaseProps} from '../BaseProps';
49
50
  import type {SizeValue} from '../utils/types';
@@ -265,12 +266,12 @@ const styles = stylex.create({
265
266
  trackFocus: {
266
267
  outline: {
267
268
  default: 'none',
268
- [stylex.when.ancestor(':has(:focus-visible)', switchScope)]:
269
+ [stylex.when.ancestor(':has(:focus-visible)', switchFocusScope)]:
269
270
  `${focusVars['--focus-outline-width']} ${focusVars['--focus-outline-style']} ${focusVars['--focus-outline-color']}`,
270
271
  },
271
272
  outlineOffset: {
272
273
  default: null,
273
- [stylex.when.ancestor(':has(:focus-visible)', switchScope)]:
274
+ [stylex.when.ancestor(':has(:focus-visible)', switchFocusScope)]:
274
275
  focusVars['--focus-outline-offset'],
275
276
  },
276
277
  },
@@ -587,10 +588,20 @@ export function Switch({
587
588
  const isBusy = isLoading || optimisticValue !== value;
588
589
 
589
590
  const announce = useAnnounce();
591
+ // One announcement per busy start (spec:AST-086 FR6). The ref outlives
592
+ // StrictMode's effect replay and rerenders, so only a new busy start (busy
593
+ // again after settling) speaks again.
594
+ const announcedBusyRef = useRef(false);
590
595
  useEffect(() => {
591
- if (isBusy) {
592
- announce(t('@astryx.switch.loading'));
596
+ if (!isBusy) {
597
+ announcedBusyRef.current = false;
598
+ return;
593
599
  }
600
+ if (announcedBusyRef.current) {
601
+ return;
602
+ }
603
+ announcedBusyRef.current = true;
604
+ announce(t('@astryx.switch.loading'));
594
605
  }, [announce, isBusy, t]);
595
606
 
596
607
  const isOn = optimisticValue === true;
@@ -637,17 +648,13 @@ export function Switch({
637
648
  id={id}
638
649
  type="checkbox"
639
650
  role="switch"
640
- // Withhold the name while disabled: with a disabledMessage the
641
- // input stays focusable (not natively disabled), and a disabled
642
- // control must not submit.
651
+ // Withhold the name while disabled: the input stays focusable (not
652
+ // natively disabled), and a disabled control must not submit.
643
653
  name={isDisabled ? undefined : htmlName}
644
654
  checked={isOn}
645
- // With a disabledMessage the switch keeps focusability via aria-disabled
646
- // so the reason is focus-discoverable; toggling is still blocked by the
647
- // isDisabled guard in onChange below.
648
- disabled={isDisabled && !showsDisabledMessage}
649
- aria-disabled={showsDisabledMessage ? 'true' : undefined}
650
- form={showsDisabledMessage ? '' : undefined}
655
+ // Focusable but detached from the form while disabled (spec:AST-085).
656
+ aria-disabled={isDisabled ? 'true' : undefined}
657
+ form={isDisabled ? '' : undefined}
651
658
  required={isRequired}
652
659
  aria-required={isEffectivelyRequired ? 'true' : undefined}
653
660
  onChange={e => {
@@ -693,7 +700,7 @@ export function Switch({
693
700
  styles.track,
694
701
  trackSizeStyles[size],
695
702
  isOn ? styles.trackOn : styles.trackOff,
696
- !isDisabled && styles.trackFocus,
703
+ styles.trackFocus,
697
704
  isDisabled && styles.trackDisabled,
698
705
  isDisabled && !isOn && styles.trackDisabledOff,
699
706
  ),
@@ -747,7 +754,9 @@ export function Switch({
747
754
  className,
748
755
  style,
749
756
  )}
750
- {...rest}>
757
+ {...rest}
758
+ // Native `disabled` suppressed clicks on the switch (spec:AST-085 FR2).
759
+ onClick={isDisabled ? undefined : rest.onClick}>
751
760
  <div
752
761
  ref={el => {
753
762
  // Interaction (hover/focus) listeners for the disabled-message
@@ -764,6 +773,7 @@ export function Switch({
764
773
  isLabelHidden && styles.containerLabelHidden,
765
774
  labelSpacing === 'spread' && styles.containerSpread,
766
775
  !isDisabled && switchScope,
776
+ switchFocusScope,
767
777
  // The touch press's strength and release live on the row the
768
778
  // controller writes to; the track and thumb inherit and paint it.
769
779
  !isDisabled && interactionOverlayStyles.pressedAlpha,
@@ -147,7 +147,7 @@ export const SWITCH_BINDING_STATES: ReadonlyArray<SwitchBindingState> = [
147
147
  },
148
148
  {
149
149
  id: 'disabled',
150
- summary: 'a natively disabled switch, out of the tab sequence',
150
+ summary: 'a disabled switch that keeps its tab stop',
151
151
  storyId: 'core-switch--disabled',
152
152
  visibleLabel: 'Premium feature',
153
153
  props: {
@@ -159,7 +159,7 @@ export const SWITCH_BINDING_STATES: ReadonlyArray<SwitchBindingState> = [
159
159
  facts: {
160
160
  checked: false,
161
161
  operable: false,
162
- focusable: false,
162
+ focusable: true,
163
163
  disabled: true,
164
164
  described: true,
165
165
  required: false,
@@ -8,3 +8,7 @@ import * as stylex from '@stylexjs/stylex';
8
8
  */
9
9
  export const switchScope: ReturnType<typeof stylex.defineMarker> =
10
10
  stylex.defineMarker();
11
+
12
+ /** Focus-ring marker; unlike `switchScope`, it stays on while disabled. */
13
+ export const switchFocusScope: ReturnType<typeof stylex.defineMarker> =
14
+ stylex.defineMarker();
@@ -27,7 +27,9 @@ verified_by:
27
27
  modules:
28
28
  [
29
29
  module:Table/useTableActivation,
30
+ module:Table/useTableInfiniteScroll,
30
31
  module:Table/useTableRowStatus,
32
+ module:Table/useTableVirtualization,
31
33
  module:Table/useTableColumnResize,
32
34
  ]
33
35
  families: []
@@ -0,0 +1,244 @@
1
+ ---
2
+ schema_version: 3
3
+ template_version: 3
4
+ kind: module
5
+ id: module:Table/useTableInfiniteScroll
6
+ authority: draft
7
+ archive_reason: null
8
+ superseded_by: null
9
+ approved_by: null
10
+ approved_at: null
11
+ owners: [cixzhang]
12
+ review_triggers:
13
+ [public-api, behavior, layout, theming, accessibility, interaction]
14
+ verified_by:
15
+ [
16
+ packages/core/src/Table/plugins/infiniteScroll/useTableInfiniteScroll.test.tsx,
17
+ packages/core/src/Table/__tests__/TableInfiniteScroll.a11y.chromium.spec.ts,
18
+ packages/core/src/Table/Table.perf.test.tsx,
19
+ scripts/check-knowledge.mjs,
20
+ ]
21
+ parent_component: component:Table
22
+ references:
23
+ [
24
+ architecture:component-theming-surface,
25
+ architecture:interaction-modality,
26
+ architecture:public-component-api,
27
+ architecture:react-component-runtime,
28
+ spec:AST-002,
29
+ spec:AST-009,
30
+ spec:AST-020,
31
+ ]
32
+ ---
33
+
34
+ # useTableInfiniteScroll module contract
35
+
36
+ ## Contract at a glance
37
+
38
+ | Area | Contract |
39
+ | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
40
+ | Public contract | Candidate `useTableInfiniteScroll` hook returning one `TablePlugin` from controlled `hasMore`, controlled `isLoading`, and `onLoadMore`; root, reset, fallback, and look-ahead inputs remain unadmitted until their owner decisions pass. |
41
+ | Behavior | One end sentinel requests the next batch as it approaches the relevant block-scroll viewport, never overlaps requests, and rearms when loading completes while more data remains. |
42
+ | End-user impact | People can continue through incrementally loaded rows without duplicate requests, focus disruption, inaccessible progress, or a dead end when automatic observation is unavailable. |
43
+ | Builder impact | Builders own data, request state, errors, and appended rows. The plugin owns observation, duplicate suppression, progress/fallback presentation, and cleanup. |
44
+ | Compatibility/readiness | Additive, unreleased module. This draft is not implementation authority. Scroll-root ownership, request completion/reset, parent rendering-mode seam, fallback policy, loading/error composition, customization, and announcement evidence require owner decisions before promotion. |
45
+ | Review checks | Reject viewport assumptions, overlapping loads, stale callbacks, a permanently visible sentinel loop, unexplained focusable sentinels, progress announced on every observer tick, a mouse-only fallback, invalid table DOM, retained observers, or router/data-client dependencies. |
46
+ | Governing rules | `component:Table` FR9-FR12 and ORD1-ORD4 own plugin composition; `architecture:react-component-runtime` owns observer lifetime; `architecture:public-component-api` and `spec:AST-002` own public caller burden. |
47
+
48
+ This table is a review projection; the body below is authoritative.
49
+
50
+ ## Intent
51
+
52
+ `useTableInfiniteScroll` connects incremental data loading to a data-driven Table
53
+ without making Table own a router, cache, query client, cursor format, or product data.
54
+ It owns an end sentinel, observation lifecycle, one-request-at-a-time behavior,
55
+ rearming, accessible progress, and a keyboard-operable fallback.
56
+
57
+ Infinite scroll is distinct from client pagination and virtualization. It appends data;
58
+ it does not choose the caller's page/cursor protocol or bound the number of rendered rows.
59
+
60
+ ## Compatibility and migration
61
+
62
+ - Released default preserved: `yes`; Table behavior remains unchanged unless the new plugin is supplied.
63
+ - Compatibility class: additive public hook, types, documentation, and examples.
64
+ - Migration decision: `module:Table/useTableInfiniteScroll/DEC-1` after the open ownership decisions are approved.
65
+
66
+ Consumer migration instructions belong in consumer docs and release notes.
67
+
68
+ ## Ownership boundary
69
+
70
+ **Owns**
71
+
72
+ - The public hook, configuration, sentinel, observer lifecycle, and loading trigger boundary.
73
+ - Duplicate-request suppression, rearming after completion, and short-batch behavior.
74
+ - Default progress and manual fallback behavior, accessibility, performance, and evidence.
75
+ - Composition with Table's scroll wrapper, empty state, loading state, appended rows, other plugins, and the candidate children-mode warning/no-op, blocked on OQ8's parent contract decision.
76
+
77
+ **Does not own / non-goals**
78
+
79
+ - Aggregate `TablePlugin` protocol, transform order, or scroll-wrapper implementation — owned by `component:Table`.
80
+ - Product data, request/cursor/page state, cache policy, retry policy, or error content — owned by the caller or another module.
81
+ - Client pagination, server pagination, Relay integration, row virtualization, or column virtualization.
82
+ - Scroll restoration, URL state, routing, or prefetching.
83
+ - A new public Table theme target unless a separate approved decision establishes a distinct theme-author need.
84
+ - Caller-composed children-mode semantics or the parent rendering-mode contract. The candidate warning/no-op depends on OQ8; the module never interprets caller children or infers mode from DOM to append loading UI.
85
+
86
+ ## Public API and concepts
87
+
88
+ The candidate minimum shape is:
89
+
90
+ ```ts
91
+ export interface UseTableInfiniteScrollConfig {
92
+ hasMore: boolean;
93
+ isLoading: boolean;
94
+ onLoadMore: () => void | Promise<void>;
95
+ }
96
+
97
+ export function useTableInfiniteScroll<T extends Record<string, unknown>>(
98
+ config: UseTableInfiniteScrollConfig,
99
+ ): TablePlugin<T>;
100
+ ```
101
+
102
+ The final shape is blocked on OQ1-OQ8. Consumer docs own final signatures,
103
+ defaults, imports, and examples after promotion.
104
+
105
+ | Concept | Closed values or states | Meaning | Default | Owner | Stability |
106
+ | --------------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------ | ------------------------ | ------------------------------------- | --------- |
107
+ | More-data state | `hasMore` true or false | Whether another load may be requested. | Controlled | Caller through this module | Candidate |
108
+ | Loading state | `isLoading` true or false | Whether a request is already in flight. | Controlled | Caller through this module | Candidate |
109
+ | Load request | `void` or promise-returning callback | Starts one caller-owned append operation without prescribing its data protocol. | No request while blocked | Caller through this module | Unsettled |
110
+ | Look-ahead | Optional non-negative distance, if admitted | Starts loading before the sentinel reaches the visible block-end edge. | Not yet admitted | Caller/module boundary | Unsettled |
111
+ | Sentinel | Observed, loading, exhausted, unavailable | Coordinates automatic loading without becoming unexplained table data or an accidental tab stop. | Derived | `module:Table/useTableInfiniteScroll` | Candidate |
112
+ | Manual fallback | Available or hidden | Provides an explicit load command when automatic observation is unavailable or unsuitable. | Unsettled | `module:Table/useTableInfiniteScroll` | Unsettled |
113
+
114
+ ## Behavioral contract
115
+
116
+ | ID | Candidate invariant | Basis | Draft review state |
117
+ | ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | --------------------------------------- |
118
+ | FR1 | Stable configuration callbacks and unchanged primitive inputs preserve one stable `TablePlugin`. | Parent plugin identity and runtime architecture | Settled candidate |
119
+ | FR2 | The plugin contributes one end sentinel after the semantic `<table>`, inside the Table scroll-wrapper content. It never inserts non-row content inside `<table>`, `<thead>`, `<tbody>`, or `<tr>`. | Valid HTML and parent wrapper contract | Settled candidate |
120
+ | FR3 | Automatic loading is armed only when `hasMore` is true and `isLoading` is false. One visibility episode produces at most one active request. | Duplicate suppression | Settled candidate |
121
+ | FR4 | While a load is in flight, the module does not call `onLoadMore` again, including through repeated observation, manual activation, or rerender. Completion can make another call eligible only for the current configuration, subject to the controlled inputs and FR14. The caller remains solely responsible for suppressing stale product-data commits. | Controlled state and async ownership | Callback completion contract unsettled |
122
+ | FR5 | When a short append leaves the sentinel visible and `hasMore` remains true, completing the prior load can request the next batch without requiring the user to scroll away and back. | Continuous loading behavior | Settled outcome; mechanism free |
123
+ | FR6 | `hasMore=false` disconnects observation, removes the manual command, and presents no loading state. Changing back to true can arm a new request. | Exhausted-state boundary | Settled candidate |
124
+ | FR7 | An observer-unavailable environment preserves server rendering/hydration and exposes the approved manual fallback rather than throwing or silently dead-ending the user. | Progressive enhancement | Fallback policy unsettled |
125
+ | FR8 | Loading progress is shown and announced at most once per state transition, not once per observer callback. Error and retry UI remain caller-owned and do not render duplicate progress chrome. | Accessibility and module boundary | Composition surface unsettled |
126
+ | FR9 | The module does not call another plugin's callbacks, mutate selection/activation/expansion/sort/filter state, replace product rows, or reset scroll position. Preservation of caller-controlled state follows from that non-interference; caller data updates remain caller-owned. | Plugin independence | Settled candidate |
127
+ | FR10 | Empty data may still load when `hasMore=true`; default EmptyState and load affordance do not obscure each other or communicate contradictory states. | Empty/loading composition | Human layout decision |
128
+ | FR11 | Approved scroll-root or look-ahead changes reconnect observation without leaking the old observer or triggering from a detached sentinel. Unmount and node replacement release every observer and retained node. | `architecture:react-component-runtime` | Settled candidate |
129
+ | FR12 | The plugin remains data-protocol agnostic. `onLoadMore` receives no Table-owned page, cursor, Relay, router, cache, or request-client object. | Public ownership boundary | Settled candidate |
130
+ | FR13 | In children mode the module is a no-op and emits at most one deduplicated development warning. This candidate outcome depends on OQ8's missing parent contract; it does not establish a rendering-mode signal on the current Table wrapper transform or permit mode inference from DOM. | Missing `component:Table` dependency, OQ8 | Hold: parent contract decision required |
131
+ | FR14 | Reconfiguration during a load follows one approved caller-visible reset or React remount boundary. Settlement from a load started before that boundary neither calls `onLoadMore`, makes a call eligible, nor changes loading presentation for the current configuration. Caller response ownership remains unchanged. | Caller-visible reset and stale-completion safety | Human API decision |
132
+
133
+ ### Transformation and precedence order
134
+
135
+ - **ORD1 — Exhausted/loading gate.** Resolve `hasMore` and `isLoading` before observing or exposing a manual command.
136
+ - **ORD2 — Current configuration.** Calls use the current `onLoadMore` and respect the approved reset/remount boundary; changing callback identity alone does not end an in-flight load or silently redefine product-data ownership.
137
+ - **ORD3 — One load at a time.** From invocation of `onLoadMore` until the approved completion condition, no observation or manual activation invokes it again, including reentrant activation from caller code. Synchronous throws and asynchronous settlement follow OQ2's completion rule.
138
+ - **ORD4 — Completion after reset.** FR14 excludes settlement from before a reset/remount from invoking or enabling a call or changing current loading presentation. Completion of a current-configuration load follows OQ2/OQ5 and the controlled inputs; the plugin never commits or rejects caller data.
139
+ - **ORD5 — Rearm.** Controlled completion and continued `hasMore` rearm the current sentinel, including when it remains visible.
140
+ - **ORD6 — Presentation.** Exhausted, loading, automatic, fallback, and error-adjacent states render one coherent after-table surface.
141
+ - **ORD7 — Parent pipeline.** In data mode the module appends to accumulated `afterTable` content and preserves prior wrapper props, styles, refs, and chrome; children mode returns prior props unchanged, subject to OQ8's parent contract dependency.
142
+
143
+ ### Performance and resources
144
+
145
+ - **PR1 — One observer.** At most one `IntersectionObserver` and one observed sentinel exist per mounted plugin instance.
146
+ - **PR2 — No scroll listener.** Native observation, not a per-frame or per-scroll JavaScript listener, drives automatic loading.
147
+ - **PR3 — Stable resources.** Callback updates do not recreate observation when the approved root/look-ahead geometry and sentinel are unchanged.
148
+ - **PR4 — Complete cleanup.** Unmount, sentinel replacement, root replacement, exhaustion, and loading transitions release obsolete observation.
149
+ - **PR5 — Constant callback work.** An observer entry performs constant coordination work and never scans rows or columns.
150
+
151
+ ## Accessibility contract
152
+
153
+ - **AR1 — Valid structure.** Sentinel, progress, and fallback UI stay outside the semantic table and do not appear as data rows or cells (WCAG 2.2 SC 1.3.1; DOM/accessibility-tree and browser evidence).
154
+ - **AR2 — Progress announcement.** Loading transitions use a non-interruptive status that does not repeat for observer churn or every appended row (WCAG 2.2 SC 4.1.3). Deterministic transition tests prove event count/order, and the approved implementation requires NVDA + Chrome on Windows and VoiceOver + Safari on macOS to prove spoken announcement timing under `spec:AST-009` FR4 and FR12.
155
+ - **AR3 — Operable fallback.** The approved fallback is a real keyboard-operable control with a localized name and visible focus; the sentinel itself is not an unexplained tab stop (WCAG 2.2 SC 2.1.1, 2.4.7, and 4.1.2; DOM/axe/real-browser evidence).
156
+ - **AR4 — Focus stability.** Appending rows does not move focus, reset focused descendants, or make the fallback disappear while it owns focus without a deterministic successor (WCAG 2.2 SC 2.4.3 and 2.4.7; real-browser keyboard evidence).
157
+ - **AR5 — Reduced motion.** Progress presentation does not require motion to communicate loading state and respects platform motion settings; real-browser visual evidence proves the supported states.
158
+ - **AR6 — Touch and zoom.** Automatic loading and fallback remain available without hover, precision pointing, or a fixed viewport size (WCAG 2.2 SC 1.4.10 and 2.1.1; responsive/touch real-browser evidence).
159
+ - **AR7 — Evidence boundaries.** Real AT evidence is limited to AR2's announcement claim or any later approved AT-dependent outcome; it supplements rather than replaces deterministic state, DOM, keyboard, and browser coverage.
160
+
161
+ ## Design relationships
162
+
163
+ | Anatomy or state | Design requirement | Representation authority | Module contract |
164
+ | ------------------- | ------------------------------------------------------------------------------------- | ------------------------- | ----------------- |
165
+ | End sentinel | Supplies observation geometry without masquerading as table data or a control. | This module | FR2-FR7, AR1 |
166
+ | Loading progress | Communicates one active append operation without duplicating caller-owned chrome. | Unsettled pending OQ4 | FR3-FR8, AR2, AR5 |
167
+ | Manual load command | Provides an explicit fallback with normal control semantics and focus treatment. | Unsettled pending OQ3/OQ4 | FR7-FR10, AR3-AR4 |
168
+ | Exhausted state | Stops requesting and removes load affordances without adding an unsolicited message. | This module | FR6, ORD1, ORD5 |
169
+ | Error/retry | Remains outside the module unless a later loading/error composition contract owns it. | Caller or another module | FR8, non-goal |
170
+
171
+ The module adds no direct theme target. OQ3/OQ4 must select any progress/fallback component and then name its exact existing target or record a classified `none`; no delegation is promised yet. Sentinel geometry is implementation detail rather than theme API.
172
+
173
+ ## Parent and system relationships
174
+
175
+ - `component:Table` owns the scroll-wrapper phase and accumulated `afterTable` slot. **Missing parent dependency (OQ8):** its current contract does not establish a data/children mode signal at that phase. The module remains on hold until the parent contract records the approved mode distinction or makes the invalid combination unrepresentable; FR13 then cites that owning claim. This draft neither supplies a field nor approves a parent API change.
176
+ - `architecture:react-component-runtime` owns observer acquisition, current-value synchronization, node replacement, StrictMode replay, and cleanup boundaries.
177
+ - `architecture:public-component-api` and `spec:AST-002` require a protocol-agnostic API whose inputs express caller-owned state rather than implementation clients.
178
+ - `spec:AST-009` and `spec:AST-020` own announcement evidence and the WCAG/evidence-layer boundary; AR2 is the only current real-AT-dependent claim.
179
+ - Pagination and virtualization remain separate modules. Infinite loading can compose with them only where their current contracts explicitly permit it.
180
+
181
+ ## Verification map
182
+
183
+ | Contract | Verification | Representative states | Mutation or failure expectation |
184
+ | ----------------------------------- | --------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
185
+ | FR1, FR3-FR7, FR14, ORD1-ORD5 | Deterministic observer events, callback count/order assertions, and controlled rerenders | Visible/hidden, loading, exhausted, short batch, reentrant activation, throw, async settle, reset/remount | Calls overlap, pre-reset completion invokes or enables a call or changes current loading presentation, short batches dead-end, observation throws, or rearm fails. |
186
+ | FR2, FR8-FR10, FR12-FR13, ORD6-ORD7 | Table render and plugin-composition tests against the parent rendering-mode contract once OQ8 is resolved | Data/children mode, empty/non-empty, wrapper chrome, loading, exhausted, caller error UI | Invalid DOM appears, children mode renders a sentinel, warning repeats, prior chrome is lost, caller state is mutated, or states contradict; absent parent authority keeps OQ8 on hold. |
187
+ | FR11, PR1-PR5 | Observer lifecycle tests under StrictMode, root/sentinel/look-ahead change, unmount, and no-op rerender | Every approved acquisition, replacement, and release path | More than one observer survives, detached nodes remain observed, or stable inputs churn resources. |
188
+ | AR1, AR3-AR7 | DOM/accessibility-tree assertions, axe, and real-browser keyboard/touch/zoom/reduced-motion/focus flows | Automatic, fallback, loading, append, exhaustion, observer unavailable | Sentinel becomes a row/tab stop, focus is lost, fallback is inoperable, or responsive/motion behavior carries required meaning. |
189
+ | AR2 | Deterministic status-transition count/order tests plus NVDA + Chrome on Windows and VoiceOver + Safari on macOS | Initial load, repeated observer entries, short batches, completion, exhaustion | Status repeats, arrives out of order, is skipped, or announces observer churn/appended rows instead of loading transitions. |
190
+ | Public API and docs | Public-subpath compile fixture, `.doc.mjs`, generated inventory, Storybook, and one generic async-cache recipe | Controlled state, promise/void callback, approved reset/root/fallback/look-ahead inputs | API leaks a client, exposes look-ahead without admission, cannot express the approved reset boundary, promises unsettled behavior, or permits contradictions. |
191
+ | Performance | Extend Table performance coverage with stable no-op, observer callback, and repeated append budgets | Large representative data and repeated batches | Stable rerenders recreate resources, callbacks scan rows, or appends exceed recorded budgets. |
192
+ | Structural ownership | `pnpm check:knowledge` | Draft module and parent backlink | A missing, orphaned, misnamed, wrong-kind, or duplicate module record passes validation. |
193
+
194
+ ## Decision log
195
+
196
+ ### DEC-1 — Infinite scroll owns loading coordination, not data
197
+
198
+ **Reference:** `module:Table/useTableInfiniteScroll/DEC-1`
199
+ **Decider:** pending owner approval
200
+
201
+ The module observes the end of rendered Table content and asks the caller for another batch. It owns at-most-one-in-flight loading, callback eligibility after completion/reset, rearming, and presentation under FR4/FR14 and ORD2-ORD5, not an internal coordination mechanism. The caller remains the sole owner of rows, request protocol, cache, cursor/page, stale-response suppression, errors, and retry policy.
202
+
203
+ Rejected: importing a router/query client, mutating Table data, or combining cursor,
204
+ server-pagination, Relay, and infinite-loading contracts.
205
+
206
+ ### DEC-2 — The sentinel is after the table
207
+
208
+ **Reference:** `module:Table/useTableInfiniteScroll/DEC-2`
209
+ **Decider:** pending owner approval
210
+
211
+ Observation and progress UI use the parent scroll wrapper's `afterTable` slot. They never
212
+ insert non-row elements into semantic table structure or synthesize a fake data row.
213
+
214
+ Rejected: a `<div>` inside `<tbody>`, a sentinel `<tr>` announced as data, and a focusable
215
+ sentinel with no user command.
216
+
217
+ ### DEC-3 — Automatic loading requires an operable fallback
218
+
219
+ **Reference:** `module:Table/useTableInfiniteScroll/DEC-3`
220
+ **Decider:** pending owner approval
221
+
222
+ Automatic observation is progressive enhancement. An approved fallback prevents a dead
223
+ end when observation is unsupported, disabled, or unsuitable and gives keyboard users an
224
+ explicit command without making the sentinel itself interactive.
225
+
226
+ Rejected: silently doing nothing without IntersectionObserver and making automatic loading
227
+ the only path.
228
+
229
+ ## Open questions
230
+
231
+ - **OQ1 — Who owns the block-scroll root?** (`human-api`) Decide viewport default versus an explicit caller root/ref, and how Table nested in a vertical `ScrollableArea` identifies the correct root without brittle ancestor discovery.
232
+ - **OQ2 — What observable event completes a load?** (`human-api`) Decide whether controlled `isLoading`, a returned promise, or both permit another `onLoadMore` call after synchronous throw and asynchronous settlement.
233
+ - **OQ3 — When is manual fallback shown?** (`human-design`) Decide always-visible, observer-unavailable-only, user-selected, or another policy; define focus behavior when loading/exhaustion removes it.
234
+ - **OQ4 — What loading customization is public?** (`human-api`) Decide whether the module renders one fixed default, accepts `renderLoadingIndicator`, composes an existing loading-state module, or stays behavior-only while still meeting progress requirements.
235
+ - **OQ5 — How do error and retry compose?** (`human-api`) Keep error content caller-owned, but define how a failed request rearms automatic loading and avoids immediate retry loops or duplicate loading/error chrome.
236
+ - **OQ6 — Is caller-configurable look-ahead admitted?** (`human-api`) First prove a non-derivable caller distinction under `spec:AST-002`; if admitted, choose an intent-named distance API with explicit units/default rather than reusing browser `threshold` terminology, which conventionally means an intersection ratio. Keep it out of the candidate interface until then.
237
+ - **OQ7 — What is the caller-visible reset boundary?** (`human-api`) Decide an explicit caller-owned reset identity versus a documented React remount/key boundary when dataset/query configuration changes during a load. Pre-reset settlement neither invokes nor enables a call nor changes loading presentation for the current configuration; stale product-data suppression remains the caller's job.
238
+ - **OQ8 — Which parent contract distinguishes data from children mode?** (`human-api`) Decide a parent-owned rendering-mode signal at the wrapper transform versus making the invalid combination unrepresentable, preserving FR13's warning/no-op outcome for children mode. The chosen behavior belongs in `component:Table`, and FR13 must cite its approved owning claim before this module is promoted. This missing dependency remains on hold; no parent field or approval is assumed.
239
+
240
+ ## Content boundary
241
+
242
+ This record does not duplicate consumer signatures/examples, the parent Table wrapper
243
+ protocol, general observer mechanics, current usage audits, implementation steps, or
244
+ pagination/virtualization/data-client contracts. It links to their canonical owners.
@@ -195,9 +195,11 @@ describe('useTableSelection', () => {
195
195
  it('disables checkbox for disabled rows', () => {
196
196
  render(<SelectionTable getIsItemEnabled={item => !item.isLocked} />);
197
197
  const rowCheckboxes = screen.getAllByLabelText('Select row');
198
- expect(rowCheckboxes[0]).not.toBeDisabled();
199
- expect(rowCheckboxes[1]).not.toBeDisabled();
200
- expect(rowCheckboxes[2]).toBeDisabled();
198
+ expect(rowCheckboxes[0]).not.toHaveAttribute('aria-disabled');
199
+ expect(rowCheckboxes[1]).not.toHaveAttribute('aria-disabled');
200
+ // Focusable disabled (spec:AST-085 FR1): aria-disabled, no native disabled.
201
+ expect(rowCheckboxes[2]).toHaveAttribute('aria-disabled', 'true');
202
+ expect(rowCheckboxes[2]).not.toHaveAttribute('disabled');
201
203
  });
202
204
 
203
205
  it('prepends selection <td> to each body row', () => {