@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
@@ -243,12 +243,35 @@ describe('FileInput', () => {
243
243
  expect(trigger).not.toHaveAttribute('aria-required');
244
244
  });
245
245
 
246
- it('sets disabled attribute when isDisabled is true', () => {
246
+ it('keeps a disabled input focusable and out of form submission', async () => {
247
+ const user = userEvent.setup();
247
248
  render(
248
- <FileInput label="Upload" isDisabled value={null} onChange={() => {}} />,
249
+ <form>
250
+ <FileInput
251
+ label="Upload"
252
+ isRequired
253
+ isDisabled
254
+ value={null}
255
+ onChange={() => {}}
256
+ />
257
+ </form>,
249
258
  );
250
- const input = document.querySelector('input[type="file"]')!;
251
- expect(input).toBeDisabled();
259
+ const input =
260
+ document.querySelector<HTMLInputElement>('input[type="file"]')!;
261
+ const trigger = screen.getByRole('button', {name: 'Upload'});
262
+ expect(input).not.toHaveAttribute('disabled');
263
+ expect(trigger).not.toHaveAttribute('disabled');
264
+ expect(trigger).toHaveAttribute('aria-disabled', 'true');
265
+ const openPicker = vi.spyOn(input, 'click');
266
+ await user.tab();
267
+ expect(trigger).toHaveFocus();
268
+ await user.keyboard('{Enter}');
269
+ expect(openPicker).not.toHaveBeenCalled();
270
+ expect(input.form).toBeNull();
271
+ expect(trigger.closest('form')!.checkValidity()).toBe(true);
272
+ // A label click activates the hidden input directly; it is canceled so
273
+ // the picker never opens (fireEvent returns false when prevented).
274
+ expect(fireEvent.click(input)).toBe(false);
252
275
  });
253
276
 
254
277
  it('sets aria-invalid when status type is error', () => {
@@ -1095,7 +1118,7 @@ describe('FileInput', () => {
1095
1118
  expect(clickSpy).not.toHaveBeenCalled();
1096
1119
  });
1097
1120
 
1098
- it('keeps the trigger non-focusable when disabled without a reason', () => {
1121
+ it('keeps the trigger focusable when disabled without a reason', () => {
1099
1122
  render(
1100
1123
  <FileInput
1101
1124
  label="Resume"
@@ -1105,8 +1128,8 @@ describe('FileInput', () => {
1105
1128
  />,
1106
1129
  );
1107
1130
  const trigger = screen.getByRole('button');
1108
- expect(trigger).toHaveAttribute('tabindex', '-1');
1109
- expect(trigger).not.toHaveAttribute('aria-disabled');
1131
+ expect(trigger).toHaveAttribute('tabindex', '0');
1132
+ expect(trigger).toHaveAttribute('aria-disabled', 'true');
1110
1133
  });
1111
1134
  });
1112
1135
  });
@@ -179,7 +179,12 @@ const styles = stylex.create({
179
179
  dropzoneDisabled: {
180
180
  cursor: 'default',
181
181
  opacity: 0.5,
182
- borderColor: colorVars['--color-border-emphasized'],
182
+ // Keeps the :focus-within border: a focused disabled trigger still shows
183
+ // focus (spec:AST-085 FR6).
184
+ borderColor: {
185
+ default: colorVars['--color-border-emphasized'],
186
+ ':focus-within': colorVars['--color-accent'],
187
+ },
183
188
  },
184
189
  compact: {
185
190
  boxSizing: 'border-box',
@@ -221,7 +226,12 @@ const styles = stylex.create({
221
226
  compactDisabled: {
222
227
  cursor: 'default',
223
228
  opacity: 0.5,
224
- borderColor: colorVars['--color-border-emphasized'],
229
+ // Keeps the :focus-within border: a focused disabled trigger still shows
230
+ // focus (spec:AST-085 FR6).
231
+ borderColor: {
232
+ default: colorVars['--color-border-emphasized'],
233
+ ':focus-within': colorVars['--color-accent'],
234
+ },
225
235
  },
226
236
  hiddenInput: {
227
237
  position: 'absolute',
@@ -857,14 +867,9 @@ export function FileInput({
857
867
  <VisuallyHidden>
858
868
  <button
859
869
  type="button"
860
- // With a disabledMessage the trigger keeps focusability via
861
- // aria-disabled so the reason is focus-discoverable; opening the
862
- // picker is still blocked by the isDisabled guards in the handlers.
863
- // Otherwise the native disabled attribute removes it from the tab
864
- // order.
865
- disabled={isDisabled && !showsDisabledMessage}
866
- tabIndex={isDisabled && !showsDisabledMessage ? -1 : 0}
867
- aria-disabled={showsDisabledMessage ? 'true' : undefined}
870
+ // A disabled trigger keeps its Tab stop (spec:AST-085 FR1).
871
+ tabIndex={0}
872
+ aria-disabled={isDisabled ? 'true' : undefined}
868
873
  onClick={handleClick}
869
874
  onKeyDown={handleKeyDown}
870
875
  // aria-label suppresses child content from the accessible name, so
@@ -888,7 +893,12 @@ export function FileInput({
888
893
  type="file"
889
894
  accept={accept}
890
895
  multiple={isMultiple}
891
- disabled={isDisabled}
896
+ // Detached from its form while disabled, so selected files are not
897
+ // submitted and `required` does not block submission (FR3).
898
+ form={isDisabled ? '' : undefined}
899
+ // A label or description click activates this input directly; cancel
900
+ // it so a disabled input never opens the picker (FR2).
901
+ onClick={isDisabled ? event => event.preventDefault() : rest.onClick}
892
902
  onChange={handleInputChange}
893
903
  // The visually-hidden native input is never focused (tabIndex={-1});
894
904
  // its describedby/required/invalid live on the trigger button.
@@ -58,10 +58,25 @@ describe('Link', () => {
58
58
  expect(handleClick).toHaveBeenCalledTimes(1);
59
59
  });
60
60
 
61
- it('button fallback supports isDisabled', () => {
62
- render(<Link isDisabled>Disabled Action</Link>);
61
+ it('button fallback stays focusable and inert when isDisabled', async () => {
62
+ const user = userEvent.setup();
63
+ const onClick = vi.fn();
64
+ render(
65
+ <Link isDisabled onClick={onClick}>
66
+ Disabled Action
67
+ </Link>,
68
+ );
63
69
  const button = screen.getByRole('button');
64
- expect(button).toBeDisabled();
70
+ expect(button).not.toHaveAttribute('disabled');
71
+ expect(button).toHaveAttribute('aria-disabled', 'true');
72
+ const nativeClick = vi.fn();
73
+ button.addEventListener('click', nativeClick);
74
+ await user.tab();
75
+ expect(button).toHaveFocus();
76
+ await user.keyboard('{Enter} ');
77
+ expect(nativeClick).not.toHaveBeenCalled();
78
+ fireEvent.click(button);
79
+ expect(onClick).not.toHaveBeenCalled();
65
80
  });
66
81
 
67
82
  it('button fallback has type="button"', () => {
@@ -156,11 +171,10 @@ describe('Link', () => {
156
171
  Disabled Link
157
172
  </Link>,
158
173
  );
159
- // An href-less anchor has no implicit `link` role, so query by text.
160
- const link = screen.getByText('Disabled Link').closest('a');
161
- expect(link).not.toBeNull();
174
+ // The href-less anchor keeps a Tab stop and an explicit link role.
175
+ const link = screen.getByRole('link', {name: 'Disabled Link'});
162
176
  expect(link).toHaveAttribute('aria-disabled', 'true');
163
- expect(link).toHaveAttribute('tabIndex', '-1');
177
+ expect(link).toHaveAttribute('tabIndex', '0');
164
178
  // Visual classes are preserved in the disabled state.
165
179
  expect(link?.className).toContain('astryx-link');
166
180
  });
package/src/Link/Link.tsx CHANGED
@@ -48,6 +48,7 @@ import {focusOutlineProps} from '../utils/focusOutline.stylex';
48
48
  import {interactionOverlayStyles} from '../utils/interactionOverlay.stylex';
49
49
  import {usePressFeedback} from '../hooks/usePressFeedback';
50
50
  import {useTranslator} from '../i18n';
51
+ import {disabledInteractionProps} from '../utils/disabledInteraction';
51
52
 
52
53
  /**
53
54
  * Base link styles
@@ -174,9 +175,8 @@ export interface LinkProps extends BaseProps<
174
175
  /**
175
176
  * Whether the link is disabled.
176
177
  * A disabled link renders as a plain anchor without an href (and without
177
- * target/rel/onClick), so it cannot be focused or activated — no
178
- * navigation and no onClick, even via programmatic focus or assistive
179
- * technology activation.
178
+ * target/rel/onClick), so it cannot navigate or run onClick. It keeps a tab
179
+ * stop and is announced as a disabled link.
180
180
  * @default false
181
181
  */
182
182
  isDisabled?: boolean;
@@ -264,15 +264,6 @@ export interface LinkProps extends BaseProps<
264
264
  children: ReactNode;
265
265
  }
266
266
 
267
- /**
268
- * Click handler for disabled links. The disabled anchor renders without an
269
- * href, so there is no navigation to block in practice; preventDefault is a
270
- * defensive guard against synthetic/programmatic clicks.
271
- */
272
- function preventDefaultClick(event: React.MouseEvent<HTMLAnchorElement>): void {
273
- event.preventDefault();
274
- }
275
-
276
267
  /**
277
268
  * A styled anchor link component.
278
269
  *
@@ -367,8 +358,6 @@ export function Link({
367
358
  {...(isDisabled ? undefined : pressable)}
368
359
  aria-label={label || undefined}
369
360
  aria-disabled={isDisabled || undefined}
370
- tabIndex={isDisabled ? -1 : undefined}
371
- disabled={isDisabled}
372
361
  {...mergeProps(
373
362
  themeProps('link', {color}),
374
363
  focusOutlineProps.focusVisible(
@@ -386,24 +375,26 @@ export function Link({
386
375
  className,
387
376
  style,
388
377
  )}
389
- {...props}>
378
+ {...props}
379
+ // Focusable while disabled (spec:AST-085 FR1), but inert (FR2).
380
+ {...(isDisabled ? disabledInteractionProps(props.onKeyDown) : null)}>
390
381
  {sharedContent}
391
382
  </button>
392
383
  );
393
384
  } else if (isDisabled) {
394
- // A disabled link renders as a plain <a> with no href: an href-less
395
- // anchor is not focusable and exposes no link affordance, so programmatic
396
- // focus + Enter, AT activation commands, and middle-click cannot navigate
397
- // or fire the consumer onClick. The router LinkComponent is deliberately
398
- // skipped — a disabled link performs no navigation, and custom router
399
- // links may require a live href. target/rel are omitted with the href.
385
+ // A disabled link renders as an <a> with no href, so nothing can navigate
386
+ // and the consumer onClick never fires. It keeps a Tab stop and the link
387
+ // role so keyboard and screen-reader users still find it (spec:AST-085
388
+ // FR1, FR5). The router LinkComponent is deliberately skipped — a disabled
389
+ // link performs no navigation, and custom router links may require a live
390
+ // href. target/rel are omitted with the href.
400
391
  linkElement = (
401
392
  <a
402
393
  ref={ref as React.Ref<HTMLAnchorElement>}
403
- onClick={preventDefaultClick}
394
+ role="link"
404
395
  aria-label={label || undefined}
405
396
  aria-disabled={true}
406
- tabIndex={-1}
397
+ tabIndex={0}
407
398
  {...mergeProps(
408
399
  themeProps('link', {color}),
409
400
  focusOutlineProps.focusVisible(
@@ -417,7 +408,8 @@ export function Link({
417
408
  className,
418
409
  style,
419
410
  )}
420
- {...props}>
411
+ {...props}
412
+ {...disabledInteractionProps(props.onKeyDown)}>
421
413
  {sharedContent}
422
414
  </a>
423
415
  );
@@ -73,7 +73,7 @@ export const LINK_A11Y_EXCLUSIONS = [
73
73
  {
74
74
  owner: 'Link component tests and family:navigation-destinations FR7',
75
75
  reason:
76
- 'A disabled Link renders an href-less anchor that is neither a link nor focusable. That disabled semantics is member-owned, so disabled states are not bound to this available-link contract.',
76
+ 'A disabled Link renders a focusable, href-less anchor with an explicit link role and aria-disabled, so it cannot navigate. That disabled semantics is member-owned, so disabled states are not bound to this available-link contract.',
77
77
  },
78
78
  {
79
79
  owner: 'Link component tests (button rendering)',
@@ -0,0 +1,143 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Markdown.rerender.test.tsx
5
+ * @input Uses vitest, @testing-library/react, Markdown, a synthetic long document
6
+ * @output Tests that an unrelated caller re-render reuses the rendered document
7
+ * @position Performance regression evidence for long documents. Counting
8
+ * renders of a caller-supplied block renderer is deterministic, unlike a
9
+ * timing budget, and fails when Markdown rebuilds every block element on a
10
+ * re-render that changed nothing it reads.
11
+ */
12
+
13
+ import {useState} from 'react';
14
+ import {act, cleanup, fireEvent, render} from '@testing-library/react';
15
+ import {afterEach, describe, expect, it, vi} from 'vitest';
16
+ import {Markdown} from './Markdown';
17
+ import type {MarkdownComponents} from './Markdown';
18
+
19
+ afterEach(() => {
20
+ cleanup();
21
+ });
22
+
23
+ /** A long, review-style document: headings, prose, lists, a table, code. */
24
+ function longDocument(sections: number): string {
25
+ return Array.from({length: sections}, (_, i) =>
26
+ [
27
+ `## Section ${i}`,
28
+ '',
29
+ `Paragraph ${i} with **bold**, *emphasis*, \`code\` and a [link](/docs/${i}).`,
30
+ '',
31
+ `- Point ${i}`,
32
+ ` - Nested point ${i}`,
33
+ `- [x] Done ${i}`,
34
+ '',
35
+ '| Name | Count |',
36
+ '| --- | ---: |',
37
+ `| row ${i} | ${i * 3} |`,
38
+ '',
39
+ '```ts',
40
+ `const value${i} = ${i};`,
41
+ '```',
42
+ ].join('\n'),
43
+ ).join('\n\n');
44
+ }
45
+
46
+ const SECTIONS = 40;
47
+
48
+ function createCountingComponents(): {
49
+ components: Partial<MarkdownComponents>;
50
+ renders: () => number;
51
+ } {
52
+ let count = 0;
53
+ function CountingCode({code}: {code: string; language?: string}) {
54
+ count++;
55
+ return <pre data-testid="code">{code}</pre>;
56
+ }
57
+ return {components: {code: CountingCode}, renders: () => count};
58
+ }
59
+
60
+ describe('Markdown re-render reuse', () => {
61
+ it('does not re-render any block when a caller re-render changes nothing Markdown reads', () => {
62
+ const {components, renders} = createCountingComponents();
63
+ const source = longDocument(SECTIONS);
64
+ let bump: () => void = () => {};
65
+ function Page() {
66
+ const [tick, setTick] = useState(0);
67
+ bump = () => setTick(t => t + 1);
68
+ return (
69
+ <section data-tick={tick}>
70
+ {/* A new link handler each render, as inline callers write it. */}
71
+ <Markdown components={components} onLinkClick={() => {}}>
72
+ {source}
73
+ </Markdown>
74
+ </section>
75
+ );
76
+ }
77
+ const {container} = render(<Page />);
78
+ const document = container.querySelector('[role="document"]');
79
+ const html = document?.innerHTML;
80
+ expect(renders()).toBe(SECTIONS);
81
+
82
+ act(() => bump());
83
+ act(() => bump());
84
+
85
+ expect(renders()).toBe(SECTIONS);
86
+ expect(container.querySelector('[role="document"]')).toBe(document);
87
+ expect(document?.innerHTML).toBe(html);
88
+ });
89
+
90
+ it('re-renders when the text changes', () => {
91
+ const {components, renders} = createCountingComponents();
92
+ const {rerender, getAllByTestId} = render(
93
+ <Markdown components={components}>{longDocument(2)}</Markdown>,
94
+ );
95
+ expect(renders()).toBe(2);
96
+ rerender(
97
+ <Markdown components={components}>
98
+ {`${longDocument(2)}\n\n\`\`\`ts\nconst added = true;\n\`\`\``}
99
+ </Markdown>,
100
+ );
101
+ expect(getAllByTestId('code')).toHaveLength(3);
102
+ expect(getAllByTestId('code')[2].textContent).toBe('const added = true;');
103
+ });
104
+
105
+ it('re-renders when a rendering prop changes', () => {
106
+ const {container, rerender} = render(
107
+ <Markdown headingLevelStart={1}>{'# Title'}</Markdown>,
108
+ );
109
+ expect(container.querySelector('h1')).not.toBeNull();
110
+ rerender(<Markdown headingLevelStart={3}>{'# Title'}</Markdown>);
111
+ expect(container.querySelector('h1')).toBeNull();
112
+ expect(container.querySelector('h3')?.textContent).toBe('Title');
113
+ });
114
+
115
+ it('calls the latest link handler after a re-render that reused the document', () => {
116
+ const first = vi.fn();
117
+ const latest = vi.fn(() => false as const);
118
+ const {container, rerender} = render(
119
+ <Markdown onLinkClick={first}>{'[go](/next)'}</Markdown>,
120
+ );
121
+ rerender(<Markdown onLinkClick={latest}>{'[go](/next)'}</Markdown>);
122
+ const link = container.querySelector('a');
123
+ expect(link).not.toBeNull();
124
+ const event = new MouseEvent('click', {bubbles: true, cancelable: true});
125
+ act(() => {
126
+ link?.dispatchEvent(event);
127
+ });
128
+ expect(first).not.toHaveBeenCalled();
129
+ expect(latest).toHaveBeenCalledWith('/next', expect.anything());
130
+ expect(event.defaultPrevented).toBe(true);
131
+ });
132
+
133
+ it('starts and stops handling link clicks when the handler is added or removed', () => {
134
+ const handler = vi.fn();
135
+ const {container, rerender} = render(<Markdown>{'[go](/next)'}</Markdown>);
136
+ rerender(<Markdown onLinkClick={handler}>{'[go](/next)'}</Markdown>);
137
+ fireEvent.click(container.querySelector('a') as HTMLAnchorElement);
138
+ expect(handler).toHaveBeenCalledTimes(1);
139
+ rerender(<Markdown>{'[go](/next)'}</Markdown>);
140
+ fireEvent.click(container.querySelector('a') as HTMLAnchorElement);
141
+ expect(handler).toHaveBeenCalledTimes(1);
142
+ });
143
+ });
@@ -41,6 +41,7 @@ import {TableHeaderCell} from '../Table/TableHeaderCell';
41
41
  import {TableHeader} from '../Table/TableHeader';
42
42
  import {TableBody} from '../Table/TableBody';
43
43
  import {mergeProps} from '../utils';
44
+ import {useIsomorphicLayoutEffect} from '../hooks/useIsomorphicLayoutEffect';
44
45
  import {useStreamingText} from '../hooks/useStreamingText';
45
46
  import {computeBoundaries, computeSegments} from './streaming';
46
47
  import type {BaseProps} from '../BaseProps';
@@ -2122,6 +2123,21 @@ export function Markdown<
2122
2123
  }: MarkdownProps<Plugins>): React.ReactElement {
2123
2124
  const t = useTranslator();
2124
2125
  const LinkComponent = useLinkComponent();
2126
+ // Links read the caller's handler when clicked, so a handler recreated on
2127
+ // every caller render does not invalidate the memoized document below.
2128
+ // Only whether a handler exists changes the rendered links.
2129
+ const onLinkClickRef = useRef(onLinkClick);
2130
+ useIsomorphicLayoutEffect(() => {
2131
+ onLinkClickRef.current = onLinkClick;
2132
+ });
2133
+ const hasLinkClick = onLinkClick != null;
2134
+ const stableOnLinkClick = useMemo<MarkdownProps['onLinkClick']>(
2135
+ () =>
2136
+ hasLinkClick
2137
+ ? (href, event) => onLinkClickRef.current?.(href, event)
2138
+ : undefined,
2139
+ [hasLinkClick],
2140
+ );
2125
2141
  // Derive the set of source IDs for the parser (stable across renders when sources don't change)
2126
2142
  const sourceIds = useMemo(
2127
2143
  () => (sources ? new Set(Object.keys(sources)) : undefined),
@@ -2327,19 +2343,80 @@ export function Markdown<
2327
2343
  return next;
2328
2344
  }, [display, smoothedLen, maxSpans]);
2329
2345
 
2330
- const cursor: StreamingCursor = {
2331
- offset: 0,
2332
- boundaries,
2333
- active: isStreaming,
2334
- };
2335
-
2336
- // Build citation context — numbers are assigned in encounter order during rendering.
2337
- // This is recreated each render so numbering stays consistent with the AST.
2338
- const citationCtx: CitationContext | null = sources
2339
- ? {sources, numberMap: new Map(), nextNumber: 1, style: citationStyle}
2346
+ const contentWidthValue = contentWidth
2347
+ ? typeof contentWidth === 'number'
2348
+ ? `${contentWidth}px`
2349
+ : contentWidth
2340
2350
  : null;
2341
2351
 
2352
+ // The block document is the expensive part of a long Markdown render. It
2353
+ // depends only on these inputs, so a caller re-render that changes none of
2354
+ // them returns the same elements and React skips the whole subtree. The
2355
+ // cursor and citation numbering are rebuilt inside, from the start of the
2356
+ // document, exactly as a full render builds them.
2357
+ const renderedBlocks = useMemo(() => {
2358
+ if (display === 'inline') {
2359
+ return null;
2360
+ }
2361
+ const blockCursor: StreamingCursor = {
2362
+ offset: 0,
2363
+ boundaries,
2364
+ active: isStreaming,
2365
+ };
2366
+ // Numbers are assigned in encounter order during rendering.
2367
+ const blockCitationCtx: CitationContext | null = sources
2368
+ ? {sources, numberMap: new Map(), nextNumber: 1, style: citationStyle}
2369
+ : null;
2370
+ return blocks.map((block, i) =>
2371
+ renderBlock(
2372
+ block,
2373
+ i,
2374
+ blocks.length,
2375
+ density,
2376
+ headingLevelStart,
2377
+ stableOnLinkClick,
2378
+ blockCursor,
2379
+ blockCitationCtx,
2380
+ contentWidthValue,
2381
+ contentAlign,
2382
+ LinkComponent,
2383
+ inlinePlugins,
2384
+ components,
2385
+ preparedPlugins,
2386
+ t,
2387
+ headingProjection,
2388
+ ),
2389
+ );
2390
+ }, [
2391
+ display,
2392
+ blocks,
2393
+ boundaries,
2394
+ isStreaming,
2395
+ sources,
2396
+ citationStyle,
2397
+ density,
2398
+ headingLevelStart,
2399
+ stableOnLinkClick,
2400
+ contentWidthValue,
2401
+ contentAlign,
2402
+ LinkComponent,
2403
+ inlinePlugins,
2404
+ components,
2405
+ preparedPlugins,
2406
+ t,
2407
+ headingProjection,
2408
+ ]);
2409
+
2342
2410
  if (display === 'inline') {
2411
+ const cursor: StreamingCursor = {
2412
+ offset: 0,
2413
+ boundaries,
2414
+ active: isStreaming,
2415
+ };
2416
+ // Numbers are assigned in encounter order during rendering.
2417
+ const citationCtx: CitationContext | null = sources
2418
+ ? {sources, numberMap: new Map(), nextNumber: 1, style: citationStyle}
2419
+ : null;
2343
2420
  const renderedInline = (
2344
2421
  <span
2345
2422
  ref={ref}
@@ -2357,7 +2434,7 @@ export function Markdown<
2357
2434
  renderInline(
2358
2435
  node,
2359
2436
  i,
2360
- onLinkClick,
2437
+ stableOnLinkClick,
2361
2438
  cursor,
2362
2439
  citationCtx,
2363
2440
  LinkComponent,
@@ -2391,30 +2468,7 @@ export function Markdown<
2391
2468
  className,
2392
2469
  style,
2393
2470
  )}>
2394
- {blocks.map((block, i) =>
2395
- renderBlock(
2396
- block,
2397
- i,
2398
- blocks.length,
2399
- density,
2400
- headingLevelStart,
2401
- onLinkClick,
2402
- cursor,
2403
- citationCtx,
2404
- contentWidth
2405
- ? typeof contentWidth === 'number'
2406
- ? `${contentWidth}px`
2407
- : contentWidth
2408
- : null,
2409
- contentAlign,
2410
- LinkComponent,
2411
- inlinePlugins,
2412
- components,
2413
- preparedPlugins,
2414
- t,
2415
- headingProjection,
2416
- ),
2417
- )}
2471
+ {renderedBlocks}
2418
2472
  </div>
2419
2473
  );
2420
2474
 
@@ -0,0 +1,71 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file MultiSelector.disabledFocus.a11y.chromium.spec.ts
5
+ * @input The `Core/MultiSelector` Default story in a built Storybook.
6
+ * @output Real-engine proof of `spec:AST-085` FR4: a trigger disabled while it
7
+ * holds keyboard focus keeps focus and reports itself disabled.
8
+ * @position Run with `pnpm test:a11y-contract`; a DOM emulator does not move
9
+ * focus off an element that becomes disabled, so it cannot show the loss.
10
+ */
11
+
12
+ import {expect, test} from '@playwright/test';
13
+ import {
14
+ DEFAULT_STORYBOOK_DIR,
15
+ serveStorybook,
16
+ type StaticServer,
17
+ } from '@astryxdesign/a11y-spec/storybook';
18
+
19
+ let storybook: StaticServer;
20
+
21
+ test.beforeAll(async () => {
22
+ storybook = await serveStorybook(
23
+ process.env.ASTRYX_STORYBOOK_DIR ?? DEFAULT_STORYBOOK_DIR,
24
+ );
25
+ });
26
+
27
+ test.afterAll(async () => {
28
+ await storybook?.close();
29
+ });
30
+
31
+ test('a focused trigger keeps focus when it becomes disabled', async ({
32
+ page,
33
+ }) => {
34
+ const storyId = 'core-multiselector--default';
35
+ await page.goto(
36
+ `${storybook.origin}/iframe.html?id=${storyId}&viewMode=story`,
37
+ );
38
+ const trigger = page.getByRole('combobox', {name: 'Columns'});
39
+ await trigger.focus();
40
+ await expect(trigger).toBeFocused();
41
+
42
+ await page.evaluate(id => {
43
+ const channel = (
44
+ window as unknown as {
45
+ __STORYBOOK_ADDONS_CHANNEL__?: {
46
+ emit: (event: string, payload: unknown) => void;
47
+ };
48
+ }
49
+ ).__STORYBOOK_ADDONS_CHANNEL__;
50
+ if (channel == null) {
51
+ throw new Error('Storybook channel is unavailable');
52
+ }
53
+ channel.emit('updateStoryArgs', {
54
+ storyId: id,
55
+ updatedArgs: {isDisabled: true},
56
+ });
57
+ }, storyId);
58
+
59
+ // Wait for the disabled re-render in either form, so focus is checked
60
+ // after it lands.
61
+ await expect
62
+ .poll(async () =>
63
+ trigger.evaluate(
64
+ el =>
65
+ el.hasAttribute('disabled') ||
66
+ el.getAttribute('aria-disabled') === 'true',
67
+ ),
68
+ )
69
+ .toBe(true);
70
+ await expect(trigger).toBeFocused();
71
+ });