@astryxdesign/core 0.7.1 → 0.7.2-canary.151434a

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 (251) hide show
  1. package/dist/AspectRatio/AspectRatio.d.ts +6 -8
  2. package/dist/AspectRatio/AspectRatio.d.ts.map +1 -1
  3. package/dist/AspectRatio/AspectRatio.js +44 -3
  4. package/dist/Button/Button.d.ts.map +1 -1
  5. package/dist/Button/Button.js +38 -14
  6. package/dist/CheckboxInput/CheckboxInput.d.ts +1 -1
  7. package/dist/CheckboxInput/CheckboxInput.d.ts.map +1 -1
  8. package/dist/CheckboxInput/CheckboxInput.js +3 -1
  9. package/dist/CheckboxList/CheckboxListItem.d.ts +9 -1
  10. package/dist/CheckboxList/CheckboxListItem.d.ts.map +1 -1
  11. package/dist/CheckboxList/CheckboxListItem.js +4 -2
  12. package/dist/Dialog/Dialog.d.ts.map +1 -1
  13. package/dist/Dialog/Dialog.js +20 -8
  14. package/dist/Drawer/Drawer.d.ts +191 -0
  15. package/dist/Drawer/Drawer.d.ts.map +1 -0
  16. package/dist/Drawer/Drawer.js +382 -0
  17. package/dist/Drawer/DrawerHeader.d.ts +85 -0
  18. package/dist/Drawer/DrawerHeader.d.ts.map +1 -0
  19. package/dist/Drawer/DrawerHeader.js +134 -0
  20. package/dist/Drawer/index.d.ts +11 -0
  21. package/dist/Drawer/index.d.ts.map +1 -0
  22. package/dist/Drawer/index.js +11 -0
  23. package/dist/Drawer/useDrawerDialogPresence.d.ts +42 -0
  24. package/dist/Drawer/useDrawerDialogPresence.d.ts.map +1 -0
  25. package/dist/Drawer/useDrawerDialogPresence.js +236 -0
  26. package/dist/DropdownMenu/DropdownMenu.js +1 -1
  27. package/dist/DropdownMenu/DropdownMenuDivider.d.ts +5 -3
  28. package/dist/DropdownMenu/DropdownMenuDivider.d.ts.map +1 -1
  29. package/dist/DropdownMenu/DropdownMenuDivider.js +7 -3
  30. package/dist/DropdownMenu/DropdownMenuSubMenu.js +4 -4
  31. package/dist/EmptyState/EmptyState.d.ts.map +1 -1
  32. package/dist/EmptyState/EmptyState.js +4 -10
  33. package/dist/Field/Field.d.ts +1 -1
  34. package/dist/Field/Field.d.ts.map +1 -1
  35. package/dist/Field/Field.js +8 -4
  36. package/dist/HoverCard/useHoverCard.d.ts.map +1 -1
  37. package/dist/HoverCard/useHoverCard.js +10 -2
  38. package/dist/Item/Item.d.ts +6 -2
  39. package/dist/Item/Item.d.ts.map +1 -1
  40. package/dist/Item/Item.js +19 -4
  41. package/dist/Layer/layerViewportInset.stylex.d.ts +6 -2
  42. package/dist/Layer/layerViewportInset.stylex.d.ts.map +1 -1
  43. package/dist/Layer/layerViewportInset.stylex.js +6 -2
  44. package/dist/Layer/useLayer.d.ts.map +1 -1
  45. package/dist/Layer/useLayer.js +13 -7
  46. package/dist/Layer/useLayerDismissal.d.ts +1 -1
  47. package/dist/Layer/useLayerDismissal.js +1 -1
  48. package/dist/Layout/container.stylex.d.ts +5 -5
  49. package/dist/Layout/container.stylex.js +4 -4
  50. package/dist/List/List.d.ts +46 -19
  51. package/dist/List/List.d.ts.map +1 -1
  52. package/dist/List/List.js +28 -10
  53. package/dist/List/ListContext.d.ts +8 -3
  54. package/dist/List/ListContext.d.ts.map +1 -1
  55. package/dist/List/ListContext.js +7 -2
  56. package/dist/List/ListItem.d.ts +13 -5
  57. package/dist/List/ListItem.d.ts.map +1 -1
  58. package/dist/List/ListItem.js +21 -8
  59. package/dist/List/index.d.ts +1 -1
  60. package/dist/List/index.d.ts.map +1 -1
  61. package/dist/Markdown/Markdown.d.ts.map +1 -1
  62. package/dist/Markdown/Markdown.js +8 -1
  63. package/dist/Markdown/plugins/frontmatter.d.ts.map +1 -1
  64. package/dist/Markdown/plugins/frontmatter.js +7 -3
  65. package/dist/MetadataList/MetadataList.d.ts.map +1 -1
  66. package/dist/MetadataList/MetadataList.js +5 -1
  67. package/dist/MetadataList/MetadataListContext.d.ts +1 -1
  68. package/dist/MetadataList/MetadataListContext.d.ts.map +1 -1
  69. package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
  70. package/dist/MultiSelector/MultiSelector.js +22 -8
  71. package/dist/MultiSelector/OptionActions.a11y.chromium.spec.js +100 -2
  72. package/dist/MultiSelector/hooks.d.ts.map +1 -1
  73. package/dist/MultiSelector/hooks.js +3 -1
  74. package/dist/Pagination/Pagination.d.ts +8 -5
  75. package/dist/Pagination/Pagination.d.ts.map +1 -1
  76. package/dist/Pagination/Pagination.js +21 -8
  77. package/dist/Popover/Popover.js +2 -2
  78. package/dist/PowerSearch/PowerSearch.js +1 -1
  79. package/dist/RadioList/RadioListItem.d.ts +9 -1
  80. package/dist/RadioList/RadioListItem.d.ts.map +1 -1
  81. package/dist/RadioList/RadioListItem.js +2 -0
  82. package/dist/Selector/hooks.d.ts.map +1 -1
  83. package/dist/Selector/hooks.js +6 -2
  84. package/dist/Switch/Switch.d.ts +1 -1
  85. package/dist/Switch/Switch.d.ts.map +1 -1
  86. package/dist/Switch/Switch.js +13 -1
  87. package/dist/TabList/TabMenu.d.ts +2 -2
  88. package/dist/TabList/TabMenu.d.ts.map +1 -1
  89. package/dist/TabList/TabMenu.js +6 -3
  90. package/dist/Text/useTruncation.d.ts +7 -5
  91. package/dist/Text/useTruncation.d.ts.map +1 -1
  92. package/dist/Text/useTruncation.js +16 -5
  93. package/dist/Tooltip/useTooltip.d.ts.map +1 -1
  94. package/dist/Tooltip/useTooltip.js +11 -0
  95. package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
  96. package/dist/Typeahead/BaseTypeahead.js +26 -0
  97. package/dist/astryx.css +35 -12
  98. package/dist/hooks/useEntryAnimation.d.ts +2 -1
  99. package/dist/hooks/useEntryAnimation.d.ts.map +1 -1
  100. package/dist/hooks/useEntryAnimation.js +14 -12
  101. package/dist/hooks/useIsHydrating.d.ts +6 -0
  102. package/dist/hooks/useIsHydrating.d.ts.map +1 -0
  103. package/dist/hooks/useIsHydrating.js +30 -0
  104. package/dist/i18n/generated-locales/en.generated.d.ts.map +1 -1
  105. package/dist/i18n/generated-locales/en.generated.js +112 -1
  106. package/dist/i18n/generated-locales/pseudo.generated.d.ts.map +1 -1
  107. package/dist/i18n/generated-locales/pseudo.generated.js +112 -1
  108. package/dist/index.d.ts +1 -0
  109. package/dist/index.d.ts.map +1 -1
  110. package/dist/index.js +1 -0
  111. package/dist/theme/derivedVarRegistry.d.ts.map +1 -1
  112. package/dist/theme/derivedVarRegistry.js +0 -3
  113. package/dist/utils/focusOutline.stylex.d.ts +4 -0
  114. package/dist/utils/focusOutline.stylex.d.ts.map +1 -1
  115. package/dist/utils/focusOutline.stylex.js +4 -0
  116. package/dist/utils/plainDate.d.ts.map +1 -1
  117. package/dist/utils/plainDate.js +15 -9
  118. package/dist/utils/sharedMutationObserver.d.ts +21 -0
  119. package/dist/utils/sharedMutationObserver.d.ts.map +1 -0
  120. package/dist/utils/sharedMutationObserver.js +69 -0
  121. package/locales/en.json +444 -0
  122. package/locales/pseudo.json +333 -0
  123. package/package.json +9 -4
  124. package/src/AspectRatio/AspectRatio.test.tsx +44 -43
  125. package/src/AspectRatio/AspectRatio.tsx +38 -18
  126. package/src/AspectRatio/__tests__/AspectRatioFitLayers.a11y.browser.spec.ts +157 -0
  127. package/src/Button/Button.doc.mjs +2 -2
  128. package/src/Button/Button.test.tsx +155 -9
  129. package/src/Button/Button.tsx +39 -16
  130. package/src/Button/__tests__/Button.a11y.known-failures.ts +1 -62
  131. package/src/Button/__tests__/Button.a11y.states.ts +0 -10
  132. package/src/CheckboxInput/CheckboxInput.test.tsx +34 -0
  133. package/src/CheckboxInput/CheckboxInput.tsx +3 -1
  134. package/src/CheckboxList/CheckboxList.test.tsx +22 -0
  135. package/src/CheckboxList/CheckboxListItem.doc.mjs +16 -0
  136. package/src/CheckboxList/CheckboxListItem.tsx +10 -0
  137. package/src/Dialog/Dialog.test.tsx +46 -0
  138. package/src/Dialog/Dialog.tsx +20 -7
  139. package/src/Dialog/__tests__/Dialog.a11y.known-failures.ts +0 -14
  140. package/src/Drawer/Drawer.doc.mjs +472 -0
  141. package/src/Drawer/Drawer.spec.md +357 -0
  142. package/src/Drawer/Drawer.test.tsx +1300 -0
  143. package/src/Drawer/Drawer.tsx +613 -0
  144. package/src/Drawer/DrawerDirection.test.tsx +127 -0
  145. package/src/Drawer/DrawerHeader.doc.mjs +293 -0
  146. package/src/Drawer/DrawerHeader.test.tsx +102 -0
  147. package/src/Drawer/DrawerHeader.tsx +235 -0
  148. package/src/Drawer/__tests__/DrawerAudit.a11y.chromium.spec.ts +401 -0
  149. package/src/Drawer/__tests__/DrawerHeaderAudit.a11y.chromium.spec.ts +297 -0
  150. package/src/Drawer/index.ts +13 -0
  151. package/src/Drawer/useDrawerDialogPresence.ts +284 -0
  152. package/src/DropdownMenu/DropdownMenu.test.tsx +21 -12
  153. package/src/DropdownMenu/DropdownMenu.tsx +2 -2
  154. package/src/DropdownMenu/DropdownMenuDivider.doc.mjs +2 -2
  155. package/src/DropdownMenu/DropdownMenuDivider.tsx +9 -5
  156. package/src/DropdownMenu/DropdownMenuSubMenu.tsx +2 -2
  157. package/src/DropdownMenu/__tests__/Menu.a11y.chromium.spec.ts +145 -0
  158. package/src/DropdownMenu/__tests__/Menu.a11y.known-failures.ts +26 -0
  159. package/src/DropdownMenu/__tests__/Menu.a11y.renders.tsx +65 -0
  160. package/src/DropdownMenu/__tests__/Menu.a11y.states.ts +138 -0
  161. package/src/DropdownMenu/__tests__/Menu.a11y.test.tsx +136 -0
  162. package/src/EmptyState/EmptyState.doc.mjs +3 -2
  163. package/src/EmptyState/EmptyState.spec.md +189 -0
  164. package/src/EmptyState/EmptyState.test.tsx +74 -3
  165. package/src/EmptyState/EmptyState.tsx +4 -8
  166. package/src/Field/Field.doc.mjs +14 -8
  167. package/src/Field/Field.spec.md +217 -0
  168. package/src/Field/Field.test.tsx +60 -2
  169. package/src/Field/Field.tsx +17 -8
  170. package/src/HoverCard/HoverCard.test.tsx +58 -0
  171. package/src/HoverCard/useHoverCard.tsx +10 -2
  172. package/src/IconButton/IconButton.doc.mjs +2 -2
  173. package/src/IconButton/IconButton.test.tsx +3 -1
  174. package/src/Item/Item.doc.mjs +12 -8
  175. package/src/Item/Item.spec.md +226 -0
  176. package/src/Item/Item.test.tsx +61 -0
  177. package/src/Item/Item.tsx +40 -4
  178. package/src/Layer/layerViewportInset.source-build.test.mjs +90 -0
  179. package/src/Layer/layerViewportInset.stylex.ts +6 -2
  180. package/src/Layer/layerViewportInset.test.ts +1 -0
  181. package/src/Layer/useLayer.tsx +24 -13
  182. package/src/Layer/useLayerDismissal.ts +1 -1
  183. package/src/Layout/container.stylex.ts +6 -6
  184. package/src/List/List.doc.mjs +14 -1
  185. package/src/List/List.spec.md +129 -77
  186. package/src/List/List.test.tsx +163 -94
  187. package/src/List/List.tsx +74 -18
  188. package/src/List/ListContext.tsx +9 -3
  189. package/src/List/ListItem.doc.mjs +24 -5
  190. package/src/List/ListItem.tsx +56 -27
  191. package/src/List/index.ts +1 -1
  192. package/src/Markdown/Markdown.tsx +14 -1
  193. package/src/Markdown/plugins/frontmatter.test.tsx +25 -0
  194. package/src/Markdown/plugins/frontmatter.ts +7 -3
  195. package/src/MetadataList/MetadataList.doc.mjs +2 -2
  196. package/src/MetadataList/MetadataList.test.tsx +38 -0
  197. package/src/MetadataList/MetadataList.tsx +8 -1
  198. package/src/MetadataList/MetadataListContext.tsx +1 -1
  199. package/src/MultiSelector/MultiSelector.test.tsx +159 -0
  200. package/src/MultiSelector/MultiSelector.tsx +40 -7
  201. package/src/MultiSelector/OptionActions.a11y.chromium.spec.ts +112 -2
  202. package/src/MultiSelector/hooks.ts +3 -1
  203. package/src/MultiSelector/useMultiCombobox.doc.mjs +267 -0
  204. package/src/Pagination/Pagination.doc.mjs +7 -7
  205. package/src/Pagination/Pagination.spec.md +9 -9
  206. package/src/Pagination/Pagination.test.tsx +242 -0
  207. package/src/Pagination/Pagination.tsx +27 -11
  208. package/src/Popover/Popover.test.tsx +2 -2
  209. package/src/Popover/Popover.tsx +4 -4
  210. package/src/PowerSearch/PowerSearch.test.tsx +85 -0
  211. package/src/PowerSearch/PowerSearch.tsx +2 -2
  212. package/src/RadioList/RadioList.test.tsx +22 -0
  213. package/src/RadioList/RadioListItem.doc.mjs +16 -0
  214. package/src/RadioList/RadioListItem.tsx +10 -1
  215. package/src/Selector/hooks.ts +6 -2
  216. package/src/Selector/useCombobox.doc.mjs +237 -0
  217. package/src/Switch/Switch.test.tsx +49 -0
  218. package/src/Switch/Switch.tsx +11 -0
  219. package/src/TabList/TabList.spec.md +57 -44
  220. package/src/TabList/TabList.test.tsx +67 -0
  221. package/src/TabList/TabMenu.doc.mjs +4 -3
  222. package/src/TabList/TabMenu.tsx +7 -4
  223. package/src/Text/useTruncation.test.tsx +69 -0
  224. package/src/Text/useTruncation.ts +16 -5
  225. package/src/Tokenizer/Tokenizer.test.tsx +54 -1
  226. package/src/Tooltip/Tooltip.test.tsx +31 -0
  227. package/src/Tooltip/useTooltip.test.tsx +13 -0
  228. package/src/Tooltip/useTooltip.tsx +11 -0
  229. package/src/TreeList/TreeList.spec.md +29 -17
  230. package/src/Typeahead/BaseTypeahead.tsx +26 -0
  231. package/src/Typeahead/Typeahead.test.tsx +260 -2
  232. package/src/hooks/useEntryAnimation.doc.mjs +4 -4
  233. package/src/hooks/useEntryAnimation.ssr.test.tsx +143 -0
  234. package/src/hooks/useEntryAnimation.ts +15 -16
  235. package/src/hooks/useIsHydrating.test.tsx +70 -0
  236. package/src/hooks/useIsHydrating.ts +31 -0
  237. package/src/i18n/InternationalizationProvider.doc.mjs +8 -0
  238. package/src/i18n/InternationalizationProvider.spec.md +252 -0
  239. package/src/i18n/__tests__/InternationalizationProvider.test.tsx +128 -0
  240. package/src/i18n/generated-locales/en.generated.ts +121 -0
  241. package/src/i18n/generated-locales/pseudo.generated.ts +121 -0
  242. package/src/i18n/useTranslator.test.tsx +37 -0
  243. package/src/index.ts +1 -0
  244. package/src/reset.css +0 -29
  245. package/src/theme/derivedVarRegistry.test.ts +3 -3
  246. package/src/theme/derivedVarRegistry.ts +0 -3
  247. package/src/utils/focusOutline.stylex.ts +4 -0
  248. package/src/utils/plainDate.test.ts +37 -1
  249. package/src/utils/plainDate.ts +15 -5
  250. package/src/utils/sharedMutationObserver.test.ts +53 -0
  251. package/src/utils/sharedMutationObserver.ts +78 -0
