@astryxdesign/core 0.7.3-canary.b74cf21 → 0.7.3-canary.ca48bb7

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 (189) 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/Dialog/Dialog.d.ts.map +1 -1
  26. package/dist/Dialog/Dialog.js +9 -4
  27. package/dist/DropdownMenu/DropdownMenu.d.ts.map +1 -1
  28. package/dist/DropdownMenu/DropdownMenu.js +52 -10
  29. package/dist/DropdownMenu/DropdownMenuContext.d.ts +34 -1
  30. package/dist/DropdownMenu/DropdownMenuContext.d.ts.map +1 -1
  31. package/dist/DropdownMenu/DropdownMenuContext.js +15 -0
  32. package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts +21 -2
  33. package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts.map +1 -1
  34. package/dist/DropdownMenu/DropdownMenuSubMenu.js +375 -42
  35. package/dist/DropdownMenu/menuRegionStops.d.ts +2 -2
  36. package/dist/DropdownMenu/menuRegionStops.js +2 -2
  37. package/dist/FileInput/FileInput.d.ts.map +1 -1
  38. package/dist/FileInput/FileInput.js +14 -12
  39. package/dist/Link/Link.d.ts +2 -3
  40. package/dist/Link/Link.d.ts.map +1 -1
  41. package/dist/Link/Link.js +11 -19
  42. package/dist/Markdown/Markdown.d.ts.map +1 -1
  43. package/dist/Markdown/Markdown.js +48 -15
  44. package/dist/MultiSelector/MultiSelector.d.ts +4 -4
  45. package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
  46. package/dist/MultiSelector/MultiSelector.disabledFocus.a11y.chromium.spec.d.ts +2 -0
  47. package/dist/MultiSelector/MultiSelector.disabledFocus.a11y.chromium.spec.d.ts.map +1 -0
  48. package/dist/MultiSelector/MultiSelector.disabledFocus.a11y.chromium.spec.js +48 -0
  49. package/dist/MultiSelector/MultiSelector.js +25 -18
  50. package/dist/Pagination/Pagination.d.ts.map +1 -1
  51. package/dist/Pagination/Pagination.js +5 -2
  52. package/dist/Popover/Popover.d.ts.map +1 -1
  53. package/dist/Popover/Popover.js +8 -4
  54. package/dist/Selector/Selector.d.ts +4 -4
  55. package/dist/Selector/Selector.d.ts.map +1 -1
  56. package/dist/Selector/Selector.js +23 -14
  57. package/dist/Slider/Slider.d.ts.map +1 -1
  58. package/dist/Slider/Slider.js +7 -14
  59. package/dist/Switch/Switch.d.ts.map +1 -1
  60. package/dist/Switch/Switch.js +25 -17
  61. package/dist/Switch/switch.markers.stylex.d.ts +2 -0
  62. package/dist/Switch/switch.markers.stylex.d.ts.map +1 -1
  63. package/dist/Switch/switch.markers.stylex.js +6 -0
  64. package/dist/TimeInput/TimeInput.d.ts +2 -1
  65. package/dist/TimeInput/TimeInput.d.ts.map +1 -1
  66. package/dist/Timestamp/Timestamp.d.ts.map +1 -1
  67. package/dist/Timestamp/Timestamp.js +41 -8
  68. package/dist/Token/Token.d.ts.map +1 -1
  69. package/dist/Token/Token.js +8 -3
  70. package/dist/Tooltip/Tooltip.d.ts.map +1 -1
  71. package/dist/Tooltip/Tooltip.js +36 -14
  72. package/dist/TopNav/TopNav.d.ts.map +1 -1
  73. package/dist/TopNav/TopNav.js +2 -2
  74. package/dist/TopNav/TopNavHeading.d.ts.map +1 -1
  75. package/dist/TopNav/TopNavHeading.js +1 -0
  76. package/dist/TreeList/TreeListItem.d.ts.map +1 -1
  77. package/dist/TreeList/TreeListItem.js +8 -3
  78. package/dist/astryx.css +7 -2
  79. package/dist/hooks/useGridFocus.d.ts.map +1 -1
  80. package/dist/hooks/useGridFocus.js +10 -1
  81. package/dist/hooks/useMenuPress.d.ts +11 -1
  82. package/dist/hooks/useMenuPress.d.ts.map +1 -1
  83. package/dist/hooks/useMenuPress.js +43 -5
  84. package/dist/utils/disabledInteraction.d.ts +21 -0
  85. package/dist/utils/disabledInteraction.d.ts.map +1 -0
  86. package/dist/utils/disabledInteraction.js +33 -0
  87. package/dist/utils/timeParser.d.ts +4 -0
  88. package/dist/utils/timeParser.d.ts.map +1 -1
  89. package/dist/utils/timeParser.js +9 -2
  90. package/package.json +3 -3
  91. package/src/Banner/Banner.spec.md +37 -32
  92. package/src/BottomSheet/BottomSheet.spec.md +26 -53
  93. package/src/BottomSheet/BottomSheet.test.tsx +29 -0
  94. package/src/BottomSheet/BottomSheet.tsx +8 -4
  95. package/src/BottomSheet/BottomSheetSwitcher.spec.md +45 -48
  96. package/src/BottomSheet/BottomSheetSwitcher.test.tsx +26 -0
  97. package/src/BottomSheet/BottomSheetSwitcher.tsx +6 -0
  98. package/src/Calendar/Calendar.test.tsx +68 -24
  99. package/src/Calendar/Calendar.tsx +2 -1
  100. package/src/CheckboxInput/CheckboxInput.test.tsx +44 -5
  101. package/src/CheckboxInput/CheckboxInput.tsx +16 -26
  102. package/src/CheckboxInput/__tests__/Checkbox.a11y.states.ts +3 -3
  103. package/src/CheckboxList/CheckboxList.test.tsx +9 -6
  104. package/src/ClickableCard/ClickableCard.doc.mjs +2 -2
  105. package/src/ClickableCard/ClickableCard.test.tsx +5 -4
  106. package/src/ClickableCard/ClickableCard.tsx +9 -9
  107. package/src/CodeBlock/CodeBlock.highlightStyles.test.tsx +87 -0
  108. package/src/CodeBlock/CodeBlock.tsx +11 -1
  109. package/src/ComplexSelector/ComplexSelector.focusableDisabled.test.tsx +76 -0
  110. package/src/ComplexSelector/ComplexSelector.tsx +21 -4
  111. package/src/ContextMenu/ContextMenu.test.tsx +41 -0
  112. package/src/DateInput/DateInput.doc.mjs +9 -8
  113. package/src/DateInput/DateInput.test.tsx +88 -13
  114. package/src/DateInput/DateInput.tsx +49 -21
  115. package/src/DateInput/DateInputTouch.test.tsx +22 -1
  116. package/src/DateInput/NativeDateField.test.tsx +29 -3
  117. package/src/DateInput/NativeDateField.tsx +20 -7
  118. package/src/DateInput/TouchDateField.tsx +24 -9
  119. package/src/DateRangeInput/DateRangeInput.doc.mjs +6 -5
  120. package/src/DateRangeInput/DateRangeInput.test.tsx +82 -16
  121. package/src/DateRangeInput/DateRangeInput.tsx +40 -20
  122. package/src/Dialog/Dialog.test.tsx +39 -0
  123. package/src/Dialog/Dialog.tsx +9 -4
  124. package/src/DropdownMenu/DropdownMenu.spec.md +33 -2
  125. package/src/DropdownMenu/DropdownMenu.tsx +79 -8
  126. package/src/DropdownMenu/DropdownMenuContext.public.test.ts +22 -0
  127. package/src/DropdownMenu/DropdownMenuContext.tsx +39 -1
  128. package/src/DropdownMenu/DropdownMenuSubMenu.doc.mjs +16 -1
  129. package/src/DropdownMenu/DropdownMenuSubMenu.test.tsx +623 -1
  130. package/src/DropdownMenu/DropdownMenuSubMenu.tsx +556 -99
  131. package/src/DropdownMenu/__tests__/SubMenuHeaderFooter.a11y.chromium.spec.ts +267 -0
  132. package/src/DropdownMenu/menuRegionStops.ts +2 -2
  133. package/src/DropdownMenu/modules/useMenuPress.spec.md +9 -0
  134. package/src/FieldStatus/FieldStatus.spec.md +3 -2
  135. package/src/FileInput/FileInput.test.tsx +30 -7
  136. package/src/FileInput/FileInput.tsx +21 -11
  137. package/src/Link/Link.test.tsx +21 -7
  138. package/src/Link/Link.tsx +16 -24
  139. package/src/Link/__tests__/Link.a11y.states.ts +1 -1
  140. package/src/Markdown/Markdown.rerender.test.tsx +143 -0
  141. package/src/Markdown/Markdown.tsx +89 -35
  142. package/src/MultiSelector/MultiSelector.disabledFocus.a11y.chromium.spec.ts +71 -0
  143. package/src/MultiSelector/MultiSelector.doc.mjs +5 -5
  144. package/src/MultiSelector/MultiSelector.focusableDisabled.test.tsx +121 -0
  145. package/src/MultiSelector/MultiSelector.test.tsx +49 -7
  146. package/src/MultiSelector/MultiSelector.tsx +53 -29
  147. package/src/Pagination/Pagination.test.tsx +5 -1
  148. package/src/Pagination/Pagination.tsx +3 -1
  149. package/src/Popover/Popover.test.tsx +15 -0
  150. package/src/Popover/Popover.tsx +19 -8
  151. package/src/PowerSearch/PowerSearchFilterEditor.test.tsx +3 -2
  152. package/src/PowerSearch/PowerSearchToken.test.tsx +7 -5
  153. package/src/Selector/Selector.doc.mjs +4 -4
  154. package/src/Selector/Selector.focusableDisabled.test.tsx +127 -0
  155. package/src/Selector/Selector.test.tsx +48 -6
  156. package/src/Selector/Selector.tsx +51 -21
  157. package/src/SideNav/SideNav.test.tsx +35 -0
  158. package/src/Slider/Slider.test.tsx +3 -3
  159. package/src/Slider/Slider.tsx +8 -15
  160. package/src/Switch/Switch.test.tsx +85 -8
  161. package/src/Switch/Switch.tsx +26 -16
  162. package/src/Switch/__tests__/Switch.a11y.states.ts +2 -2
  163. package/src/Switch/switch.markers.stylex.ts +4 -0
  164. package/src/Table/plugins/selection/useTableSelection.test.tsx +5 -3
  165. package/src/TimeInput/TimeInput.doc.mjs +5 -3
  166. package/src/TimeInput/TimeInput.test.tsx +20 -0
  167. package/src/TimeInput/TimeInput.tsx +2 -1
  168. package/src/Timestamp/Timestamp.doc.mjs +1 -1
  169. package/src/Timestamp/Timestamp.test.tsx +168 -0
  170. package/src/Timestamp/Timestamp.tsx +62 -11
  171. package/src/Token/Token.test.tsx +26 -4
  172. package/src/Token/Token.tsx +6 -2
  173. package/src/Tooltip/Tooltip.test.tsx +51 -6
  174. package/src/Tooltip/Tooltip.tsx +41 -18
  175. package/src/Tooltip/__tests__/Tooltip.a11y.chromium.spec.ts +129 -0
  176. package/src/Tooltip/__tests__/Tooltip.a11y.renders.tsx +86 -0
  177. package/src/Tooltip/__tests__/Tooltip.a11y.states.ts +132 -0
  178. package/src/Tooltip/__tests__/Tooltip.a11y.test.tsx +126 -0
  179. package/src/TopNav/TopNav.tsx +11 -1
  180. package/src/TopNav/TopNavHeading.tsx +1 -0
  181. package/src/TopNav/__tests__/TopNavMobileLongHeading.a11y.chromium.spec.ts +74 -0
  182. package/src/TreeList/TreeList.test.tsx +28 -0
  183. package/src/TreeList/TreeListItem.tsx +6 -2
  184. package/src/__tests__/DisabledTriggerFocus.a11y.chromium.spec.ts +86 -0
  185. package/src/hooks/useGridFocus.ts +16 -1
  186. package/src/hooks/useMenuPress.ts +45 -7
  187. package/src/utils/disabledInteraction.ts +40 -0
  188. package/src/utils/timeParser.test.ts +11 -0
  189. package/src/utils/timeParser.ts +9 -2
