@astryxdesign/core 0.1.4-canary.39ffd21 → 0.1.4-canary.3d64ef2

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 (234) hide show
  1. package/dist/Banner/Banner.d.ts.map +1 -1
  2. package/dist/Banner/Banner.js +8 -1
  3. package/dist/Calendar/Calendar.d.ts.map +1 -1
  4. package/dist/Calendar/Calendar.js +31 -11
  5. package/dist/Calendar/styles.d.ts +2 -2
  6. package/dist/Calendar/styles.d.ts.map +1 -1
  7. package/dist/Calendar/styles.js +2 -2
  8. package/dist/Card/Card.d.ts +4 -2
  9. package/dist/Card/Card.d.ts.map +1 -1
  10. package/dist/Card/Card.js +15 -7
  11. package/dist/Carousel/Carousel.d.ts.map +1 -1
  12. package/dist/Carousel/Carousel.js +1 -0
  13. package/dist/Chat/ChatToolCalls.d.ts.map +1 -1
  14. package/dist/Chat/ChatToolCalls.js +8 -1
  15. package/dist/CheckboxInput/CheckboxInput.d.ts +1 -1
  16. package/dist/CheckboxInput/CheckboxInput.d.ts.map +1 -1
  17. package/dist/CheckboxInput/CheckboxInput.js +3 -1
  18. package/dist/ClickableCard/ClickableCard.d.ts.map +1 -1
  19. package/dist/ClickableCard/ClickableCard.js +26 -3
  20. package/dist/CodeBlock/CodeBlock.d.ts +10 -1
  21. package/dist/CodeBlock/CodeBlock.d.ts.map +1 -1
  22. package/dist/CodeBlock/CodeBlock.js +23 -5
  23. package/dist/Collapsible/Collapsible.d.ts +9 -3
  24. package/dist/Collapsible/Collapsible.d.ts.map +1 -1
  25. package/dist/Collapsible/Collapsible.js +101 -13
  26. package/dist/Collapsible/CollapsibleGroup.d.ts +39 -7
  27. package/dist/Collapsible/CollapsibleGroup.d.ts.map +1 -1
  28. package/dist/Collapsible/CollapsibleGroup.js +70 -9
  29. package/dist/Collapsible/CollapsibleGroupContext.d.ts +31 -0
  30. package/dist/Collapsible/CollapsibleGroupContext.d.ts.map +1 -1
  31. package/dist/Collapsible/CollapsibleGroupContext.js +33 -3
  32. package/dist/Collapsible/index.d.ts +1 -0
  33. package/dist/Collapsible/index.d.ts.map +1 -1
  34. package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
  35. package/dist/ContextMenu/ContextMenu.js +2 -0
  36. package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
  37. package/dist/DateTimeInput/DateTimeInput.js +2 -0
  38. package/dist/Grid/Grid.d.ts +4 -3
  39. package/dist/Grid/Grid.d.ts.map +1 -1
  40. package/dist/Grid/Grid.js +26 -9
  41. package/dist/Icon/Icon.d.ts.map +1 -1
  42. package/dist/Icon/Icon.js +5 -1
  43. package/dist/InputGroup/InputGroup.js +2 -2
  44. package/dist/Item/Item.js +1 -1
  45. package/dist/Lightbox/Lightbox.d.ts.map +1 -1
  46. package/dist/Lightbox/Lightbox.js +26 -2
  47. package/dist/NumberInput/NumberInput.d.ts.map +1 -1
  48. package/dist/NumberInput/NumberInput.js +32 -16
  49. package/dist/Pagination/Pagination.d.ts.map +1 -1
  50. package/dist/Pagination/Pagination.js +87 -36
  51. package/dist/PowerSearch/PowerSearch.d.ts.map +1 -1
  52. package/dist/PowerSearch/PowerSearch.js +39 -21
  53. package/dist/ProgressBar/ProgressBar.d.ts.map +1 -1
  54. package/dist/ProgressBar/ProgressBar.js +2 -1
  55. package/dist/Resizable/ResizeHandle.d.ts.map +1 -1
  56. package/dist/Resizable/ResizeHandle.js +10 -2
  57. package/dist/SideNav/SideNav.d.ts.map +1 -1
  58. package/dist/SideNav/SideNav.js +6 -2
  59. package/dist/TabList/TabMenu.d.ts.map +1 -1
  60. package/dist/TabList/TabMenu.js +34 -7
  61. package/dist/Table/BaseTable.d.ts.map +1 -1
  62. package/dist/Table/BaseTable.js +14 -3
  63. package/dist/Table/plugins/columnResize/useTableColumnResize.d.ts.map +1 -1
  64. package/dist/Table/plugins/columnResize/useTableColumnResize.js +5 -2
  65. package/dist/Table/plugins/pagination/paginateData.d.ts.map +1 -1
  66. package/dist/Table/plugins/pagination/paginateData.js +5 -1
  67. package/dist/Table/plugins/selection/useTableSelectionState.d.ts.map +1 -1
  68. package/dist/Table/plugins/selection/useTableSelectionState.js +11 -1
  69. package/dist/Table/plugins/sortable/useTableSortableState.d.ts.map +1 -1
  70. package/dist/Table/plugins/sortable/useTableSortableState.js +8 -4
  71. package/dist/Thumbnail/Thumbnail.d.ts +3 -2
  72. package/dist/Thumbnail/Thumbnail.d.ts.map +1 -1
  73. package/dist/Thumbnail/Thumbnail.js +1 -0
  74. package/dist/TimeInput/TimeInput.d.ts.map +1 -1
  75. package/dist/TimeInput/TimeInput.js +6 -1
  76. package/dist/Toast/Toast.d.ts.map +1 -1
  77. package/dist/Toast/Toast.js +11 -3
  78. package/dist/Toast/ToastViewport.d.ts.map +1 -1
  79. package/dist/Toast/ToastViewport.js +12 -0
  80. package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
  81. package/dist/Typeahead/BaseTypeahead.js +5 -2
  82. package/dist/astryx.css +7 -0
  83. package/dist/astryx.umd.js +48 -48
  84. package/dist/astryx.umd.js.map +4 -4
  85. package/dist/hooks/focusableSelector.d.ts +1 -1
  86. package/dist/hooks/focusableSelector.d.ts.map +1 -1
  87. package/dist/hooks/focusableSelector.js +1 -1
  88. package/dist/hooks/useFocusTrap.d.ts +3 -0
  89. package/dist/hooks/useFocusTrap.d.ts.map +1 -1
  90. package/dist/hooks/useFocusTrap.js +38 -1
  91. package/dist/theme/defineTheme.d.ts +1 -1
  92. package/dist/theme/defineTheme.d.ts.map +1 -1
  93. package/dist/theme/expandColorScale.d.ts.map +1 -1
  94. package/dist/theme/expandColorScale.js +10 -3
  95. package/dist/theme/hct.d.ts +0 -15
  96. package/dist/theme/hct.d.ts.map +1 -1
  97. package/dist/theme/hct.js +9 -9
  98. package/dist/theme/tokens.d.ts +10 -2
  99. package/dist/theme/tokens.d.ts.map +1 -1
  100. package/dist/theme/tokens.js +220 -4
  101. package/dist/utils/color.d.ts +42 -0
  102. package/dist/utils/color.d.ts.map +1 -0
  103. package/dist/utils/color.js +150 -0
  104. package/dist/utils/index.d.ts +2 -0
  105. package/dist/utils/index.d.ts.map +1 -1
  106. package/dist/utils/index.js +2 -1
  107. package/dist/utils/timeParser.d.ts.map +1 -1
  108. package/dist/utils/timeParser.js +8 -5
  109. package/package.json +1 -1
  110. package/src/Avatar/Avatar.doc.mjs +11 -0
  111. package/src/Banner/Banner.doc.mjs +8 -0
  112. package/src/Banner/Banner.test.tsx +38 -0
  113. package/src/Banner/Banner.tsx +8 -1
  114. package/src/Calendar/Calendar.doc.mjs +1 -1
  115. package/src/Calendar/Calendar.test.tsx +112 -4
  116. package/src/Calendar/Calendar.tsx +26 -5
  117. package/src/Calendar/styles.ts +12 -2
  118. package/src/Card/Card.tsx +16 -6
  119. package/src/Carousel/Carousel.doc.mjs +12 -12
  120. package/src/Carousel/Carousel.test.tsx +13 -0
  121. package/src/Carousel/Carousel.tsx +1 -0
  122. package/src/Chat/Chat.doc.mjs +1 -1
  123. package/src/Chat/ChatToolCalls.test.tsx +55 -0
  124. package/src/Chat/ChatToolCalls.tsx +10 -12
  125. package/src/CheckboxInput/CheckboxInput.doc.mjs +3 -3
  126. package/src/CheckboxInput/CheckboxInput.test.tsx +71 -4
  127. package/src/CheckboxInput/CheckboxInput.tsx +2 -0
  128. package/src/Citation/Citation.doc.mjs +5 -0
  129. package/src/ClickableCard/ClickableCard.tsx +45 -2
  130. package/src/CodeBlock/CodeBlock.doc.mjs +7 -2
  131. package/src/CodeBlock/CodeBlock.test.tsx +87 -3
  132. package/src/CodeBlock/CodeBlock.tsx +43 -3
  133. package/src/Collapsible/Collapsible.doc.mjs +8 -4
  134. package/src/Collapsible/Collapsible.tsx +86 -10
  135. package/src/Collapsible/CollapsibleGroup.doc.mjs +27 -3
  136. package/src/Collapsible/CollapsibleGroup.test.tsx +288 -0
  137. package/src/Collapsible/CollapsibleGroup.tsx +107 -9
  138. package/src/Collapsible/CollapsibleGroupContext.tsx +42 -2
  139. package/src/Collapsible/index.ts +4 -0
  140. package/src/CommandPalette/CommandPaletteEmpty.doc.mjs +5 -0
  141. package/src/CommandPalette/CommandPaletteGroup.doc.mjs +10 -0
  142. package/src/CommandPalette/CommandPaletteInput.doc.mjs +6 -0
  143. package/src/CommandPalette/CommandPaletteItem.doc.mjs +6 -0
  144. package/src/CommandPalette/CommandPaletteList.doc.mjs +9 -0
  145. package/src/ContextMenu/ContextMenu.doc.mjs +15 -0
  146. package/src/ContextMenu/ContextMenu.test.tsx +15 -0
  147. package/src/ContextMenu/ContextMenu.tsx +2 -0
  148. package/src/ContextMenu/ContextMenuItem.doc.mjs +6 -0
  149. package/src/DateTimeInput/DateTimeInput.test.tsx +70 -0
  150. package/src/DateTimeInput/DateTimeInput.tsx +2 -0
  151. package/src/Dialog/DialogHeader.doc.mjs +45 -0
  152. package/src/DropdownMenu/DropdownMenu.doc.mjs +1 -1
  153. package/src/Field/FieldLabel.test.tsx +2 -2
  154. package/src/FormLayout/__snapshots__/FormLayout.test.tsx.snap +0 -3
  155. package/src/Grid/Grid.test.tsx +43 -18
  156. package/src/Grid/Grid.tsx +26 -9
  157. package/src/Icon/Icon.test.tsx +41 -29
  158. package/src/Icon/Icon.tsx +6 -2
  159. package/src/InputGroup/InputGroup.tsx +2 -2
  160. package/src/Item/Item.tsx +1 -1
  161. package/src/Layout/LayoutContent.doc.mjs +12 -0
  162. package/src/Layout/LayoutPanel.doc.mjs +40 -0
  163. package/src/Lightbox/Lightbox.doc.mjs +5 -0
  164. package/src/Lightbox/Lightbox.test.tsx +123 -2
  165. package/src/Lightbox/Lightbox.tsx +29 -1
  166. package/src/NavMenu/NavMenu.doc.mjs +15 -0
  167. package/src/NumberInput/NumberInput.test.tsx +48 -3
  168. package/src/NumberInput/NumberInput.tsx +32 -15
  169. package/src/Overlay/Overlay.doc.mjs +6 -0
  170. package/src/Pagination/Pagination.test.tsx +143 -0
  171. package/src/Pagination/Pagination.tsx +75 -23
  172. package/src/Popover/Popover.test.tsx +65 -0
  173. package/src/PowerSearch/PowerSearch.doc.mjs +2 -2
  174. package/src/PowerSearch/PowerSearch.test.tsx +109 -1
  175. package/src/PowerSearch/PowerSearch.tsx +37 -16
  176. package/src/ProgressBar/ProgressBar.test.tsx +10 -0
  177. package/src/ProgressBar/ProgressBar.tsx +2 -1
  178. package/src/Resizable/ResizeHandle.test.tsx +180 -0
  179. package/src/Resizable/ResizeHandle.tsx +10 -2
  180. package/src/SegmentedControl/SegmentedControl.doc.mjs +1 -1
  181. package/src/SelectableCard/SelectableCard.doc.mjs +1 -1
  182. package/src/SideNav/SideNav.doc.mjs +7 -1
  183. package/src/SideNav/SideNav.test.tsx +20 -0
  184. package/src/SideNav/SideNav.tsx +6 -2
  185. package/src/TabList/TabList.test.tsx +185 -1
  186. package/src/TabList/TabMenu.tsx +45 -9
  187. package/src/Table/BaseTable.tsx +13 -2
  188. package/src/Table/Table.test.tsx +51 -0
  189. package/src/Table/plugins/columnResize/useTableColumnResize.test.tsx +13 -0
  190. package/src/Table/plugins/columnResize/useTableColumnResize.tsx +3 -1
  191. package/src/Table/plugins/pagination/paginateData.ts +5 -1
  192. package/src/Table/plugins/pagination/useTablePagination.test.tsx +13 -0
  193. package/src/Table/plugins/selection/useTableSelectionState.test.tsx +23 -0
  194. package/src/Table/plugins/selection/useTableSelectionState.tsx +11 -6
  195. package/src/Table/plugins/sortable/useTableSortableState.test.tsx +35 -1
  196. package/src/Table/plugins/sortable/useTableSortableState.tsx +8 -4
  197. package/src/TextArea/TextArea.test.tsx +3 -3
  198. package/src/TextInput/TextInput.test.tsx +3 -3
  199. package/src/Thumbnail/Thumbnail.test.tsx +36 -2
  200. package/src/Thumbnail/Thumbnail.tsx +4 -2
  201. package/src/TimeInput/TimeInput.test.tsx +37 -0
  202. package/src/TimeInput/TimeInput.tsx +11 -1
  203. package/src/Timestamp/Timestamp.doc.mjs +1 -1
  204. package/src/Toast/Toast.tsx +11 -3
  205. package/src/Toast/ToastViewport.test.tsx +64 -0
  206. package/src/Toast/ToastViewport.tsx +12 -0
  207. package/src/ToggleButton/ToggleButton.doc.mjs +1 -0
  208. package/src/Tokenizer/Tokenizer.doc.mjs +2 -2
  209. package/src/TopNav/TopNav.doc.mjs +1 -1
  210. package/src/TreeList/TreeList.doc.mjs +2 -2
  211. package/src/Typeahead/BaseTypeahead.doc.mjs +19 -0
  212. package/src/Typeahead/BaseTypeahead.tsx +5 -2
  213. package/src/Typeahead/Typeahead.doc.mjs +1 -1
  214. package/src/Typeahead/Typeahead.test.tsx +45 -1
  215. package/src/__tests__/TestIcon.tsx +26 -0
  216. package/src/docPropReferences.test.ts +178 -0
  217. package/src/hooks/focusableSelector.ts +1 -1
  218. package/src/hooks/useFocusTrap.doc.mjs +4 -2
  219. package/src/hooks/useFocusTrap.test.tsx +149 -1
  220. package/src/hooks/useFocusTrap.ts +50 -1
  221. package/src/theme/SyntaxTheme.doc.mjs +4 -4
  222. package/src/theme/defineTheme.ts +4 -13
  223. package/src/theme/derivedVarRegistry.test.ts +2 -2
  224. package/src/theme/expandColorScale.test.ts +29 -0
  225. package/src/theme/expandColorScale.ts +11 -6
  226. package/src/theme/hct.ts +9 -17
  227. package/src/theme/tokens.test.ts +182 -9
  228. package/src/theme/tokens.ts +239 -4
  229. package/src/theme/useTheme.test.tsx +20 -0
  230. package/src/utils/color.test.ts +133 -0
  231. package/src/utils/color.ts +148 -0
  232. package/src/utils/index.ts +3 -0
  233. package/src/utils/timeParser.test.ts +13 -0
  234. package/src/utils/timeParser.ts +8 -5
