@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
@@ -17,7 +17,14 @@
17
17
  * - /packages/cli/assets/templates/blocks/components/Timestamp/ (showcase blocks)
18
18
  */
19
19
 
20
- import {lazy, Suspense, useEffect, useRef, useState} from 'react';
20
+ import {
21
+ lazy,
22
+ Suspense,
23
+ useEffect,
24
+ useRef,
25
+ useState,
26
+ useSyncExternalStore,
27
+ } from 'react';
21
28
  import * as stylex from '@stylexjs/stylex';
22
29
  import {Text} from '../Text';
23
30
  import type {TextType, TextSize, TextColor, TextWeight} from '../theme/types';
@@ -215,6 +222,13 @@ function getLiveInterval(diffSeconds: number): number {
215
222
  return 300_000; // every 5 minutes
216
223
  }
217
224
 
225
+ // Whether this render may read the current time. Server renders (including
226
+ // static prerenders) and the hydration pass read `false`, so the markup never
227
+ // depends on when it was rendered; client renders read `true`.
228
+ const subscribeToNothing = () => () => {};
229
+ const canReadClockOnClient = () => true;
230
+ const canReadClockOnServer = () => false;
231
+
218
232
  /** Whether a format is non-relative (i.e. shows a fixed date/time). */
219
233
  function isAbsoluteFormat(
220
234
  format: TimestampFormat,
@@ -279,7 +293,29 @@ export function Timestamp({
279
293
  const locale = useLocale();
280
294
  const timeRef = useRef<HTMLTimeElement>(null);
281
295
  const mergedTimeRef = useMergedRefs(ref, timeRef);
282
- const [now, setNow] = useState(() => new Date());
296
+ // Only relative and auto formats depend on the current time. Absolute
297
+ // formats never read the clock, so they render identically on the server,
298
+ // in a static prerender, and on the client. A clock-dependent format reads
299
+ // it on the client only: server renders and the hydration pass show the
300
+ // full date and time in UTC, and the client switches to the relative phrase
301
+ // right after hydration.
302
+ const needsClock = !isAbsoluteFormat(format);
303
+ const canReadClock = useSyncExternalStore(
304
+ subscribeToNothing,
305
+ canReadClockOnClient,
306
+ canReadClockOnServer,
307
+ );
308
+ const [now, setNow] = useState<Date | null>(() =>
309
+ needsClock && canReadClock ? new Date() : null,
310
+ );
311
+ // Read the clock during render once it may be read: after hydration (the
312
+ // store re-renders with its client value) and when `format` switches from
313
+ // an absolute value to a clock-dependent one, so the placeholder never
314
+ // commits on the client.
315
+ if (needsClock && canReadClock && now === null) {
316
+ // eslint-disable-next-line @eslint-react/purity -- seeds clock state once, like the initializer; never runs on the server or while hydrating
317
+ setNow(new Date());
318
+ }
283
319
 
284
320
  const date = parseValue(value);
285
321
  // An unparseable value (a malformed date string, or a NaN timestamp from
@@ -289,25 +325,40 @@ export function Timestamp({
289
325
  const isValidDate = !Number.isNaN(date.getTime());
290
326
  const isoString = isValidDate ? date.toISOString() : '';
291
327
 
292
- // Determine effective format
293
- const diffSeconds = Math.round((now.getTime() - date.getTime()) / 1000);
294
- const effectiveFormat: TimestampFormat =
295
- format === 'auto'
328
+ // Determine effective format. Until the clock is read, a clock-dependent
329
+ // format renders as the full date and time.
330
+ const isClockPending = needsClock && now === null;
331
+ const diffSeconds =
332
+ now === null ? 0 : Math.round((now.getTime() - date.getTime()) / 1000);
333
+ const effectiveFormat: TimestampFormat = isClockPending
334
+ ? 'date_time'
335
+ : format === 'auto'
296
336
  ? Math.abs(diffSeconds) <= autoThreshold
297
337
  ? 'relative'
298
338
  : 'date_time'
299
339
  : format;
300
340
 
301
- // Format the display text. No time zone is passed: the visible text always
302
- // reads in the viewer's own zone, and only the tooltip names others.
341
+ // Format the display text in the viewer's own zone; only the tooltip names
342
+ // others. The one exception is the pre-hydration placeholder, written in
343
+ // UTC with its label so every zone produces the same markup.
303
344
  const displayText = !isValidDate
304
345
  ? ''
305
346
  : effectiveFormat === 'relative'
306
- ? formatRelativeTime(date, now, locale, 'long')
347
+ ? formatRelativeTime(date, now ?? date, locale, 'long')
307
348
  : effectiveFormat === 'relative_short'
308
- ? formatRelativeTime(date, now, locale, 'narrow')
349
+ ? formatRelativeTime(date, now ?? date, locale, 'narrow')
309
350
  : isAbsoluteFormat(effectiveFormat)
310
- ? formatInstant(date, effectiveFormat, locale, {isTimezoneShown})
351
+ ? formatInstant(
352
+ date,
353
+ effectiveFormat,
354
+ locale,
355
+ // The pre-hydration placeholder is written in UTC, labelled, so
356
+ // the server and the hydrating client produce the same text in
357
+ // every time zone.
358
+ isClockPending
359
+ ? {timeZone: 'UTC', isTimezoneShown: true}
360
+ : {isTimezoneShown},
361
+ )
311
362
  : '';
312
363
 
313
364
  // Full absolute text for the tooltip (visible — keeps the compact timezone
@@ -134,8 +134,10 @@ describe('Token', () => {
134
134
  />,
135
135
  );
136
136
  const button = screen.getByRole('button', {name: 'Disabled'});
137
- expect(button).toBeDisabled();
137
+ expect(button).not.toHaveAttribute('disabled');
138
+ expect(button).toHaveAttribute('aria-disabled', 'true');
138
139
  expect(button.tagName).toBe('BUTTON');
140
+ fireEvent.click(button);
139
141
  // Container click is also disabled
140
142
  const container = screen.getByTestId('token');
141
143
  fireEvent.click(container);
@@ -254,14 +256,34 @@ describe('Token accessibility', () => {
254
256
  ).toBeInTheDocument();
255
257
  });
256
258
 
257
- it('disables both buttons when isDisabled is true', () => {
259
+ it('keeps both buttons focusable and inert when isDisabled is true', async () => {
260
+ const user = userEvent.setup();
261
+ const handleClick = vi.fn();
262
+ const handleRemove = vi.fn();
258
263
  render(
259
- <Token label="Token" onClick={() => {}} onRemove={() => {}} isDisabled />,
264
+ <Token
265
+ label="Token"
266
+ onClick={handleClick}
267
+ onRemove={handleRemove}
268
+ isDisabled
269
+ />,
260
270
  );
261
271
  const buttons = screen.getAllByRole('button');
272
+ expect(buttons).toHaveLength(2);
262
273
  for (const button of buttons) {
263
- expect(button).toBeDisabled();
274
+ expect(button).not.toHaveAttribute('disabled');
275
+ expect(button).toHaveAttribute('aria-disabled', 'true');
276
+ // A wrapping Popover listens for clicks on the button itself.
277
+ const nativeClick = vi.fn();
278
+ button.addEventListener('click', nativeClick);
279
+ await user.tab();
280
+ expect(button).toHaveFocus();
281
+ await user.keyboard('{Enter} ');
282
+ expect(nativeClick).not.toHaveBeenCalled();
283
+ fireEvent.click(button);
264
284
  }
285
+ expect(handleClick).not.toHaveBeenCalled();
286
+ expect(handleRemove).not.toHaveBeenCalled();
265
287
  });
266
288
 
267
289
  it('does not fire onClick or onRemove when disabled', () => {
@@ -41,6 +41,7 @@ import {interactionOverlayStyles} from '../utils/interactionOverlay.stylex';
41
41
  import {usePressFeedback} from '../hooks/usePressFeedback';
42
42
  import {useTranslator} from '../i18n';
43
43
  import type {TokenColorMap} from './index';
44
+ import {disabledInteractionProps} from '../utils/disabledInteraction';
44
45
 
45
46
  // =============================================================================
46
47
  // Types
@@ -321,7 +322,9 @@ export function Token({
321
322
  e.stopPropagation();
322
323
  onRemove(e);
323
324
  }}
324
- disabled={isDisabled}
325
+ // Focusable while disabled (spec:AST-085 FR1), but inert (FR2).
326
+ aria-disabled={isDisabled || undefined}
327
+ {...(isDisabled ? disabledInteractionProps() : null)}
325
328
  {...focusOutlineProps.focusVisible(styles.removeButton)}>
326
329
  <Icon icon="close" size="xsm" color="inherit" />
327
330
  </button>
@@ -455,7 +458,8 @@ export function Token({
455
458
  <button
456
459
  type="button"
457
460
  onClick={effectiveOnClick}
458
- disabled={isDisabled}
461
+ aria-disabled={isDisabled || undefined}
462
+ {...(isDisabled ? disabledInteractionProps() : null)}
459
463
  {...stylex.props(styles.invisibleButton)}>
460
464
  <span
461
465
  {...stylex.props(
@@ -166,23 +166,23 @@ pending.
166
166
 
167
167
  ### Representative states
168
168
 
169
- | State | Required invariant | Allowed variation |
170
- | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
171
- | Empty field | The mounted inner input is the opening control; a full-width menu stays aligned to the outer field. | Placeholder and bootstrap results may vary. |
172
- | Focus bootstrap, choices remain | A committed selection is removed from the displayed cohort; the input and popup remain active on the next valid remaining choice. | Internal cohort storage and projection may vary. |
173
- | Focus bootstrap, selected choice was last | The nearest preceding remaining choice becomes active without changing the displayed order. | Option identity representation may vary. |
174
- | Focus bootstrap, no choices or max reached | The popup closes and no active descendant remains; pending work cannot reopen it. | Source cancellation mechanism may vary. |
175
- | Source pending or replaced | Busy semantics identify pending work; stale work cannot add choices or reopen a terminal popup. | A settled current cohort may be retained internally. |
176
- | Disabled | No choice can be committed; an open popup closes and clears its active descendant while disabled-reason focusability remains available where configured. | Native-disabled or focusable-disabled representation follows current Tokenizer behavior. |
177
- | One or many tokens | The same input remains after tokens and moves with wrapping; full-width placement does not follow that movement. | Token labels, wrapping rows, and custom content may vary. |
178
- | At `maxEntries` | The input identity remains mounted but its zero-width/hidden box is unusable as geometry; opening falls back to the outer field. | Search remains unavailable under current behavior. |
179
- | Token removal or clear | Input identity survives and current refocus behavior remains. | Token count and input position change. |
180
- | `unfocusedInline` | Blurred OverflowList and focused direct-token layouts resolve the same live input identity; a collapsed input is not used as geometry. | Visible token count may vary with width. |
181
- | `unfocusedLayer`, focused | The live wrapper/input may be reparented into the context layer without descriptor churn. | Corrective portal location may vary. |
182
- | `unfocusedLayer`, unfocused | The hidden-layer input is unusable for a new open; the ordinary-flow outer-field fallback is used. | Placeholder token count may vary. |
183
- | Default or custom token edit | The actual activated token/control is captured before edit state changes; if no usable descendant control exists, input then outer-field fallback applies. | Custom token markup may vary. |
184
- | LTR or RTL | Physical proximity selects an edge only for a narrower surface; logical start wins ties and full-width placement. | Collision may move the final rendered surface. |
185
- | SSR, hydration, or unmount | No server geometry read occurs; first client open prepares normally; unmount clears identities and pending show work. | Hydration timing may vary. |
169
+ | State | Required invariant | Allowed variation |
170
+ | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- |
171
+ | Empty field | The mounted inner input is the opening control; a full-width menu stays aligned to the outer field. | Placeholder and bootstrap results may vary. |
172
+ | Focus bootstrap, choices remain | A committed selection is removed from the displayed cohort; the input and popup remain active on the next valid remaining choice. | Internal cohort storage and projection may vary. |
173
+ | Focus bootstrap, selected choice was last | The nearest preceding remaining choice becomes active without changing the displayed order. | Option identity representation may vary. |
174
+ | Focus bootstrap, no choices or max reached | The popup closes and no active descendant remains; pending work cannot reopen it. | Source cancellation mechanism may vary. |
175
+ | Source pending or replaced | Busy semantics identify pending work; stale work cannot add choices or reopen a terminal popup. | A settled current cohort may be retained internally. |
176
+ | Disabled | No choice can be committed; an open popup closes and clears its active descendant while disabled-reason focusability remains available where configured. | The disabled representation follows `spec:AST-085`. |
177
+ | One or many tokens | The same input remains after tokens and moves with wrapping; full-width placement does not follow that movement. | Token labels, wrapping rows, and custom content may vary. |
178
+ | At `maxEntries` | The input identity remains mounted but its zero-width/hidden box is unusable as geometry; opening falls back to the outer field. | Search remains unavailable under current behavior. |
179
+ | Token removal or clear | Input identity survives and current refocus behavior remains. | Token count and input position change. |
180
+ | `unfocusedInline` | Blurred OverflowList and focused direct-token layouts resolve the same live input identity; a collapsed input is not used as geometry. | Visible token count may vary with width. |
181
+ | `unfocusedLayer`, focused | The live wrapper/input may be reparented into the context layer without descriptor churn. | Corrective portal location may vary. |
182
+ | `unfocusedLayer`, unfocused | The hidden-layer input is unusable for a new open; the ordinary-flow outer-field fallback is used. | Placeholder token count may vary. |
183
+ | Default or custom token edit | The actual activated token/control is captured before edit state changes; if no usable descendant control exists, input then outer-field fallback applies. | Custom token markup may vary. |
184
+ | LTR or RTL | Physical proximity selects an edge only for a narrower surface; logical start wins ties and full-width placement. | Collision may move the final rendered surface. |
185
+ | SSR, hydration, or unmount | No server geometry read occurs; first client open prepares normally; unmount clears identities and pending show work. | Hydration timing may vary. |
186
186
 
187
187
  ### Transformation and precedence order
188
188
 
@@ -282,8 +282,8 @@ consumer docs, and Changeset together. Exact-head browser evidence must exercise
282
282
  next remaining option active.
283
283
  2. The fallback when the committed option was last, plus terminal closure for no
284
284
  remaining choice and `maxEntries`.
285
- 3. Escape, Tab or outside focus departure, transition to native-disabled and
286
- focusable-disabled states, and pending or stale source settlement without a
285
+ 3. Escape, Tab or outside focus departure, transition to the disabled state,
286
+ and pending or stale source settlement without a
287
287
  later reopen.
288
288
  4. The unchanged path when `hasEntriesOnFocus` is off and direct single-select
289
289
  BaseTypeahead selection still closes.
@@ -297,15 +297,15 @@ become Tokenizer policy.
297
297
 
298
298
  ## Verification map
299
299
 
300
- | Contract | Verification | Representative states | Mutation or failure expectation | Audit section |
301
- | ---------------- | ---------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- |
302
- | FR1–FR3 | Existing Tokenizer/BaseTypeahead unit tests plus source inspection | empty, tokens, remove, clear, max, wrapping, both overflow modes | Input remounts, suggestion menu changes anchor/width semantics, or overflow ownership is misstated. | `audit:Tokenizer/current` |
303
- | FR4–FR8 | Focused descriptor identity/capture tests when implemented | add, default/custom token edit, max, hidden layer, reparent, stale node, unmount, SSR | Descriptor leaks publicly, captures a transient result/pointer, misses custom controls, or uses unusable geometry. | `audit:Tokenizer/opening-anchor` |
304
- | FR9–FR12 | Layer unit tests and real-Chromium geometry checks when implemented | full/equal/narrow surface, start/end/tie, LTR/RTL, collision, stale fallback | Full-width follows inner input, tie is physical, collision runs early, or stale identity flashes. | `audit:Tokenizer/placement` |
305
- | FR13–FR15, AR5 | Focused Tokenizer/BaseTypeahead tests and exact-head Chromium interaction evidence | pointer/keyboard commit, first/middle/last choice, rejected/deferred control, loading, disabled, max, exhaustion, Escape, Tab, outside focus | Selected values remain available, order changes, focus leaves the combobox, active descendant names no option, stale work reopens, or a terminal state stays interactive. | `audit:Tokenizer/multi-select` |
306
- | PR1–PR5 | Instrumented reads, pending-frame cleanup tests, and first-frame Chromium capture | open, close before frame, reopen opposite side, resize while open | Render-time/repeated reads, observer/pointer tracking, uncancelled frame, or first-frame jump occurs. | `audit:Tokenizer/performance` |
307
- | AR1–AR5 | Existing interaction suites plus focused pointer/keyboard/browser checks | add/edit, focus/refocus, overflow, disabled, dismissal, retained bootstrap selection | Modality changes placement, semantics/focus regress, active descendant becomes invalid, or visible placement is wrong before fallback. | `audit:Tokenizer/accessibility` |
308
- | Record structure | `scripts/check-knowledge.mjs` | Template-v3 current record and current-only frontmatter links | Invalid metadata, authority, or relationship references fail repository validation. | `audit:Tokenizer/knowledge` |
300
+ | Contract | Verification | Representative states | Mutation or failure expectation | Audit section |
301
+ | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- |
302
+ | FR1–FR3 | Existing Tokenizer/BaseTypeahead unit tests plus source inspection | empty, tokens, remove, clear, max, wrapping, both overflow modes | Input remounts, suggestion menu changes anchor/width semantics, or overflow ownership is misstated. | `audit:Tokenizer/current` |
303
+ | FR4–FR8 | Focused descriptor identity/capture tests when implemented | add, default/custom token edit, max, hidden layer, reparent, stale node, unmount, SSR | Descriptor leaks publicly, captures a transient result/pointer, misses custom controls, or uses unusable geometry. | `audit:Tokenizer/opening-anchor` |
304
+ | FR9–FR12 | Layer unit tests and real-Chromium geometry checks when implemented | full/equal/narrow surface, start/end/tie, LTR/RTL, collision, stale fallback | Full-width follows inner input, tie is physical, collision runs early, or stale identity flashes. | `audit:Tokenizer/placement` |
305
+ | FR13–FR15, AR5 | Focused Tokenizer/BaseTypeahead tests and exact-head Chromium interaction evidence | pointer/keyboard commit, first/middle/last choice, rejected/deferred control, loading, disabled, max, exhaustion, Escape, Tab, outside focus | Selected values remain available, order changes, focus leaves the combobox, active descendant names no option, stale work reopens, or a terminal state stays interactive. | `audit:Tokenizer/multi-select` |
306
+ | PR1–PR5 | Instrumented reads, pending-frame cleanup tests, and first-frame Chromium capture | open, close before frame, reopen opposite side, resize while open | Render-time/repeated reads, observer/pointer tracking, uncancelled frame, or first-frame jump occurs. | `audit:Tokenizer/performance` |
307
+ | AR1–AR5 | Existing interaction suites plus focused pointer/keyboard/browser checks; native-disabled assertions pending conformance (`spec:AST-085`) | add/edit, focus/refocus, overflow, disabled, dismissal, retained bootstrap selection | Modality changes placement, semantics/focus regress, active descendant becomes invalid, or visible placement is wrong before fallback. | `audit:Tokenizer/accessibility` |
308
+ | Record structure | `scripts/check-knowledge.mjs` | Template-v3 current record and current-only frontmatter links | Invalid metadata, authority, or relationship references fail repository validation. | `audit:Tokenizer/knowledge` |
309
309
 
310
310
  ## Decision log
311
311
 
@@ -25,6 +25,7 @@ import {
25
25
  fireEvent,
26
26
  waitFor,
27
27
  } from '@testing-library/react';
28
+ import {useRef} from 'react';
28
29
  import {Tooltip} from './Tooltip';
29
30
  import {useTooltip} from './useTooltip';
30
31
  import {__resetInteractionModalityForTest} from '../utils/interactionModality';
@@ -84,17 +85,15 @@ describe('Tooltip', () => {
84
85
  expect(screen.getByRole('button', {name: 'Trigger'})).toBeInTheDocument();
85
86
  });
86
87
 
87
- it('gives the tooltip layer role="tooltip" linked from the trigger', () => {
88
+ it('renders the content prop inside the tooltip layer', () => {
88
89
  render(
89
90
  <Tooltip content="Tooltip text">
90
91
  <button type="button">Trigger</button>
91
92
  </Tooltip>,
92
93
  );
93
- const layer = screen.getByRole('tooltip', {hidden: true});
94
- expect(layer).toHaveTextContent('Tooltip text');
95
- // ARIA tooltip pattern: trigger references the layer via aria-describedby.
96
- const trigger = screen.getByRole('button', {name: 'Trigger'});
97
- expect(trigger.getAttribute('aria-describedby')).toBe(layer.id);
94
+ expect(screen.getByRole('tooltip', {hidden: true})).toHaveTextContent(
95
+ 'Tooltip text',
96
+ );
98
97
  });
99
98
 
100
99
  it('calls onOpenChange(true) when shown via hover', async () => {
@@ -604,3 +603,49 @@ describe('Tooltip', () => {
604
603
  });
605
604
  });
606
605
  });
606
+
607
+ describe('Tooltip sibling mode (anchorRef)', () => {
608
+ // SideNavItem's collapsed rail tooltip renders the Tooltip inside the very
609
+ // element anchorRef points at. React attaches an ancestor's ref after its
610
+ // descendants' layout effects, so on mount the tooltip's effect used to see
611
+ // a null anchor and never retried (#7692).
612
+ it('attaches to an anchor it is rendered inside of', async () => {
613
+ const onOpenChange = vi.fn();
614
+ function AnchorInside() {
615
+ const anchorRef = useRef<HTMLDivElement>(null);
616
+ return (
617
+ <div ref={anchorRef}>
618
+ <button type="button">Inner</button>
619
+ <Tooltip
620
+ content="Sibling tooltip"
621
+ anchorRef={anchorRef}
622
+ delay={0}
623
+ onOpenChange={onOpenChange}
624
+ />
625
+ </div>
626
+ );
627
+ }
628
+
629
+ const {unmount} = render(<AnchorInside />);
630
+ const anchor = screen.getByRole('button', {name: 'Inner'})
631
+ .parentElement as HTMLElement;
632
+
633
+ // The anchor is described by the tooltip once the commit has attached refs.
634
+ await waitFor(() => {
635
+ expect(anchor.getAttribute('aria-describedby')).not.toBe('');
636
+ });
637
+ const layer = screen.getByRole('tooltip', {hidden: true});
638
+ expect(anchor.getAttribute('aria-describedby')).toBe(layer.id);
639
+
640
+ // Hovering the anchor opens the tooltip: the interaction listeners were
641
+ // attached to the late anchor too.
642
+ fireEvent.mouseEnter(anchor);
643
+ await waitFor(() => {
644
+ expect(onOpenChange).toHaveBeenCalledWith(true);
645
+ });
646
+
647
+ // Unmounting detaches: the aria-describedby the tooltip added is removed.
648
+ unmount();
649
+ expect(anchor.getAttribute('aria-describedby')).toBe(null);
650
+ });
651
+ });
@@ -242,28 +242,51 @@ export function Tooltip({
242
242
  return;
243
243
  }
244
244
 
245
+ const attach = (el: HTMLElement) => {
246
+ // Use combined ref for position + interaction
247
+ tooltip.ref(el);
248
+
249
+ // Set aria-describedby, merging with existing
250
+ const existingDescribedBy = el.getAttribute('aria-describedby');
251
+ el.setAttribute(
252
+ 'aria-describedby',
253
+ mergeIds(existingDescribedBy, tooltip.describedBy) ?? '',
254
+ );
255
+
256
+ return () => {
257
+ tooltip.ref(null);
258
+ if (existingDescribedBy) {
259
+ el.setAttribute('aria-describedby', existingDescribedBy);
260
+ } else {
261
+ el.removeAttribute('aria-describedby');
262
+ }
263
+ };
264
+ };
265
+
245
266
  const el = anchorRef.current;
246
- if (!el) {
247
- return;
267
+ if (el) {
268
+ return attach(el);
248
269
  }
249
270
 
250
- // Use combined ref for position + interaction
251
- tooltip.ref(el);
252
-
253
- // Set aria-describedby, merging with existing
254
- const existingDescribedBy = el.getAttribute('aria-describedby');
255
- el.setAttribute(
256
- 'aria-describedby',
257
- mergeIds(existingDescribedBy, tooltip.describedBy) ?? '',
258
- );
259
-
260
- return () => {
261
- tooltip.ref(null);
262
- if (existingDescribedBy) {
263
- el.setAttribute('aria-describedby', existingDescribedBy);
264
- } else {
265
- el.removeAttribute('aria-describedby');
271
+ // React attaches an ancestor's ref after its descendants' layout effects,
272
+ // so a tooltip rendered inside its own anchor (SideNavItem's collapsed
273
+ // rail tooltip) still sees null here on mount, and this effect's deps
274
+ // never change to re-run it. Re-check once the commit has attached all
275
+ // refs; if the anchor is still gone, this tooltip has no anchor, as before.
276
+ let detach: (() => void) | undefined;
277
+ let cancelled = false;
278
+ queueMicrotask(() => {
279
+ if (cancelled) {
280
+ return;
266
281
  }
282
+ const el = anchorRef.current;
283
+ if (el) {
284
+ detach = attach(el);
285
+ }
286
+ });
287
+ return () => {
288
+ cancelled = true;
289
+ detach?.();
267
290
  };
268
291
  }, [anchorRef, tooltip.ref, tooltip.describedBy]);
269
292
 
@@ -0,0 +1,129 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Tooltip.a11y.chromium.spec.ts
5
+ * @input Built tooltip stories and TOOLTIP_PATTERN in real Chromium
6
+ * @output Browser evidence for every tooltip expectation across Tooltip's and useTooltip's representative states
7
+ * @position Chromium lane of the Tooltip binding
8
+ */
9
+
10
+ import {
11
+ expect,
12
+ test,
13
+ type CDPSession,
14
+ type Locator,
15
+ type Page,
16
+ } from '@playwright/test';
17
+ import {
18
+ TOOLTIP_PATTERN,
19
+ blockingResults,
20
+ checkAccessibilitySpec,
21
+ formatFailures,
22
+ neverExercised,
23
+ summarize,
24
+ type BindingResult,
25
+ } from '@astryxdesign/a11y-spec';
26
+ import {
27
+ createChromiumHarness,
28
+ holdMotionStill,
29
+ } from '@astryxdesign/a11y-spec/chromium';
30
+ import {
31
+ DEFAULT_STORYBOOK_DIR,
32
+ serveStorybook,
33
+ type StaticServer,
34
+ } from '@astryxdesign/a11y-spec/storybook';
35
+ import {
36
+ TOOLTIP_BINDING_STATES,
37
+ type TooltipBindingState,
38
+ } from './Tooltip.a11y.states';
39
+
40
+ let storybook: StaticServer;
41
+
42
+ test.beforeAll(async () => {
43
+ storybook = await serveStorybook(
44
+ process.env.ASTRYX_STORYBOOK_DIR ?? DEFAULT_STORYBOOK_DIR,
45
+ );
46
+ });
47
+
48
+ test.afterAll(async () => {
49
+ await storybook?.close();
50
+ });
51
+
52
+ function storyUrl(storyId: string): string {
53
+ return `${storybook.origin}/iframe.html?id=${storyId}&viewMode=story`;
54
+ }
55
+
56
+ async function mountState(
57
+ page: Page,
58
+ state: TooltipBindingState,
59
+ ): Promise<{subject: Locator; related: Record<string, Locator>}> {
60
+ await page.goto(storyUrl(state.storyId), {waitUntil: 'load'});
61
+ await holdMotionStill(page);
62
+ const root = page.locator('#storybook-root');
63
+ const subject =
64
+ state.triggerRole == null
65
+ ? root.getByText(state.triggerText, {exact: true})
66
+ : root.getByRole(state.triggerRole, {
67
+ name: state.triggerText,
68
+ exact: true,
69
+ });
70
+ await expect(subject).toBeVisible();
71
+ return {
72
+ subject,
73
+ related: {
74
+ // The layer may mount the tooltip outside the story root, and it stays
75
+ // in the page while hidden, so it is found page-wide, hidden included.
76
+ tooltip: page.getByRole('tooltip', {includeHidden: true}),
77
+ before: root.getByRole('button', {
78
+ name: 'Before the trigger',
79
+ exact: true,
80
+ }),
81
+ after: root.getByRole('button', {
82
+ name: 'After the trigger',
83
+ exact: true,
84
+ }),
85
+ },
86
+ };
87
+ }
88
+
89
+ async function runState(
90
+ page: Page,
91
+ cdp: CDPSession,
92
+ state: TooltipBindingState,
93
+ ): Promise<BindingResult> {
94
+ return checkAccessibilitySpec({
95
+ spec: TOOLTIP_PATTERN,
96
+ binding: 'Tooltip',
97
+ state: state.id,
98
+ facts: state.facts,
99
+ mount: async () => {
100
+ const {subject, related} = await mountState(page, state);
101
+ return createChromiumHarness({page, cdp, subject, related});
102
+ },
103
+ });
104
+ }
105
+
106
+ test('every tooltip expectation is exercised by a Tooltip state', async ({
107
+ page,
108
+ }) => {
109
+ test.setTimeout(3 * 60 * 1000);
110
+ const cdp = await page.context().newCDPSession(page);
111
+ const results: BindingResult[] = [];
112
+ for (const state of TOOLTIP_BINDING_STATES) {
113
+ results.push(await runState(page, cdp, state));
114
+ }
115
+ expect(neverExercised(results)).toEqual([]);
116
+ });
117
+
118
+ for (const state of TOOLTIP_BINDING_STATES) {
119
+ test(`Tooltip [${state.id}] — ${state.summary}`, async ({page}) => {
120
+ test.setTimeout(2 * 60 * 1000);
121
+ const cdp = await page.context().newCDPSession(page);
122
+ const result = await runState(page, cdp, state);
123
+ const report = summarize(TOOLTIP_PATTERN, [result]);
124
+ // Required rows block (AST-020 FR9); advisory rows report.
125
+ expect(formatFailures(blockingResults([result]))).toBe('');
126
+ expect(report.unrunLayers).toEqual([]);
127
+ expect(report.counts.unexpectedPass).toBe(0);
128
+ });
129
+ }
@@ -0,0 +1,86 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ /**
6
+ * @file Tooltip.a11y.renders.tsx
7
+ * @input One tooltip binding state from Tooltip.a11y.states.ts
8
+ * @output The React tree every lane renders for that state (the jsdom test directly, the story through Storybook): a page button before the trigger, the trigger and its tooltip, and a page button after it
9
+ * @position Shared fixture for the Tooltip and useTooltip binding
10
+ */
11
+
12
+ import {useRef, type ReactElement} from 'react';
13
+ import {Button} from '../../Button/Button';
14
+ import {isRenderable} from '../../utils/isRenderable';
15
+ import {Tooltip} from '../Tooltip';
16
+ import {useTooltip} from '../useTooltip';
17
+ import type {TooltipBindingState} from './Tooltip.a11y.states';
18
+
19
+ const HINT_ID = 'tooltip-a11y-hint';
20
+
21
+ type TriggerState = Pick<
22
+ TooltipBindingState,
23
+ 'attachment' | 'triggerText' | 'content' | 'hint'
24
+ >;
25
+
26
+ /** A caller button the tooltip attaches to through `anchorRef`. */
27
+ function AnchorRefTrigger({state}: {state: TriggerState}): ReactElement {
28
+ const anchorRef = useRef<HTMLButtonElement>(null);
29
+ return (
30
+ <>
31
+ <button
32
+ ref={anchorRef}
33
+ type="button"
34
+ aria-describedby={state.hint == null ? undefined : HINT_ID}>
35
+ {state.triggerText}
36
+ </button>
37
+ <Tooltip anchorRef={anchorRef} content={state.content} />
38
+ </>
39
+ );
40
+ }
41
+
42
+ /** A caller button wired by hand through `useTooltip`. */
43
+ function HookTrigger({state}: {state: TriggerState}): ReactElement {
44
+ const tooltip = useTooltip();
45
+ return (
46
+ <>
47
+ <button
48
+ ref={tooltip.ref}
49
+ type="button"
50
+ aria-describedby={tooltip.describedBy}>
51
+ {state.triggerText}
52
+ </button>
53
+ {tooltip.renderTooltip(state.content)}
54
+ </>
55
+ );
56
+ }
57
+
58
+ function Trigger({state}: {state: TriggerState}): ReactElement {
59
+ switch (state.attachment) {
60
+ case 'wrapped-element':
61
+ return (
62
+ <Tooltip content={state.content}>
63
+ <Button label={state.triggerText} />
64
+ </Tooltip>
65
+ );
66
+ case 'text':
67
+ return <Tooltip content={state.content}>{state.triggerText}</Tooltip>;
68
+ case 'anchor-ref':
69
+ return <AnchorRefTrigger state={state} />;
70
+ case 'hook':
71
+ return <HookTrigger state={state} />;
72
+ }
73
+ }
74
+
75
+ export function renderTooltipState(state: TriggerState): ReactElement {
76
+ return (
77
+ <div style={{padding: 64}}>
78
+ <button type="button">Before the trigger</button>{' '}
79
+ <Trigger state={state} />
80
+ {isRenderable(state.hint) ? (
81
+ <span id={HINT_ID}> {state.hint}</span>
82
+ ) : null}{' '}
83
+ <button type="button">After the trigger</button>
84
+ </div>
85
+ );
86
+ }