@@ -3691,3 +3691,38 @@ describe('SideNav collapse conflict warning', () => {
3691
3691
  expect(warn).not.toHaveBeenCalled();
3692
3692
  });
3693
3693
  });
3694
+
3695
+ // =============================================================================
3696
+ // SideNavItem — collapsed-from-mount rail tooltip
3697
+ // =============================================================================
3698
+
3699
+ describe('SideNavItem collapsed-from-mount rail tooltip', () => {
3700
+ // #7692: a childless item that mounts inside an already-collapsed SideNav
3701
+ // renders its Tooltip inside the very element anchorRef points at. React
3702
+ // attaches that ref only after the tooltip's layout effect has run, so the
3703
+ // tooltip never attached and a persisted collapsed rail lost all tooltips
3704
+ // on page load.
3705
+ it('anchors the rail tooltip when the nav is collapsed at mount', async () => {
3706
+ render(
3707
+ <SideNav
3708
+ aria-label="Main"
3709
+ collapsible={{isCollapsed: true, hasButton: false}}>
3710
+ <SideNavItem label="Dashboard" icon={StubIcon} href="/dashboard" />
3711
+ </SideNav>,
3712
+ );
3713
+ const link = screen.getByRole('link', {name: 'Dashboard'});
3714
+ const anchor = link.parentElement as HTMLElement;
3715
+
3716
+ // The tooltip attaches despite mounting inside its own anchor: the item
3717
+ // row is described by the tooltip layer.
3718
+ await waitFor(() => {
3719
+ expect(anchor.getAttribute('aria-describedby')).not.toBe('');
3720
+ });
3721
+ const layer = document.getElementById(
3722
+ anchor.getAttribute('aria-describedby') as string,
3723
+ );
3724
+ expect(layer).not.toBeNull();
3725
+ expect(layer).toHaveAttribute('role', 'tooltip');
3726
+ expect(layer).toHaveTextContent('Dashboard');
3727
+ });
3728
+ });
@@ -385,7 +385,7 @@ describe('Slider', () => {
385
385
  render(<Slider label="Volume" value={50} isDisabled />);
386
386
  const slider = screen.getByRole('slider');
387
387
  expect(slider).toHaveAttribute('aria-disabled', 'true');
388
- expect(slider).toHaveAttribute('tabIndex', '-1');
388
+ expect(slider).toHaveAttribute('tabIndex', '0');
389
389
  });