@@ -168,14 +168,18 @@ function defaultCompare<T extends Record<string, unknown>>(
168
168
  const aVal = a[sortKey];
169
169
  const bVal = b[sortKey];
170
170
 
171
- // null/undefined sort to the end
172
- if (aVal == null && bVal == null) {
171
+ // null/undefined/NaN sort to the end. NaN must not reach the numeric fast
172
+ // path: a NaN comparator result reads as "equal" to Array.sort, which makes
173
+ // the comparator inconsistent and corrupts the order of the other rows.
174
+ const aMissing = aVal == null || (typeof aVal === 'number' && Number.isNaN(aVal));
175
+ const bMissing = bVal == null || (typeof bVal === 'number' && Number.isNaN(bVal));
176
+ if (aMissing && bMissing) {
173
177
  return 0;
174
178
  }
175
- if (aVal == null) {
179
+ if (aMissing) {
176
180
  return 1;
177
181
  }
178
- if (bVal == null) {
182
+ if (bMissing) {
179
183
  return -1;
180
184
  }
181
185
 
@@ -13,7 +13,7 @@ import {useState} from 'react';
13
13
  import {describe, it, expect, vi, beforeEach} from 'vitest';
14
14
  import {render, screen, fireEvent, waitFor} from '@testing-library/react';
15
15
  import userEvent from '@testing-library/user-event';
16
- import {MagnifyingGlassIcon} from '@heroicons/react/24/outline';
16
+ import {TestIcon} from '../__tests__/TestIcon';
17
17
  import {TextArea} from './TextArea';
18
18
 
19
19
  // Mock showPopover/hidePopover since jsdom does not implement them. Used by the
@@ -193,7 +193,7 @@ describe('TextArea', () => {
193
193
  label="Description"
194
194
  value=""
195
195
  onChange={() => {}}
196
- startIcon={MagnifyingGlassIcon}
196
+ startIcon={TestIcon}
197
197
  />,
198
198
  );
199
199
  expect(screen.getByRole('textbox')).toBeInTheDocument();
@@ -577,7 +577,7 @@ describe('TextArea', () => {
577
577
  label="Notes"
578
578
  value=""
579
579
  onChange={() => {}}
580
- startIcon={<MagnifyingGlassIcon />}
580
+ startIcon={<TestIcon />}
581
581
  />,
582
582
  );
583
583
 
@@ -12,7 +12,7 @@
12
12
  import {describe, it, expect, vi, beforeEach} from 'vitest';
13
13
  import {render, screen, fireEvent, waitFor} from '@testing-library/react';
14
14
  import userEvent from '@testing-library/user-event';
15
- import {MagnifyingGlassIcon} from '@heroicons/react/24/outline';
15
+ import {TestIcon} from '../__tests__/TestIcon';
16
16
  import {TextInput} from './TextInput';
17
17
 
18
18
  // Mock showPopover/hidePopover since jsdom does not implement them. Used by the
@@ -163,7 +163,7 @@ describe('TextInput', () => {
163
163
  label="Search"
164
164
  value=""
165
165
  onChange={() => {}}
166
- startIcon={MagnifyingGlassIcon}
166
+ startIcon={TestIcon}
167
167
  />,
168
168
  );
169
169
  expect(screen.getByRole('textbox')).toBeInTheDocument();
@@ -482,7 +482,7 @@ describe('TextInput', () => {
482
482
  label="Search"
483
483
  value=""
484
484
  onChange={() => {}}
485
- startIcon={<MagnifyingGlassIcon />}
485
+ startIcon={<TestIcon />}
486
486
  />,
487
487
  );
488
488
 
@@ -37,11 +37,45 @@ describe('Thumbnail', () => {
37
37
  expect(screen.getByRole('status')).toBeInTheDocument();
38
38
  });
39
39
 
40
- it('uses label as accessible name on root', () => {
40
+ it('exposes the label as an accessible name via a valid group role', () => {
41
41
  render(<Thumbnail label="photo.png" data-testid="thumb" />);
42
- expect(screen.getByTestId('thumb')).toHaveAttribute('aria-label', 'photo.png');
42
+ // The accessible name must be carried by a valid named role (group),
43
+ // not by a bare aria-label on a generic div (aria-prohibited-attr).
44
+ const group = screen.getByRole('group', {name: 'photo.png'});
45
+ expect(group).toBe(screen.getByTestId('thumb'));
43
46
  });
44
47
 
48
+ it('does not put aria-label on a generic (roleless) element', () => {
49
+ render(<Thumbnail label="photo.png" data-testid="thumb" />);
50
+ const thumb = screen.getByTestId('thumb');
51
+ // The labeled element must declare a role so the name is legitimate.
52
+ expect(thumb).toHaveAttribute('role', 'group');
53
+ });
54
+
55
+ it('keeps interactive children accessible while exposing the group name', () => {
56
+ const onRemove = vi.fn();
57
+ render(
58
+ <Thumbnail
59
+ src="/img.jpg"
60
+ alt="Clickable"
61
+ label="file.png"
62
+ onClick={vi.fn()}
63
+ onRemove={onRemove}
64
+ />,
65
+ );
66
+ // A group role (unlike img) must not hide descendant controls.
67
+ expect(
68
+ screen.getByRole('group', {name: 'file.png — Clickable'}),
69
+ ).toBeInTheDocument();
70
+ expect(
71
+ screen.getByRole('button', {name: 'Open file.png — Clickable'}),
72
+ ).toBeInTheDocument();
73
+ expect(
74
+ screen.getByRole('button', {name: 'Remove file.png — Clickable'}),
75
+ ).toBeInTheDocument();
76
+ });
77
+
78
+
45
79
  it('label is shown via tooltip, not as inline text', () => {
46
80
  render(<Thumbnail label="photo.png" data-testid="thumb" />);
47
81
  // Label should exist in DOM (tooltip) but not as a direct child text node
@@ -58,8 +58,9 @@ export interface ThumbnailProps extends BaseProps<HTMLDivElement> {
58
58
  alt?: string;
59
59
  /**
60
60
  * Accessible label for the thumbnail (e.g. file name).
61
- * Not rendered visually — shown in a tooltip on hover and used
62
- * as accessible name for the remove button.
61
+ * Not rendered visually — shown in a tooltip on hover, exposed as the
62
+ * accessible name of the thumbnail group, and used as the accessible
63
+ * name for the remove button.
63
64
  */
64
65
  label?: string;
65
66
  /**
@@ -296,6 +297,7 @@ export function Thumbnail({
296
297
  <div
297
298
  ref={ref}
298
299
  data-testid={testId}
300
+ role="group"
299
301
  aria-label={accessibleName}
300
302
  {...mergeProps(
301
303
  themeProps('thumbnail'),
@@ -160,6 +160,43 @@ describe('TimeInput', () => {
160
160
  expect(onChange).not.toHaveBeenCalled();
161
161
  });
162
162
 
163
+ it('sets aria-invalid="true" when typed input is out of range', () => {
164
+ render(<TimeInput label="Time" onChange={() => {}} />);
165
+
166
+ const input = screen.getByRole('textbox');
167
+ fireEvent.change(input, {target: {value: '25:99'}});
168
+
169
+ expect(input).toHaveAttribute('aria-invalid', 'true');
170
+ });
171
+
172
+ it('does not set aria-invalid when typed input is a valid time', () => {
173
+ render(<TimeInput label="Time" onChange={() => {}} />);
174
+
175
+ const input = screen.getByRole('textbox');
176
+ fireEvent.change(input, {target: {value: '3:45 pm'}});
177
+
178
+ expect(input).not.toHaveAttribute('aria-invalid');
179
+ });
180
+
181
+ it('announces an alert message when typed input is invalid', () => {
182
+ render(<TimeInput label="Time" onChange={() => {}} />);
183
+
184
+ const input = screen.getByRole('textbox');
185
+ fireEvent.change(input, {target: {value: '25:99'}});
186
+
187
+ expect(screen.getByRole('alert')).toHaveTextContent('Invalid time');
188
+ });
189
+
190
+ it('does not announce an alert message when input is valid', () => {
191
+ render(<TimeInput label="Time" onChange={() => {}} />);
192
+
193
+ const input = screen.getByRole('textbox');
194
+ fireEvent.change(input, {target: {value: '3:45 pm'}});
195
+
196
+ expect(screen.getByRole('alert')).toHaveTextContent('');
197
+ expect(screen.queryByText('Invalid time')).not.toBeInTheDocument();
198
+ });
199
+
163
200
  it('calls onChange on blur when input is valid', async () => {
164
201
  const user = userEvent.setup();
165
202
  const onChange = vi.fn();
@@ -635,7 +635,9 @@ export function TimeInput({
635
635
  data-autofocus={hasAutoFocus || undefined}
636
636
  aria-describedby={ariaDescribedBy}
637
637
  aria-required={isRequired === true ? 'true' : undefined}
638
- aria-invalid={status?.type === 'error' ? 'true' : undefined}
638
+ aria-invalid={
639
+ status?.type === 'error' || !isInputValid ? 'true' : undefined
640
+ }
639
641
  aria-busy={isBusy || undefined}
640
642
  aria-labelledby={ariaLabelledBy}
641
643
  {...stylex.props(
@@ -644,6 +646,14 @@ export function TimeInput({
644
646
  !isInputValid && styles.inputInvalid,
645
647
  )}
646
648
  />
649
+ {/*
650
+ Live region announcing invalid typed input to assistive technology.
651
+ The value silently reverts on blur, so without this a screen-reader
652
+ user would get no feedback that their entry was rejected (WCAG 3.3.1).
653
+ */}
654
+ <VisuallyHidden as="div" role="alert" aria-live="assertive">
655
+ {!isInputValid ? 'Invalid time' : ''}
656
+ </VisuallyHidden>
647
657
  {isBusy && <Spinner size="sm" />}
648
658
  {hasClear && value && !isDisabled && (
649
659
  <button
@@ -84,7 +84,7 @@ export const docs = {
84
84
  ],
85
85
  theming: {
86
86
  targets: [
87
- {className: 'astryx-timestamp', visualProps: ['type', 'color']},
87
+ {className: 'astryx-timestamp', visualProps: ['type', 'color', 'format']},
88
88
  ],
89
89
  },
90
90
  usage: {
@@ -121,8 +121,14 @@ export function Toast({
121
121
  // Will be initialized by startTimer when actually used
122
122
  const startTimeRef = useRef<number | null>(null);
123
123
 
124
+ // Read onDismiss through a ref: the viewport re-creates it on every render
125
+ // (another toast arriving/exiting), and a startTimer that depends on it
126
+ // would restart — and un-pause — this toast's timer on unrelated renders.
127
+ const onDismissRef = useRef(onDismiss);
128
+ onDismissRef.current = onDismiss;
129
+
124
130
  const startTimer = useCallback(() => {
125
- if (!isAutoHide) {
131
+ if (!isAutoHide || isPausedRef.current) {
126
132
  return;
127
133
  }
128
134
  if (timerRef.current) {
@@ -130,9 +136,9 @@ export function Toast({
130
136
  }
131
137
  startTimeRef.current = Date.now();
132
138
  timerRef.current = setTimeout(() => {
133
- onDismiss('auto');
139
+ onDismissRef.current('auto');
134
140
  }, remainingRef.current);
135
- }, [isAutoHide, onDismiss]);
141
+ }, [isAutoHide]);
136
142
 
137
143
  const pauseTimer = useCallback(() => {
138
144
  if (!isAutoHide || isPausedRef.current) {
@@ -165,6 +171,8 @@ export function Toast({
165
171
  clearTimeout(timerRef.current);
166
172
  }
167
173
  };
174
+ // startTimer's identity is stable per isAutoHide, so this runs on mount
175
+ // and on a genuine duration change — not on unrelated viewport renders.
168
176
  }, [autoHideDuration, startTimer]);
169
177
 
170
178
  // Pause the auto-hide timer while the window is not focused, so a toast
@@ -212,3 +212,67 @@ describe('ToastViewport region ARIA', () => {
212
212
  expect(region).not.toHaveAttribute('aria-modal');
213
213
  });
214
214
  });
215
+
216
+ describe('toast timer lifecycle (#3589)', () => {
217
+ it('fires onHide exactly once when dismissed twice during the exit window', () => {
218
+ const onHide = vi.fn();
219
+ renderViewport(
220
+ <ShowToastButton options={{body: 'Once', onHide}} triggerLabel="Show" />,
221
+ );
222
+ act(() => {
223
+ fireEvent.click(screen.getByText('Show'));
224
+ });
225
+ const dismiss = screen.getByRole('button', {name: 'Dismiss notification'});
226
+ act(() => {
227
+ fireEvent.click(dismiss);
228
+ });
229
+ // The toast stays mounted during its exit transition; a second click
230
+ // lands on the same still-mounted button.
231
+ act(() => {
232
+ fireEvent.click(dismiss);
233
+ });
234
+ expect(onHide).toHaveBeenCalledTimes(1);
235
+ });
236
+
237
+ it('keeps a window-blur pause alive when another toast arrives', () => {
238
+ vi.useFakeTimers();
239
+ try {
240
+ const onHide = vi.fn();
241
+ renderViewport(
242
+ <>
243
+ <ShowToastButton
244
+ options={{body: 'Paused', autoHideDuration: 3000, onHide}}
245
+ triggerLabel="Show A"
246
+ />
247
+ <ShowToastButton options={INFO_B} triggerLabel="Show B" />
248
+ </>,
249
+ );
250
+ act(() => {
251
+ fireEvent.click(screen.getByText('Show A'));
252
+ });
253
+ act(() => {
254
+ fireEvent.blur(window);
255
+ });
256
+ // A second toast arriving re-renders the viewport; the paused timer
257
+ // must not silently restart.
258
+ act(() => {
259
+ fireEvent.click(screen.getByText('Show B'));
260
+ });
261
+ act(() => {
262
+ vi.advanceTimersByTime(60_000);
263
+ });
264
+ expect(onHide).not.toHaveBeenCalled();
265
+ // Focus returns: the remaining time resumes and completes normally.
266
+ act(() => {
267
+ fireEvent.focus(window);
268
+ });
269
+ act(() => {
270
+ vi.advanceTimersByTime(60_000);
271
+ });
272
+ expect(onHide).toHaveBeenCalledTimes(1);
273
+ expect(onHide).toHaveBeenCalledWith('auto');
274
+ } finally {
275
+ vi.useRealTimers();
276
+ }
277
+ });
278
+ });
@@ -114,6 +114,9 @@ export function ToastViewport({
114
114
 
115
115
  // Show the popover on mount so it enters the top layer.
116
116
  const viewportRef = useRef<HTMLDivElement>(null);
117
+ // Toast ids whose exit has begun — guards onHide from double-firing (see
118
+ // removeToast). Mirrors exitingIds state, readable synchronously.
119
+ const exitingIdsRef = useRef<Set<string>>(new Set());
117
120
  // The element that was focused before the user jumped into the viewport
118
121
  // (via F6). Used to restore focus once all toasts are dismissed so focus
119
122
  // never falls to <body>.
@@ -171,6 +174,14 @@ export function ToastViewport({
171
174
  }, []);
172
175
 
173
176
  const removeToast = useCallback((id: string, reason: ToastDismissReason) => {
177
+ // An exiting toast stays in toastsRef until its exit transition ends, so
178
+ // a second dismissal inside that window (double-click, auto-timer plus
179
+ // manual dismiss()) would re-fire onHide. Track exiting ids in a ref —
180
+ // the exitingIds state dedupe below runs too late to protect onHide.
181
+ if (exitingIdsRef.current.has(id)) {
182
+ return;
183
+ }
184
+ exitingIdsRef.current.add(id);
174
185
  const entry = toastsRef.current.find(t => t.id === id);
175
186
  if (entry) {
176
187
  entry.options.onHide?.(reason);
@@ -210,6 +221,7 @@ export function ToastViewport({
210
221
  }, []);
211
222
 
212
223
  const handleExited = useCallback((id: string) => {
224
+ exitingIdsRef.current.delete(id);
213
225
  setExitingIds(prev => {
214
226
  if (!prev.has(id)) {
215
227
  return prev;
@@ -17,6 +17,7 @@ export const docs = {
17
17
  theming: {
18
18
  targets: [
19
19
  {className: 'astryx-toggle-button-group'},
20
+ {className: 'astryx-toggle-button', states: ['isPressed']},
20
21
  ],
21
22
  },
22
23
  description: 'A button that toggles between pressed and unpressed states. Thin wrapper over Button with controlled toggle pattern, icon swap, and font weight emphasis.',
@@ -189,7 +189,7 @@ export const docs = {
189
189
  ],
190
190
  theming: {
191
191
  targets: [
192
- {className: 'astryx-tokenizer', visualProps: ['size']},
192
+ {className: 'astryx-tokenizer', visualProps: ['size', 'status']},
193
193
  ],
194
194
  },
195
195
  usage: {
@@ -390,7 +390,7 @@ export const docsZh = {
390
390
  ],
391
391
  theming: {
392
392
  targets: [
393
- {className: 'astryx-tokenizer', visualProps: ['size']},
393
+ {className: 'astryx-tokenizer', visualProps: ['size', 'status']},
394
394
  ],
395
395
  },
396
396
  usage: {
@@ -17,7 +17,7 @@ export const docs = {
17
17
  theming: {
18
18
  targets: [
19
19
  {className: 'astryx-top-nav', states: ['mode']},
20
- {className: 'astryx-top-nav-item', states: ['mode']},
20
+ {className: 'astryx-top-nav-item', states: ['mode', 'selected']},
21
21
  {className: 'astryx-top-nav-heading'},
22
22
  {className: 'astryx-top-nav-mega-menu', states: ['mode']},
23
23
  {className: 'astryx-top-nav-mega-menu-item', states: ['mode']},
@@ -25,7 +25,7 @@ export const docs = {
25
25
  theming: {
26
26
  targets: [
27
27
  {className: 'astryx-tree-list', visualProps: ['density']},
28
- {className: 'astryx-tree-list-item', states: ['selected', 'disabled']},
28
+ {className: 'astryx-tree-list-item', visualProps: ['density'], states: ['selected', 'disabled']},
29
29
  ],
30
30
  },
31
31
  components: [
@@ -89,7 +89,7 @@ export const docsZh = {
89
89
  theming: {
90
90
  targets: [
91
91
  {className: 'astryx-tree-list', visualProps: ['density']},
92
- {className: 'astryx-tree-list-item', states: ['selected', 'disabled']},
92
+ {className: 'astryx-tree-list-item', visualProps: ['density'], states: ['selected', 'disabled']},
93
93
  ],
94
94
  },
95
95
  components: [
@@ -8,6 +8,16 @@ export const docs = {
8
8
  displayName: 'Base Typeahead',
9
9
  isHiddenFromOverview: true,
10
10
  description: 'Unstyled combobox engine providing input, search, keyboard navigation, and dropdown. No wrapper div, no border styling, no token rendering. Used by Typeahead and Tokenizer for custom compositions.',
11
+ usage: {
12
+ description: 'Unstyled combobox engine providing input, search, keyboard navigation, and dropdown. No wrapper div, no border styling, no token rendering. Used by Typeahead and Tokenizer for custom compositions.',
13
+ bestPractices: [
14
+ { guidance: true, description: 'Use Typeahead or Tokenizer for standard fields; they wrap BaseTypeahead with the wrapper div, border styling, and token rendering it intentionally omits.' },
15
+ { guidance: true, description: 'Provide your own wrapper div with border and layout when composing directly, since BaseTypeahead renders no visual chrome of its own.' },
16
+ { guidance: true, description: 'Pass anchorRef pointing to your wrapper so the dropdown positions against your custom input chrome, not just the bare input element.' },
17
+ { guidance: false, description: 'Expect a wrapper div, border, or token rendering. BaseTypeahead is an engine only; all visual chrome is the caller\'s responsibility.' },
18
+ { guidance: false, description: 'Use BaseTypeahead when Typeahead or Tokenizer would suffice; the extra wrapper and styling work is only justified for truly custom compositions.' },
19
+ ],
20
+ },
11
21
  props: [
12
22
  {
13
23
  name: 'searchSource',
@@ -226,6 +236,15 @@ export const docsDense = {
226
236
  isHiddenFromOverview: true,
227
237
  displayName: 'Base Typeahead',
228
238
  description: 'Unstyled combobox engine; input+search+keyboard nav+dropdown. No wrapper/border/token. Used by Typeahead+Tokenizer.',
239
+ usage: {
240
+ bestPractices: [
241
+ { guidance: true, description: 'Use Typeahead or Tokenizer for standard fields; they add the wrapper, border, and token rendering BaseTypeahead omits.' },
242
+ { guidance: true, description: 'Provide your own wrapper div with border and layout when composing directly; BaseTypeahead renders no chrome.' },
243
+ { guidance: true, description: 'Pass anchorRef to your wrapper so the dropdown positions against your input chrome, not the bare input.' },
244
+ { guidance: false, description: 'Expect wrapper, border, or token rendering. Engine only; chrome is the caller\'s job.' },
245
+ { guidance: false, description: 'Use BaseTypeahead when Typeahead or Tokenizer suffice; extra work only pays off for custom compositions.' },
246
+ ],
247
+ },
229
248
  propDescriptions: {
230
249
  searchSource: 'Data source w/ search+bootstrap methods.',
231
250
  value: 'Currently selected item.',
@@ -410,7 +410,10 @@ export const BaseTypeahead = function BaseTypeahead<T extends SearchableItem>({
410
410
  const performSearch = useCallback(
411
411
  async (searchQuery: string) => {
412
412
  searchSource.cancel?.();
413
- const gen = searchGenRef.current;
413
+ // Claim a new generation so overlapping searches can't race: an
414
+ // in-flight response for an older query fails the gen check below
415
+ // instead of overwriting the newer results.
416
+ const gen = ++searchGenRef.current;
414
417
  setIsLoading(true);
415
418
  setHasSearched(true);
416
419
  try {
@@ -451,7 +454,7 @@ export const BaseTypeahead = function BaseTypeahead<T extends SearchableItem>({
451
454
 
452
455
  // Perform bootstrap
453
456
  const performBootstrap = useCallback(async () => {
454
- const gen = searchGenRef.current;
457
+ const gen = ++searchGenRef.current;
455
458
  setIsLoading(true);
456
459
  try {
457
460
  const bootstrapResults = await searchSource.bootstrap();
@@ -168,7 +168,7 @@ export const docs = {
168
168
  components: [{name: 'BaseTypeahead'}, {name: 'TypeaheadItem'}],
169
169
  theming: {
170
170
  targets: [
171
- {className: 'astryx-typeahead', visualProps: ['status']},
171
+ {className: 'astryx-typeahead', visualProps: ['status', 'size']},
172
172
  {className: 'astryx-typeahead-dropdown'},
173
173
  {className: 'astryx-typeahead-item'},
174
174
  ],
@@ -18,7 +18,7 @@ import {
18
18
  afterAll,
19
19
  beforeEach,
20
20
  } from 'vitest';
21
- import {render, screen, fireEvent, waitFor} from '@testing-library/react';
21
+ import {render, screen, fireEvent, waitFor, act} from '@testing-library/react';
22
22
  import userEvent from '@testing-library/user-event';
23
23
  import {Typeahead} from './Typeahead';
24
24
  import {BaseTypeahead} from './BaseTypeahead';
@@ -314,6 +314,50 @@ describe('BaseTypeahead focus-out', () => {
314
314
  });
315
315
 
316
316
  describe('Typeahead', () => {
317
+ describe('out-of-order async results', () => {
318
+ it('discards a stale response that resolves after a newer query', async () => {
319
+ const resolvers = new Map<string, (items: SearchableItem[]) => void>();
320
+ const rawSource: SearchSource = {
321
+ search: async (query: string) =>
322
+ new Promise<SearchableItem[]>(resolve => {
323
+ resolvers.set(query, resolve);
324
+ }),
325
+ bootstrap: () => [],
326
+ };
327
+
328
+ render(
329
+ <Typeahead
330
+ label="Fruit"
331
+ searchSource={rawSource}
332
+ value={null}
333
+ onChange={() => {}}
334
+ debounceMs={0}
335
+ />,
336
+ );
337
+
338
+ const input = screen.getByRole('combobox');
339
+ fireEvent.change(input, {target: {value: 'a'}});
340
+ fireEvent.change(input, {target: {value: 'ap'}});
341
+
342
+ // The newer query resolves first…
343
+ await act(async () => {
344
+ resolvers.get('ap')!([{id: 'apple', label: 'Apple'}]);
345
+ });
346
+ expect(screen.getByText('Apple')).toBeInTheDocument();
347
+
348
+ // …then the abandoned query's slow response arrives and must be
349
+ // discarded rather than overwriting the current results.
350
+ await act(async () => {
351
+ resolvers.get('a')!([
352
+ {id: 'avocado', label: 'Avocado'},
353
+ {id: 'apricot', label: 'Apricot'},
354
+ ]);
355
+ });
356
+ expect(screen.getByText('Apple')).toBeInTheDocument();
357
+ expect(screen.queryByText('Avocado')).not.toBeInTheDocument();
358
+ });
359
+ });
360
+
317
361
  it('renders with label', () => {
318
362
  render(
319
363
  <Typeahead
@@ -0,0 +1,26 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file TestIcon.tsx
5
+ * @input Standard SVG props
6
+ * @output A minimal SVG React component for tests
7
+ * @position Test fixture; a stand-in external SVG icon for Icon component-mode tests
8
+ *
9
+ * A trivial SVG component used by tests that need to pass a custom SVG icon to
10
+ * `Icon` (component mode) or component `*Icon` props. It forwards all standard
11
+ * SVG props and its ref so tests exercising prop/ref forwarding behave the same
12
+ * as a real third-party icon component.
13
+ */
14
+
15
+ import React, {type SVGProps} from 'react';
16
+
17
+ export function TestIcon({
18
+ ref,
19
+ ...props
20
+ }: SVGProps<SVGSVGElement> & {ref?: React.Ref<SVGSVGElement>}) {
21
+ return (
22
+ <svg ref={ref} viewBox="0 0 24 24" {...props}>
23
+ <path d="M4 4h16v16H4z" />
24
+ </svg>
25
+ );
26
+ }