@@ -37,7 +37,7 @@ import {useListFocus} from '../hooks/useListFocus';
37
37
  import {useTabListContext} from './TabListContext';
38
38
  import type {TabListSize} from './TabListContext';
39
39
  import {tabScope} from './tab.markers.stylex';
40
- import {mergeProps} from '../utils';
40
+ import {composeEventHandlers, mergeProps} from '../utils';
41
41
  import {useMergedRefs} from '../hooks/useMergedRefs';
42
42
  import type {BaseProps} from '../BaseProps';
43
43
  import {themeProps} from '../utils/themeProps';
@@ -51,9 +51,9 @@ export interface TabMenuOption {
51
51
  icon?: ReactNode | IconType;
52
52
  }
53
53
 
54
- export interface TabMenuProps extends Pick<
54
+ export interface TabMenuProps extends Omit<
55
55
  BaseProps<HTMLButtonElement>,
56
- 'xstyle' | 'className' | 'style'
56
+ 'role' | 'tabIndex' | 'aria-haspopup' | 'aria-expanded' | 'aria-controls'
57
57
  > {
58
58
  ref?: React.Ref<HTMLButtonElement>;
59
59
  /**
@@ -263,6 +263,8 @@ export function TabMenu({
263
263
  xstyle,
264
264
  className,
265
265
  style,
266
+ onClick,
267
+ ...rest
266
268
  }: TabMenuProps) {
267
269
  const tabListCtx = useTabListContext();
268
270
  const menuId = useId();
@@ -346,6 +348,7 @@ export function TabMenu({
346
348
  return (
347
349
  <>
348
350
  <button
351
+ {...rest}
349
352
  ref={setButtonRef}
350
353
  type="button"
351
354
  aria-haspopup="menu"
@@ -353,7 +356,7 @@ export function TabMenu({
353
356
  aria-controls={menuId}
354
357
  data-tab-menu=""
355
358
  tabIndex={hasSelectedOption ? 0 : -1}
356
- onClick={handleToggle}
359
+ onClick={composeEventHandlers(handleToggle, onClick)}
357
360
  {...mergeProps(
358
361
  themeProps('tab-menu'),
359
362
  focusOutlineProps.focusVisible(
@@ -0,0 +1,69 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file useTruncation.test.tsx
5
+ * Tests for useTruncation
6
+ */
7
+
8
+ import {render, screen, waitFor} from '@testing-library/react';
9
+ import {afterEach, describe, it, expect, vi} from 'vitest';
10
+ import {useTruncation} from './useTruncation';
11
+
12
+ function Probe({text}: {text: string}) {
13
+ const truncation = useTruncation({maxLines: 1});
14
+ return (
15
+ <>
16
+ <span ref={truncation.ref}>{text}</span>
17
+ <output data-testid="full-text">{truncation.fullText}</output>
18
+ </>
19
+ );
20
+ }
21
+
22
+ // A status line at a fixed width never resizes when its message changes, so
23
+ // the tooltip text must follow the content, not only the element's size.
24
+ describe('useTruncation', () => {
25
+ it('re-measures when the text changes without a resize', async () => {
26
+ const {rerender} = render(<Probe text="planning the first step" />);
27
+ expect(screen.getByTestId('full-text')).toHaveTextContent(
28
+ 'planning the first step',
29
+ );
30
+
31
+ rerender(<Probe text="writing the second step" />);
32
+
33
+ await waitFor(() =>
34
+ expect(screen.getByTestId('full-text')).toHaveTextContent(
35
+ 'writing the second step',
36
+ ),
37
+ );
38
+ });
39
+
40
+ describe('content observation', () => {
41
+ afterEach(() => {
42
+ vi.unstubAllGlobals();
43
+ });
44
+
45
+ it('shares one observer across every truncating instance', () => {
46
+ const NativeMutationObserver = globalThis.MutationObserver;
47
+ let created = 0;
48
+ vi.stubGlobal(
49
+ 'MutationObserver',
50
+ class extends NativeMutationObserver {
51
+ constructor(callback: MutationCallback) {
52
+ super(callback);
53
+ created += 1;
54
+ }
55
+ },
56
+ );
57
+
58
+ render(
59
+ <>
60
+ {Array.from({length: 50}, (_, index) => (
61
+ <Probe key={index} text={`cell ${index}`} />
62
+ ))}
63
+ </>,
64
+ );
65
+
66
+ expect(created).toBe(1);
67
+ });
68
+ });
69
+ });
@@ -4,7 +4,7 @@
4
4
 
5
5
  /**
6
6
  * @file useTruncation.ts
7
- * @input Uses React hooks, sharedResizeObserver
7
+ * @input Uses React hooks, sharedResizeObserver, sharedMutationObserver
8
8
  * @output Exports useTruncation hook for detecting text overflow
9
9
  * @position Hook; consumed by Text.tsx, Heading.tsx
10
10
  *
@@ -14,6 +14,7 @@
14
14
 
15
15
  import {useCallback, useRef, useState, type RefCallback} from 'react';
16
16
  import {observeResize} from '../utils/sharedResizeObserver';
17
+ import {observeContent} from '../utils/sharedMutationObserver';
17
18
 
18
19
  export interface UseTruncationOptions {
19
20
  /**
@@ -43,10 +44,12 @@ export interface UseTruncationReturn {
43
44
  /**
44
45
  * Hook for detecting text overflow/truncation.
45
46
  *
46
- * Uses a shared ResizeObserver singleton (via observeResize)
47
- * for efficient detection when content or container changes. A single
48
- * ResizeObserver instance is shared across all mounted useTruncation hooks,
49
- * so even hundreds of table cells only create one observer.
47
+ * Uses a shared ResizeObserver singleton (via observeResize) for size
48
+ * changes and a shared MutationObserver singleton (via observeContent) for
49
+ * content changes, so even hundreds of table cells create one observer of
50
+ * each kind. Observing content matters because an element whose text changes
51
+ * without resizing (a fixed-width status line) must still be re-measured for
52
+ * the tooltip text and the truncated state to follow the current content.
50
53
  *
51
54
  * - Single-line: compares scrollWidth > offsetWidth
52
55
  * - Multi-line: uses Range.getBoundingClientRect() to measure actual content
@@ -71,6 +74,7 @@ export function useTruncation(
71
74
  const [fullText, setFullText] = useState('');
72
75
  const elementRef = useRef<HTMLElement | null>(null);
73
76
  const unobserveRef = useRef<(() => void) | null>(null);
77
+ const unobserveContentRef = useRef<(() => void) | null>(null);
74
78
 
75
79
  const checkTruncation = useCallback(
76
80
  (element: HTMLElement) => {
@@ -113,6 +117,8 @@ export function useTruncation(
113
117
  // by element leaves any other observer of the same node untouched.
114
118
  unobserveRef.current?.();
115
119
  unobserveRef.current = null;
120
+ unobserveContentRef.current?.();
121
+ unobserveContentRef.current = null;
116
122
 
117
123
  elementRef.current = element;
118
124
 
@@ -127,6 +133,11 @@ export function useTruncation(
127
133
  } else {
128
134
  checkTruncation(element);
129
135
  }
136
+ // New text in a box that keeps its size fires no resize, so the
137
+ // captured text and truncated state would otherwise stay stale.
138
+ unobserveContentRef.current = observeContent(element, () => {
139
+ checkTruncation(element);
140
+ });
130
141
  } else {
131
142
  setIsTruncated(false);
132
143
  setFullText('');
@@ -20,7 +20,7 @@ import {
20
20
  afterEach,
21
21
  } from 'vitest';
22
22
  import {render, screen, fireEvent, act, waitFor} from '@testing-library/react';
23
- import {createRef, Profiler} from 'react';
23
+ import {createRef, Profiler, useState} from 'react';
24
24
  import userEvent from '@testing-library/user-event';
25
25
  import {Tokenizer} from './Tokenizer';
26
26
  import {__resetLiveRegionsForTest} from '../hooks/useAnnounce';
@@ -1790,6 +1790,59 @@ describe('Tokenizer end-lane reserve', () => {
1790
1790
  expect(renderedTokens).toEqual([]);
1791
1791
  });
1792
1792
 
1793
+ it('clears the busy spinner when a token is added while a bootstrap load is in flight', async () => {
1794
+ let settle: (items: SearchableItem[]) => void = () => {};
1795
+ const bootstrap = vi.fn(
1796
+ async () =>
1797
+ new Promise<SearchableItem[]>(resolve => {
1798
+ settle = resolve;
1799
+ }),
1800
+ );
1801
+ const source: SearchSource = {search: () => [], bootstrap};
1802
+
1803
+ function Harness() {
1804
+ const [value, setValue] = useState<SearchableItem[]>([]);
1805
+ return (
1806
+ <>
1807
+ <Tokenizer
1808
+ label="Items"
1809
+ searchSource={source}
1810
+ value={value}
1811
+ onChange={setValue}
1812
+ hasEntriesOnFocus
1813
+ />
1814
+ <button type="button" onClick={() => setValue([users[0]])}>
1815
+ Add Token
1816
+ </button>
1817
+ </>
1818
+ );
1819
+ }
1820
+
1821
+ render(<Harness />);
1822
+ const input = screen.getByRole('combobox');
1823
+
1824
+ fireEvent.focus(input);
1825
+ await waitFor(() => {
1826
+ expect(screen.getByRole('status', {name: 'Loading'})).toBeInTheDocument();
1827
+ });
1828
+
1829
+ fireEvent.click(screen.getByRole('button', {name: 'Add Token'}));
1830
+
1831
+ await waitFor(() => {
1832
+ expect(
1833
+ screen.queryByRole('status', {name: 'Loading'}),
1834
+ ).not.toBeInTheDocument();
1835
+ });
1836
+
1837
+ await act(async () => {
1838
+ settle([]);
1839
+ await Promise.resolve();
1840
+ });
1841
+ expect(
1842
+ screen.queryByRole('status', {name: 'Loading'}),
1843
+ ).not.toBeInTheDocument();
1844
+ });
1845
+
1793
1846
  it('shares one observer across every field on the page', () => {
1794
1847
  // Browsers batch per observer instance, so one observer per lane meant N
1795
1848
  // callback dispatches a frame for N fields.
@@ -183,6 +183,37 @@ describe('Tooltip', () => {
183
183
  });
184
184
  });
185
185
 
186
+ describe('icon-only trigger', () => {
187
+ // A rendered icon is an SVGElement, and ShowPopoverOptions.source is
188
+ // declared as an HTMLElement. Handing the icon to showPopover throws
189
+ // during dictionary conversion, before the popover opens, so the hint
190
+ // never appeared. (#7226)
191
+ it('opens an icon trigger without passing an SVGElement as the source', async () => {
192
+ const showSpy = vi.mocked(HTMLElement.prototype.showPopover);
193
+ showSpy.mockClear();
194
+
195
+ render(
196
+ <Tooltip content="Shared memory" isDefaultOpen>
197
+ <svg data-testid="icon" aria-hidden="true" />
198
+ </Tooltip>,
199
+ );
200
+
201
+ await waitFor(() => {
202
+ expect(showSpy).toHaveBeenCalled();
203
+ });
204
+
205
+ const [options] = showSpy.mock.calls[0];
206
+ // Compared as a boolean on purpose: a plain `toBeUndefined` failure makes
207
+ // the diff printer walk the SVGElement, which it cannot inspect.
208
+ expect(options?.source === undefined).toBe(true);
209
+
210
+ // The icon is still the trigger the ARIA tooltip pattern is wired to.
211
+ expect(screen.getByTestId('icon').getAttribute('aria-describedby')).toBe(
212
+ screen.getByRole('tooltip', {hidden: true}).id,
213
+ );
214
+ });
215
+ });
216
+
186
217
  describe('WCAG 1.4.13 — content on hover or focus', () => {
187
218
  it('dismisses on Escape while visible (dismissible)', async () => {
188
219
  const onOpenChange = vi.fn();
@@ -147,6 +147,19 @@ describe('useTooltip — show delay', () => {
147
147
 
148
148
  expect(onShow).not.toHaveBeenCalled();
149
149
  });
150
+
151
+ it('drops a pending show when isEnabled turns false before the delay elapses', () => {
152
+ const onShow = vi.fn();
153
+ const {rerender} = render(<Harness onShow={onShow} />);
154
+
155
+ fireEvent.mouseEnter(trigger());
156
+ tick(100);
157
+ rerender(<Harness isEnabled={false} onShow={onShow} />);
158
+ tick(1000);
159
+
160
+ expect(onShow).not.toHaveBeenCalled();
161
+ expect(tooltipElement().matches(':popover-open')).toBe(false);
162
+ });
150
163
  });
151
164
 
152
165
  describe('useTooltip — touch suppression', () => {
@@ -352,6 +352,17 @@ export function useTooltip(options: TooltipOptions = {}): TooltipReturn {
352
352
  }, delay);
353
353
  }, [isEnabled, isOpen, clearTimeouts, showLayer, delay]);
354
354
 
355
+ // A show scheduled while the triggers were enabled must not outlive them:
356
+ // disabling also detaches the leave listener that would have cancelled it,
357
+ // so the timer would open a tooltip that nothing ever closes. Only the
358
+ // pending show is dropped; a pending hide still runs.
359
+ useEffect(() => {
360
+ if (!isEnabled && showTimeoutRef.current) {
361
+ clearTimeout(showTimeoutRef.current);
362
+ showTimeoutRef.current = null;
363
+ }
364
+ }, [isEnabled]);
365
+
355
366
  // Schedule hide with delay (suppressed when isOpen is true).
356
367
  // A small hover bridge (when hideDelay is 0) lets the pointer travel from the
357
368
  // trigger onto the tooltip surface without the tooltip vanishing — required
@@ -96,14 +96,15 @@ Supporting one does not justify omitting another from the durable contract.
96
96
 
97
97
  ## Behavioral and layout contract
98
98
 
99
- | ID | Invariant | Basis | Contract state |
100
- | --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | --------------------------------------------------- |
101
- | FR1 | The current render contains one Tree list, one Item and Item label per rendered node, optional Header, Chevron, Item description, Start content, End content, and Guide parts, and delegated Chevron glyphs. | Current source, docs, and tests | Current behavior |
102
- | FR2 | Tree list, Item, Chevron, Item label, and Guide carry the five current `tree-list*` targets documented below. | Current source and target docs | Current behavior |
103
- | FR3 | Chevron glyph delegates to Icon's `icon` target. Header, Start content, and End content are consumer-owned and have no TreeList target. | Current source and component docs | Current ownership |
104
- | FR4 | Item description is a stable library-rendered part below Item label and currently has no public target. It does not inherit through `tree-list-item-label`; both spans are siblings inside the row content. | Current source and target docs | Current reachability gap; no new target is accepted |
105
- | FR5 | `TreeListItemData` MUST admit `className`, `style`, and `xstyle` as one per-item styling seam. Every supplied value MUST compose on the same Item row box; an implementation MUST NOT treat current support for only one mechanism as authority to exclude the other two. | Owner decision; `architecture:public-component-api` INV5–INV6; `spec:AST-002/DEC-1` | Settled intent; implementation may be staged |
106
- | FR6 | The Item row box is the logical element that paints the row, owns its hover state, and contains Start content, Item label and description, and End content. The styling seam MUST NOT move to the semantic `<li>` or another element without a compatibility decision. | Owner decision and the caller-owned per-row interaction boundary | Settled intent |
99
+ | ID | Invariant | Basis | Contract state |
100
+ | --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | --------------------------------------------------- |
101
+ | FR1 | The current render contains one Tree list, one Item and Item label per rendered node, optional Header, Chevron, Item description, Start content, End content, and Guide parts, and delegated Chevron glyphs. | Current source, docs, and tests | Current behavior |
102
+ | FR2 | Tree list, Item, Chevron, Item label, and Guide carry the five current `tree-list*` targets documented below. | Current source and target docs | Current behavior |
103
+ | FR3 | Chevron glyph delegates to Icon's `icon` target. Header, Start content, and End content are consumer-owned and have no TreeList target. | Current source and component docs | Current ownership |
104
+ | FR4 | Item description is a stable library-rendered part below Item label and currently has no public target. It does not inherit through `tree-list-item-label`; both spans are siblings inside the row content. | Current source and target docs | Current reachability gap; no new target is accepted |
105
+ | FR5 | `TreeListItemData` MUST admit `className`, `style`, and `xstyle` as one per-item styling seam. Every supplied value MUST compose on the same Item row box; an implementation MUST NOT treat current support for only one mechanism as authority to exclude the other two. | Owner decision; `architecture:public-component-api` INV5–INV6; `spec:AST-002/DEC-1` | Settled intent; implementation may be staged |
106
+ | FR6 | The Item row box is the logical element that paints the row, owns its hover state, and contains Start content, Item label and description, and End content. The styling seam MUST NOT move to the semantic `<li>` or another element without a compatibility decision. | Owner decision and the caller-owned per-row interaction boundary | Settled intent |
107
+ | FR7 | A click on an Item row outside its Item label performs the row's action: an Item with `onClick` runs it; otherwise an Item with children toggles; otherwise an Item whose only action is its link follows that link as Enter does, with modified and middle clicks following the shared enlarged-surface rule in `family:navigation-destinations`. The Chevron and controls in Start content and End content keep their own activation, and a disabled Item does nothing. | Owner decision; `family:navigation-destinations` | Settled intent; implementation may be staged |
107
108
 
108
109
  ### Allowed variation
109
110
 
@@ -183,6 +184,16 @@ structure. Its current lack of a target is a reachability gap, not a decision
183
184
  that it must remain unthemeable. Header, Start content, and End content remain
184
185
  caller-owned even though TreeList positions their slot wrappers.
185
186
 
187
+ ### Design decisions
188
+
189
+ <!-- design-decisions:v1 -->
190
+
191
+ | ID | Decision | Intent or reason | Applies to | Allowed variation |
192
+ | --- | ---------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
193
+ | DD1 | Every Item spans the full width of the tree list at any depth; its nesting indent sits inside the row, ahead of its content. | Hover, pressed, and selected fills line up across depths, so the tree reads as one list of equal rows while labels keep their per-level alignment. | Item at every depth, at rest and through hover, pressed, selected, disabled, and focus | Themes set the indent step with `--tree-list-indent` and may restyle the row's fill, radius, and padding; row padding adds to the indent instead of replacing it. |
194
+ | DD2 | A nested Item's indent area is part of the row's painted target: it shows the row's hover and press feedback. | The whole painted row reads as one target, matching where a click acts (FR7), so no part of a row lights up apart from the rest. | Nested Items that act on click | Themes may restyle the feedback through the `tree-list-item` target. |
195
+ | DD3 | Guides paint above row fills, and an Item whose focus ring is showing paints above the guides. | Hierarchy lines stay continuous through hover and selection, and the focus indicator is never cut; on a focused row that is also filled, the ring takes precedence and the fill covers the guides at that row. | Guide and Item in `lineGuides` trees | Themes may recolor or hide guides through the `tree-list-guide` target. |
196
+
186
197
  ### Theming anatomy
187
198
 
188
199
  <!-- anatomy-theming:v1 -->
@@ -242,15 +253,16 @@ authorize or prohibit a future target.
242
253
 
243
254
  ## Verification map
244
255
 
245
- | Contract | Verification | Representative states | Mutation or failure expectation | Audit section |
246
- | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------ |
247
- | FR1 | `TreeList.test.tsx` rendering, hierarchy, slot, and accessibility suites plus source inspection | Flat, nested, described, headed, and slotted items | Removing asserted Tree list, Header, Item, Chevron, Item label, Item description, Start content, End content, or Guide behavior fails existing role, content, hierarchy, or state assertions; Chevron glyph presence remains source-inspected only. | `audit:TreeList/anatomy` |
248
- | FR2 | `TreeList.test.tsx` and `themingTargets.test.ts` | Five local targets and their documented states | Removing or moving any current local target fails focused class assertions or the global inventory. | `audit:TreeList/theming` |
249
- | FR3 | Source inspection plus Icon public target metadata | Expandable rows and caller-provided slots | Existing focused tests do not assert the composed Icon instance; changing glyph or slot ownership requires this map and the relevant owner metadata to change. | `audit:TreeList/theming` |
250
- | FR4 | `TreeList.test.tsx` description rendering test and source inspection | Item with description | Removing description fails content coverage; adding a target requires an explicit map update. | `audit:TreeList/anatomy` |
251
- | AR1 | Tree View accessibility contract (`internal/a11y-spec`, pending) bound to TreeList, in jsdom and Chromium; `TreeList.test.tsx` row-naming cases ("names a parent row by its label alone, not the chevron toggle", "names a plain row by its label and describes it with its description", "names and describes link and action rows themselves, not only their inner control", "keeps start and end content, including controls, out of the row name", "typeahead matches the label, not the description or start content") and `TreeListRowNames.a11y.chromium.spec.ts` for the browser-computed names; `TreeList.test.tsx` structural ARIA cases ("sets aria-level, aria-posinset, and aria-setsize at the top level", "sets aria-level/posinset/setsize correctly at deeper levels", "sets aria-level across three depths"); `TreeList.test.tsx` and `useTreeFocus.test.tsx` keyboard suites | Flat and nested trees; expanded/collapsed; selected/unselected; LTR and RTL; typeahead | A row loses its role, label name, state or position, two rows hold a Tab stop, an arrow key stops moving or stops mirroring under RTL, Home/End or typeahead stop working, or the tree claims multi-select | `audit:TreeList/accessibility` |
252
- | FR5, FR6, ORD1 | Focused `TreeList.test.tsx` row-identity and styling-composition coverage, required with implementation | One input at a time; all three together; sibling rows | Dropping any mechanism, applying mechanisms to different elements, targeting the `<li>`, changing the merge order, or styling a sibling row fails. | `audit:TreeList/api` |
253
- | Theming anatomy map | `scripts/check-knowledge.mjs` | Canonical anatomy and five current local targets | Missing, extra, duplicated, prefixed, or stale mappings fail repository validation. | `audit:TreeList/theming` |
256
+ | Contract | Verification | Representative states | Mutation or failure expectation | Audit section |
257
+ | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------ |
258
+ | FR1 | `TreeList.test.tsx` rendering, hierarchy, slot, and accessibility suites plus source inspection | Flat, nested, described, headed, and slotted items | Removing asserted Tree list, Header, Item, Chevron, Item label, Item description, Start content, End content, or Guide behavior fails existing role, content, hierarchy, or state assertions; Chevron glyph presence remains source-inspected only. | `audit:TreeList/anatomy` |
259
+ | FR2 | `TreeList.test.tsx` and `themingTargets.test.ts` | Five local targets and their documented states | Removing or moving any current local target fails focused class assertions or the global inventory. | `audit:TreeList/theming` |
260
+ | FR3 | Source inspection plus Icon public target metadata | Expandable rows and caller-provided slots | Existing focused tests do not assert the composed Icon instance; changing glyph or slot ownership requires this map and the relevant owner metadata to change. | `audit:TreeList/theming` |
261
+ | FR4 | `TreeList.test.tsx` description rendering test and source inspection | Item with description | Removing description fails content coverage; adding a target requires an explicit map update. | `audit:TreeList/anatomy` |
262
+ | AR1 | Tree View accessibility contract (`internal/a11y-spec`, pending) bound to TreeList, in jsdom and Chromium; `TreeList.test.tsx` row-naming cases ("names a parent row by its label alone, not the chevron toggle", "names a plain row by its label and describes it with its description", "names and describes link and action rows themselves, not only their inner control", "keeps start and end content, including controls, out of the row name", "typeahead matches the label, not the description or start content") and `TreeListRowNames.a11y.chromium.spec.ts` for the browser-computed names; `TreeList.test.tsx` structural ARIA cases ("sets aria-level, aria-posinset, and aria-setsize at the top level", "sets aria-level/posinset/setsize correctly at deeper levels", "sets aria-level across three depths"); `TreeList.test.tsx` and `useTreeFocus.test.tsx` keyboard suites | Flat and nested trees; expanded/collapsed; selected/unselected; LTR and RTL; typeahead | A row loses its role, label name, state or position, two rows hold a Tab stop, an arrow key stops moving or stops mirroring under RTL, Home/End or typeahead stop working, or the tree claims multi-select | `audit:TreeList/accessibility` |
263
+ | FR5, FR6, ORD1 | Focused `TreeList.test.tsx` row-identity and styling-composition coverage, required with implementation | One input at a time; all three together; sibling rows | Dropping any mechanism, applying mechanisms to different elements, targeting the `<li>`, changing the merge order, or styling a sibling row fails. | `audit:TreeList/api` |
264
+ | FR7 | Focused `TreeList.test.tsx` row-click coverage, required with implementation | Action, parent, link, and disabled rows; clicks in the indent and padding, on the Chevron, and on Start and End content controls; plain, modified, and middle clicks | A click outside the label does nothing or runs the wrong action, a link-row click reaches ancestors twice, the Chevron or a Start or End content control activates the row, or a disabled row acts. | `audit:TreeList/behavior` |
265
+ | Theming anatomy map | `scripts/check-knowledge.mjs` | Canonical anatomy and five current local targets | Missing, extra, duplicated, prefixed, or stale mappings fail repository validation. | `audit:TreeList/theming` |
254
266
 
255
267
  Focused target-placement assertions cover all five local targets. Runtime support
256
268
  and mutation-sensitive coverage for the item styling seam may land separately,
@@ -624,6 +624,32 @@ export const BaseTypeahead = function BaseTypeahead<T extends SearchableItem>({
624
624
  sourceGenRef.current = ++searchGenRef.current;
625
625
  }
626
626
 
627
+ // The previous source is remembered in state, not a ref, so a render React
628
+ // discards cannot consume the comparison without its state updates.
629
+ const [sourceOfState, setSourceOfState] = useState(searchSource);
630
+ if (sourceOfState !== searchSource) {
631
+ setSourceOfState(searchSource);
632
+ if (isLoading) {
633
+ setIsLoading(false);
634
+ setHasSearched(false);
635
+ }
636
+ loadingRef.current = false;
637
+ }
638
+
639
+ // When the search source is replaced, any in-flight load from the previous
640
+ // source is abandoned (generation bumped above). Notify the wrapper's busy lane
641
+ // in an effect so external store listeners run safely after commit.
642
+ const isInitialMountRef = useRef(true);
643
+ useEffect(() => {
644
+ if (isInitialMountRef.current) {
645
+ isInitialMountRef.current = false;
646
+ return;
647
+ }
648
+ if (!loadingRef.current) {
649
+ onLoadingChangeRef.current?.(false);
650
+ }
651
+ }, [searchSource]);
652
+
627
653
  // Layer for dropdown
628
654
  const handleLayerShow = useCallback(() => {
629
655
  onOpenChange?.(true);