390
390
 
391
391
  it('does not fire onChange on pointer down when disabled', () => {
@@ -1111,11 +1111,11 @@ describe('Slider', () => {
1111
1111
  expect(onChange).not.toHaveBeenCalled();
1112
1112
  });
1113
1113
 
1114
- it('remains non-focusable when disabled without a reason', () => {
1114
+ it('stays focusable when disabled without a reason', () => {
1115
1115
  render(
1116
1116
  <Slider label="Volume" value={50} valueDisplay="none" isDisabled />,
1117
1117
  );
1118
- expect(screen.getByRole('slider')).toHaveAttribute('tabindex', '-1');
1118
+ expect(screen.getByRole('slider')).toHaveAttribute('tabindex', '0');
1119
1119
  });
1120
1120
  });
1121
1121
  describe('form participation', () => {
@@ -558,15 +558,12 @@ export function Slider({ref, ...props}: SliderProps) {
558
558
 
559
559
  const handleThumbFocus = useCallback(
560
560
  (thumbIndex: number, _e: FocusEvent<HTMLDivElement>) => {
561
- // A disabled thumb draws no ring even when it stays focusable for its
562
- // reason tooltip, so there is nothing to track for one.
561
+ // A focused disabled thumb still shows the ring (spec:AST-085 FR6).
563
562
  setKeyboardFocusThumb(
564
- !isDisabled && getInteractionModality() === 'keyboard'
565
- ? thumbIndex
566
- : null,
563
+ getInteractionModality() === 'keyboard' ? thumbIndex : null,
567
564
  );
568
565
  },
569
- [isDisabled],
566
+ [],
570
567
  );
571
568
 
572
569
  const handleThumbBlur = useCallback((_e: FocusEvent<HTMLDivElement>) => {
@@ -960,10 +957,8 @@ export function Slider({ref, ...props}: SliderProps) {
960
957
  ref={!isRange ? ref : undefined}
961
958
  id={!isRange ? id : undefined}
962
959
  role="slider"
963
- // With a disabledMessage the thumb keeps focusability so the reason is
964
- // focus-discoverable; value changes stay blocked by the isDisabled
965
- // guards in the pointer/keyboard handlers.
966
- tabIndex={isDisabled && !showsDisabledMessage ? -1 : 0}
960
+ // A disabled thumb keeps its Tab stop (spec:AST-085 FR1).
961
+ tabIndex={0}
967
962
  aria-valuemin={ariaValueMin}
968
963
  aria-valuemax={ariaValueMax}
969
964
  aria-valuenow={val}
@@ -999,8 +994,7 @@ export function Slider({ref, ...props}: SliderProps) {
999
994
  : rtlStyles.centerInline('50%'),
1000
995
  !isDisabled && styles.thumbHover,
1001
996
  !isDisabled && draggingThumb === thumbIndex && styles.thumbPressed,
1002
- !isDisabled &&
1003
- keyboardFocusThumb === thumbIndex &&
997
+ keyboardFocusThumb === thumbIndex &&
1004
998
  focusOutlineStyles.focusVisible,
1005
999
  isDisabled && styles.thumbDisabled,
1006
1000
  ),
@@ -1095,7 +1089,9 @@ export function Slider({ref, ...props}: SliderProps) {
1095
1089
  }),
1096
1090
  stylex.props(styles.sliderRow),
1097
1091
  )}>
1092
+ {/* A disabled slider submits no value (spec:AST-085 FR3). */}
1098
1093
  {htmlName != null &&
1094
+ !isDisabled &&
1099
1095
  values.map((v, i) => (
1100
1096
  <input
1101
1097
  // Positional identity: index 0 is the start thumb, 1 the end.
@@ -1103,9 +1099,6 @@ export function Slider({ref, ...props}: SliderProps) {
1103
1099
  type="hidden"
1104
1100
  name={htmlName}
1105
1101
  value={String(v)}
1106
- // Disabled native controls are excluded from form submission;
1107
- // mirror that for the hidden carrier.
1108
- disabled={isDisabled}
1109
1102
  />
1110
1103
  ))}
1111
1104
  <div
@@ -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();
@@ -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', () => {
@@ -212,7 +212,7 @@ export const docs = {
212
212
  name: 'increment',
213
213
  type: 'number',
214
214
  description:
215
- 'Number of minutes to add or subtract when the user presses the up or down arrow key.',
215
+ 'Number of minutes to add or subtract when the user presses the up or down arrow key. Steps are whole minutes; a fractional value leaves the time unchanged.',
216
216
  default: '1',
217
217
  },
218
218
  {
@@ -392,7 +392,8 @@ export const docsZh = {
392
392
  {
393
393
  name: 'increment',
394
394
  type: 'number',
395
- description: '用户按上或下方向键时增加或减少的分钟数。',
395
+ description:
396
+ '用户按上或下方向键时增加或减少的分钟数。步长为整分钟;传入小数时时间保持不变。',
396
397
  default: '1',
397
398
  },
398
399
  {
@@ -623,7 +624,8 @@ export const docsDense = {
623
624
  hasClear: 'Shows clear button when value set+not disabled.',
624
625
  hourFormat:
625
626
  "Display format. '12h' shows AM/PM; '24h' uses 24-hour notation.",
626
- increment: 'Minutes to add/subtract on arrow up/down.',
627
+ increment:
628
+ 'Minutes to add/subtract on arrow up/down. Whole minutes only; a fractional value leaves the time unchanged.',
627
629
  presentation:
628
630
  "picker surface: 'adaptive-native' (default) = typed field fine / browser/OS coarse (hasSeconds or increment!=1 retains typed); 'native' = browser/OS always, no fallback; 'adaptive-bottom-sheet' = typed fine / Astryx wheel sheet coarse; 'bottom-sheet' = sheet always; 'text-input'/'popover' = typed field only.",
629
631
  nativePicker:
@@ -65,6 +65,26 @@ describe('TimeInput', () => {
65
65
  expect(onChange).toHaveBeenCalledWith('14:31');
66
66
  });
67
67
 
68
+ it('does not step into a fractional minute when increment is fractional', () => {
69
+ const onChange = vi.fn();
70
+ render(
71
+ <TimeInput
72
+ label="Time"
73
+ value={'14:30' as ISOTimeString}
74
+ increment={0.5}
75
+ onChange={onChange}
76
+ />,
77
+ );
78
+ const input = screen.getByLabelText('Time');
79
+
80
+ // `increment` is documented as minutes and typed `number`. A fractional one
81
+ // used to reach formatISOTime unrounded, which pads "30.5" to two
82
+ // characters and leaves it, so the arrow key handed the consumer a value
83
+ // that is no longer a time at all.
84
+ fireEvent.keyDown(input, {key: 'ArrowUp'});
85
+ expect(onChange).toHaveBeenCalledWith('14:30');
86
+ });
87
+
68
88
  it('displays formatted time in 12h format', () => {
69
89
  render(
70
90
  <TimeInput
@@ -286,7 +286,8 @@ export interface TimeInputProps extends Omit<
286
286
  hourFormat?: TimeInputHourFormat;
287
287
 
288
288
  /**
289
- * Increment in minutes when using arrow keys.
289
+ * Increment in minutes when using arrow keys. Steps are whole minutes: a
290
+ * fractional value leaves the time unchanged.
290
291
  * @default 1
291
292
  */
292
293
  increment?: number;
@@ -28,7 +28,7 @@ export const docs = {
28
28
  name: 'format',
29
29
  type: "'relative' | 'relative_short' | 'auto' | 'date' | 'date_long' | 'date_weekday' | 'date_time' | 'time' | 'system_date' | 'system_date_time' | 'system_time' | 'unix_seconds'",
30
30
  description:
31
- "Display format. 'relative' uses the locale's native long relative-time wording ('2 hours ago' in English), 'relative_short' uses its narrow pattern ('2h ago' in English) for compact surfaces, 'date' shows 'Mar 21, 2025', 'date_long' shows 'March 21, 2025', 'date_weekday' shows 'Wed, Mar 21, 2025', 'date_time' shows 'Mar 21, 2025, 2:51 PM', 'time' shows '2:51 PM', 'system_*' variants use ISO-style formatting, 'unix_seconds' shows the Unix time in whole seconds since the epoch (an absolute, zone-independent value), 'auto' switches from relative to date_time based on recency.",
31
+ "Display format. 'relative' uses the locale's native long relative-time wording ('2 hours ago' in English), 'relative_short' uses its narrow pattern ('2h ago' in English) for compact surfaces, 'date' shows 'Mar 21, 2025', 'date_long' shows 'March 21, 2025', 'date_weekday' shows 'Wed, Mar 21, 2025', 'date_time' shows 'Mar 21, 2025, 2:51 PM', 'time' shows '2:51 PM', 'system_*' variants use ISO-style formatting, 'unix_seconds' shows the Unix time in whole seconds since the epoch (an absolute, zone-independent value), 'auto' switches from relative to date_time based on recency. Absolute formats never read the current time. 'relative', 'relative_short' and 'auto' read it on the client only: a server render or static prerender shows the full date and time in UTC, labelled, and the client switches to the format's normal output after hydration.",
32
32
  default: "'auto'",
33
33
  },
34
34
  {
@@ -11,6 +11,9 @@ import {
11
11
  } from 'vitest';
12
12
  import {render, screen, act, waitFor, fireEvent} from '@testing-library/react';
13
13
  import userEvent from '@testing-library/user-event';
14
+ import {Profiler} from 'react';
15
+ import {hydrateRoot, type Root} from 'react-dom/client';
16
+ import {renderToString} from 'react-dom/server';
14
17
  import {Timestamp} from './Timestamp';
15
18
  import {formatInstant} from './formatInstant';
16
19
  import {formatTooltipLines} from './tooltipEntries';
@@ -1466,3 +1469,168 @@ describe('Timestamp pass-through props', () => {
1466
1469
  });
1467
1470
  });
1468
1471
  });
1472
+
1473
+ /**
1474
+ * Counts `new Date()` calls with no argument (reads of the current time) made
1475
+ * while `run` executes.
1476
+ */
1477
+ function countClockReads(run: () => void): number {
1478
+ const ActiveDate = globalThis.Date;
1479
+ let reads = 0;
1480
+ class CountingDate extends ActiveDate {
1481
+ constructor(...args: [] | [string | number | Date]) {
1482
+ if (args.length === 0) {
1483
+ reads += 1;
1484
+ }
1485
+ super(...(args as [string | number | Date]));
1486
+ }
1487
+ }
1488
+ globalThis.Date = CountingDate as DateConstructor;
1489
+ try {
1490
+ run();
1491
+ } finally {
1492
+ globalThis.Date = ActiveDate;
1493
+ }
1494
+ return reads;
1495
+ }
1496
+
1497
+ describe('Timestamp server rendering', () => {
1498
+ beforeEach(() => {
1499
+ vi.useFakeTimers();
1500
+ vi.setSystemTime(new Date('2026-03-25T12:00:00Z'));
1501
+ });
1502
+
1503
+ afterEach(() => {
1504
+ vi.useRealTimers();
1505
+ });
1506
+
1507
+ it.each(['date', 'date_time', 'time', 'unix_seconds'] as const)(
1508
+ 'never reads the current time for the absolute %s format',
1509
+ format => {
1510
+ const element = (
1511
+ <Timestamp value="2026-03-25T10:00:00Z" format={format} />
1512
+ );
1513
+ expect(countClockReads(() => renderToString(element))).toBe(0);
1514
+ expect(countClockReads(() => render(element))).toBe(0);
1515
+ },
1516
+ );
1517
+
1518
+ it.each(['relative', 'relative_short', 'auto'] as const)(
1519
+ 'renders the %s format as the full date and time in UTC on the server without reading the clock',
1520
+ format => {
1521
+ const value = '2026-03-25T10:00:00Z';
1522
+ let html = '';
1523
+ expect(
1524
+ countClockReads(() => {
1525
+ html = renderToString(<Timestamp value={value} format={format} />);
1526
+ }),
1527
+ ).toBe(0);
1528
+ const host = document.createElement('div');
1529
+ host.innerHTML = html;
1530
+ expect(host.textContent).toBe(
1531
+ formatInstant(new Date(value), 'date_time', 'en-US', {
1532
+ timeZone: 'UTC',
1533
+ isTimezoneShown: true,
1534
+ }),
1535
+ );
1536
+ },
1537
+ );
1538
+
1539
+ it('never commits the UTC placeholder when a client Timestamp switches to a relative format', () => {
1540
+ const value = Date.now() / 1000 - 7200;
1541
+ const committed: string[] = [];
1542
+ function Spy({format}: {format: 'date' | 'relative'}) {
1543
+ return (
1544
+ <Profiler
1545
+ id="ts"
1546
+ onRender={() => {
1547
+ committed.push(document.querySelector('time')?.textContent ?? '');
1548
+ }}>
1549
+ <Timestamp value={value} format={format} hasTooltip={false} />
1550
+ </Profiler>
1551
+ );
1552
+ }
1553
+ const {rerender} = render(<Spy format="date" />);
1554
+ committed.length = 0;
1555
+ rerender(<Spy format="relative" />);
1556
+ expect(screen.getByText('2 hours ago')).toBeInTheDocument();
1557
+ expect(committed.some(text => text.includes('UTC'))).toBe(false);
1558
+ });
1559
+
1560
+ describe('across time zones', () => {
1561
+ const originalTZ = process.env.TZ;
1562
+
1563
+ afterEach(() => {
1564
+ if (originalTZ === undefined) {
1565
+ delete process.env.TZ;
1566
+ } else {
1567
+ process.env.TZ = originalTZ;
1568
+ }
1569
+ });
1570
+
1571
+ it('writes the same server markup whatever the server time zone', () => {
1572
+ const element = (
1573
+ <Timestamp value="2026-03-25T10:00:00Z" format="relative" />
1574
+ );
1575
+ process.env.TZ = 'UTC';
1576
+ const utc = renderToString(element);
1577
+ process.env.TZ = 'Asia/Tokyo';
1578
+ expect(renderToString(element)).toBe(utc);
1579
+ });
1580
+
1581
+ it('hydrates server markup from another time zone without a mismatch', () => {
1582
+ const value = Date.now() / 1000 - 7200;
1583
+ const element = (
1584
+ <Timestamp value={value} format="relative" hasTooltip={false} />
1585
+ );
1586
+ process.env.TZ = 'UTC';
1587
+ const host = document.createElement('div');
1588
+ document.body.appendChild(host);
1589
+ host.innerHTML = renderToString(element);
1590
+
1591
+ process.env.TZ = 'America/Los_Angeles';
1592
+ const errors = vi.spyOn(console, 'error').mockImplementation(() => {});
1593
+ let root: Root | undefined;
1594
+ try {
1595
+ act(() => {
1596
+ root = hydrateRoot(host, element, {
1597
+ onRecoverableError: error => {
1598
+ throw error;
1599
+ },
1600
+ });
1601
+ });
1602
+ expect(host.textContent).toBe('2 hours ago');
1603
+ expect(errors).not.toHaveBeenCalled();
1604
+ } finally {
1605
+ errors.mockRestore();
1606
+ act(() => root?.unmount());
1607
+ host.remove();
1608
+ }
1609
+ });
1610
+ });
1611
+
1612
+ it('hydrates the server markup without a mismatch, then shows the relative phrase', () => {
1613
+ const value = Date.now() / 1000 - 7200;
1614
+ const element = (
1615
+ <Timestamp value={value} format="relative" hasTooltip={false} />
1616
+ );
1617
+ const host = document.createElement('div');
1618
+ document.body.appendChild(host);
1619
+ host.innerHTML = renderToString(element);
1620
+ expect(host.textContent).not.toBe('2 hours ago');
1621
+
1622
+ const errors = vi.spyOn(console, 'error').mockImplementation(() => {});
1623
+ let root: Root | undefined;
1624
+ try {
1625
+ act(() => {
1626
+ root = hydrateRoot(host, element);
1627
+ });
1628
+ expect(host.textContent).toBe('2 hours ago');
1629
+ expect(errors).not.toHaveBeenCalled();
1630
+ } finally {
1631
+ errors.mockRestore();
1632
+ act(() => root?.unmount());
1633
+ host.remove();
1634
+ }
1635
+ });
1636
+ });