@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
@@ -20,6 +20,12 @@ export const docs = {
20
20
  description: 'Border on the appropriate edge.',
21
21
  default: 'false',
22
22
  },
23
+ {
24
+ name: 'padding',
25
+ type: '0 | 0.5 | 1 | 1.5 | 2 | 3 | 4 | 5 | 6 | 8 | 10',
26
+ description:
27
+ 'Internal padding using the spacing scale. Overrides the default padding from the layout container.',
28
+ },
23
29
  {
24
30
  name: 'isScrollable',
25
31
  type: 'boolean',
@@ -36,6 +42,18 @@ export const docs = {
36
42
  type: 'AriaRole',
37
43
  description: 'ARIA landmark role.',
38
44
  },
45
+ {
46
+ name: 'width',
47
+ type: 'number | string',
48
+ description:
49
+ 'Width of the panel. Numbers are treated as pixels, strings are used as-is. Ignored when resizable is provided; the hook controls width.',
50
+ },
51
+ {
52
+ name: 'resizable',
53
+ type: 'ResizableProps',
54
+ description:
55
+ 'Resize props from useResizable(). When provided, the hook drives the panel width and a ResizeHandle should be placed adjacent to the panel.',
56
+ },
39
57
  ],
40
58
  };
41
59
 
@@ -56,6 +74,11 @@ export const docsZh = {
56
74
  description: '相应边缘的边框。',
57
75
  default: 'false',
58
76
  },
77
+ {
78
+ name: 'padding',
79
+ type: '0 | 0.5 | 1 | 1.5 | 2 | 3 | 4 | 5 | 6 | 8 | 10',
80
+ description: '使用间距比例的内边距。覆盖布局容器的默认内边距。',
81
+ },
59
82
  {
60
83
  name: 'isScrollable',
61
84
  type: 'boolean',
@@ -72,6 +95,18 @@ export const docsZh = {
72
95
  type: 'AriaRole',
73
96
  description: 'ARIA 地标角色。',
74
97
  },
98
+ {
99
+ name: 'width',
100
+ type: 'number | string',
101
+ description:
102
+ '面板宽度。数字按像素处理,字符串按原样使用。提供 resizable 时忽略,宽度由 hook 控制。',
103
+ },
104
+ {
105
+ name: 'resizable',
106
+ type: 'ResizableProps',
107
+ description:
108
+ '来自 useResizable() 的调整大小属性。提供时面板宽度由 hook 驱动,应在面板旁放置 ResizeHandle。',
109
+ },
75
110
  ],
76
111
  };
77
112
 
@@ -83,8 +118,13 @@ export const docsDense = {
83
118
  propDescriptions: {
84
119
  children: 'Panel content.',
85
120
  hasDivider: 'Border on appropriate edge.',
121
+ padding: 'Internal padding on spacing scale. Overrides layout container default.',
86
122
  isScrollable: 'Enable scrollable overflow.',
87
123
  label: 'Accessible label for landmark element.',
88
124
  role: 'ARIA landmark role.',
125
+ width:
126
+ 'Panel width. Numbers = pixels, strings as-is. Ignored when resizable provided; hook controls width.',
127
+ resizable:
128
+ 'Resize props from useResizable(). Hook drives panel width; place ResizeHandle adjacent.',
89
129
  },
90
130
  };
@@ -64,8 +64,13 @@ export const docs = {
64
64
  { guidance: false, description: 'Nest interactive content inside captions; keep them plain text.' },
65
65
  ],
66
66
  },
67
+ // The lightbox opens via showModal() and renders nothing while closed —
68
+ // overlay mode gives the Properties preview an open trigger instead of an
69
+ // empty stage, mirroring MobileNav (#3616).
67
70
  playground: {
71
+ overlay: true,
68
72
  defaults: {
73
+ isOpen: false,
69
74
  media: {
70
75
  src: 'https://lookaside.facebook.com/assets/astryx/light-scene-horizontal-1.png',
71
76
  alt: 'Coastal shoreline with ocean waves',
@@ -1,8 +1,9 @@
1
1
  // Copyright (c) Meta Platforms, Inc. and affiliates.
2
2
 
3
- import {describe, it, expect, vi, beforeEach} from 'vitest';
4
- import {render, screen, fireEvent} from '@testing-library/react';
3
+ import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest';
4
+ import {render, screen, fireEvent, waitFor} from '@testing-library/react';
5
5
  import {Lightbox} from './Lightbox';
6
+ import {__resetLiveRegionsForTest} from '../hooks/useAnnounce';
6
7
 
7
8
  // Mock showModal/close for jsdom
8
9
  beforeEach(() => {
@@ -16,6 +17,16 @@ beforeEach(() => {
16
17
  });
17
18
  });
18
19
 
20
+ // useAnnounce mounts singleton live regions on <body>; reset between tests so
21
+ // stale announcements from one test don't leak into the next.
22
+ afterEach(() => {
23
+ __resetLiveRegionsForTest();
24
+ });
25
+
26
+ function politeRegion(): HTMLElement | null {
27
+ return document.querySelector('[data-astryx-live-region="polite"]');
28
+ }
29
+
19
30
  describe('Lightbox', () => {
20
31
  it('renders as a dialog element', () => {
21
32
  render(
@@ -300,6 +311,116 @@ describe('Lightbox', () => {
300
311
  });
301
312
  });
302
313
 
314
+ describe('screen-reader announcements', () => {
315
+ const media = [
316
+ {src: '/a.jpg', alt: 'Image A', caption: 'First'},
317
+ {src: '/b.jpg', alt: 'Image B', caption: 'Second'},
318
+ {src: '/c.jpg', alt: 'Image C'},
319
+ ];
320
+
321
+ it('announces the new image and position when navigating next via button', async () => {
322
+ render(
323
+ <Lightbox
324
+ isOpen={true}
325
+ onOpenChange={() => {}}
326
+ media={media}
327
+ defaultIndex={0}
328
+ />,
329
+ );
330
+ fireEvent.click(screen.getByLabelText('Next'));
331
+ await waitFor(() => {
332
+ expect(politeRegion()).toHaveTextContent('Image B, 2 of 3');
333
+ });
334
+ });
335
+
336
+ it('announces the new image and position when navigating via arrow keys', async () => {
337
+ render(
338
+ <Lightbox
339
+ isOpen={true}
340
+ onOpenChange={() => {}}
341
+ media={media}
342
+ defaultIndex={1}
343
+ />,
344
+ );
345
+ const dialog = document.querySelector('dialog')!;
346
+ fireEvent.keyDown(dialog, {key: 'ArrowRight'});
347
+ await waitFor(() => {
348
+ expect(politeRegion()).toHaveTextContent('Image C, 3 of 3');
349
+ });
350
+ });
351
+
352
+ it('announces the new image and position when navigating prev', async () => {
353
+ render(
354
+ <Lightbox
355
+ isOpen={true}
356
+ onOpenChange={() => {}}
357
+ media={media}
358
+ defaultIndex={2}
359
+ />,
360
+ );
361
+ fireEvent.click(screen.getByLabelText('Previous'));
362
+ await waitFor(() => {
363
+ expect(politeRegion()).toHaveTextContent('Image B, 2 of 3');
364
+ });
365
+ });
366
+
367
+ it('falls back to a positional label when the image has no alt', async () => {
368
+ const unlabeled = [
369
+ {src: '/a.jpg', alt: 'Image A'},
370
+ {src: '/b.jpg', alt: ''},
371
+ ];
372
+ render(
373
+ <Lightbox
374
+ isOpen={true}
375
+ onOpenChange={() => {}}
376
+ media={unlabeled}
377
+ defaultIndex={0}
378
+ />,
379
+ );
380
+ fireEvent.click(screen.getByLabelText('Next'));
381
+ await waitFor(() => {
382
+ expect(politeRegion()).toHaveTextContent('Image 2 of 2');
383
+ });
384
+ });
385
+
386
+ it('does not announce on initial open', async () => {
387
+ render(
388
+ <Lightbox
389
+ isOpen={true}
390
+ onOpenChange={() => {}}
391
+ media={media}
392
+ defaultIndex={1}
393
+ />,
394
+ );
395
+ // Allow any scheduled rAF to flush; nothing should have been announced.
396
+ await new Promise(resolve => requestAnimationFrame(() => resolve(null)));
397
+ // The dialog's aria-label already names the current image on open, so no
398
+ // live region is created (announce is never called).
399
+ expect(politeRegion()).toBeNull();
400
+ });
401
+
402
+ it('does not announce when the lightbox opens at a new index', async () => {
403
+ const {rerender} = render(
404
+ <Lightbox
405
+ isOpen={false}
406
+ onOpenChange={() => {}}
407
+ media={media}
408
+ index={0}
409
+ />,
410
+ );
411
+ rerender(
412
+ <Lightbox
413
+ isOpen={true}
414
+ onOpenChange={() => {}}
415
+ media={media}
416
+ index={2}
417
+ />,
418
+ );
419
+ await new Promise(resolve => requestAnimationFrame(() => resolve(null)));
420
+ expect(politeRegion()).toBeNull();
421
+ });
422
+ });
423
+
303
424
  describe('video support', () => {
304
425
  it('renders a video element when type is video', () => {
305
426
  const {container} = render(
@@ -19,6 +19,7 @@
19
19
  import {
20
20
  useCallback,
21
21
  useEffect,
22
+ useMemo,
22
23
  useRef,
23
24
  useState,
24
25
  type MouseEvent as ReactMouseEvent,
@@ -28,6 +29,7 @@ import * as stylex from '@stylexjs/stylex';
28
29
  import {colorVars, spacingVars, typeScaleVars} from '../theme/tokens.stylex';
29
30
  import {Icon} from '../Icon';
30
31
  import {IconButton} from '../IconButton';
32
+ import {useAnnounce} from '../hooks/useAnnounce';
31
33
  import {useScrollLock} from '../hooks/useScrollLock';
32
34
  import {useIsomorphicLayoutEffect} from '../hooks/useIsomorphicLayoutEffect';
33
35
  import {mergeProps, mergeRefs} from '../utils';
@@ -306,7 +308,10 @@ export function Lightbox({
306
308
  const dragStartRef = useRef({x: 0, y: 0, panX: 0, panY: 0});
307
309
 
308
310
  // Resolve current media item
309
- const mediaArray = Array.isArray(media) ? media : [media];
311
+ const mediaArray = useMemo(
312
+ () => (Array.isArray(media) ? media : [media]),
313
+ [media],
314
+ );
310
315
  const isGallery = mediaArray.length > 1;
311
316
  const currentItem =
312
317
  mediaArray.length > 0
@@ -329,6 +334,29 @@ export function Lightbox({
329
334
  setPan({x: 0, y: 0});
330
335
  }, [index, currentItem?.src]);
331
336
 
337
+ // Announce gallery navigation to screen readers. Moving between images only
338
+ // updates the visual counter, which is silent to assistive tech, so mirror
339
+ // each change in a polite live region ("<alt>, 3 of 12", or "Image 3 of 12"
340
+ // when the image has no alt). Announce only when the image changes during an
341
+ // already-open session — not on mount, not when opening (even at a new
342
+ // index, since the dialog's aria-label already names the current image), and
343
+ // not on close.
344
+ const announce = useAnnounce();
345
+ const prevIndexRef = useRef(index);
346
+ const wasOpenRef = useRef(isOpen);
347
+ useEffect(() => {
348
+ const indexChanged = prevIndexRef.current !== index;
349
+ const wasOpen = wasOpenRef.current;
350
+ prevIndexRef.current = index;
351
+ wasOpenRef.current = isOpen;
352
+ if (!indexChanged || !isOpen || !wasOpen) {
353
+ return;
354
+ }
355
+ const item = mediaArray[Math.min(index, mediaArray.length - 1)];
356
+ const position = `${index + 1} of ${mediaArray.length}`;
357
+ announce(item?.alt ? `${item.alt}, ${position}` : `Image ${position}`);
358
+ }, [index, isOpen, announce, mediaArray]);
359
+
332
360
  // Open/close dialog
333
361
  useIsomorphicLayoutEffect(() => {
334
362
  const dialog = dialogRef.current;
@@ -17,12 +17,27 @@ export const docs = {
17
17
  'NavHeadingMenuItem renders individual selectable items. ' +
18
18
  'Pass as the menu prop of SideNavHeading or TopNavHeading.',
19
19
  },
20
+ playground: {
21
+ defaults: {
22
+ children: [
23
+ {__element: 'NavHeadingMenuItem', props: {label: 'Dashboard', href: '#'}},
24
+ {__element: 'NavHeadingMenuItem', props: {label: 'Analytics', href: '#'}},
25
+ {__element: 'NavHeadingMenuItem', props: {label: 'Settings', href: '#'}},
26
+ ],
27
+ },
28
+ },
20
29
  props: [
21
30
  {name: 'children', type: 'ReactNode', required: true, description: 'Menu items.'},
22
31
  {name: 'size', type: "'sm' | 'md' | 'lg'", default: "'md'", description: 'Controls min-width and item padding.'},
23
32
  {name: 'minWidth', type: 'number | string', description: 'Minimum width override.'},
24
33
  {name: 'xstyle', type: 'StyleXStyles', description: 'StyleX styles for layout customization (margins, positioning, sizing). Must be a stylex.create() value, not an inline style object like style={{}}.'},
25
34
  ],
35
+ theming: {
36
+ targets: [
37
+ {className: 'astryx-nav-heading-menu', visualProps: ['size']},
38
+ {className: 'astryx-nav-heading-menu-item', visualProps: ['size']},
39
+ ],
40
+ },
26
41
  };
27
42
 
28
43
  /** @type {import('../docs-types').TranslationDoc} */
@@ -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 {HashtagIcon} from '@heroicons/react/24/outline';
15
+ import {TestIcon} from '../__tests__/TestIcon';
16
16
  import {NumberInput} from './NumberInput';
17
17
 
18
18
  // Mock showPopover/hidePopover since jsdom does not implement them. Used by the
@@ -174,7 +174,7 @@ describe('NumberInput', () => {
174
174
  label="Count"
175
175
  value={null}
176
176
  onChange={() => {}}
177
- startIcon={HashtagIcon}
177
+ startIcon={TestIcon}
178
178
  />,
179
179
  );
180
180
  expect(screen.getByRole('spinbutton')).toBeInTheDocument();
@@ -707,7 +707,7 @@ describe('NumberInput', () => {
707
707
  label="Qty"
708
708
  value={0}
709
709
  onChange={() => {}}
710
- startIcon={<HashtagIcon />}
710
+ startIcon={<TestIcon />}
711
711
  />,
712
712
  );
713
713
 
@@ -867,3 +867,48 @@ describe('NumberInput', () => {
867
867
  });
868
868
  });
869
869
  });
870
+
871
+ describe('keyboard clearing with hasClear (#3599)', () => {
872
+ it('commits null when the input is emptied and blurred', () => {
873
+ const onChange = vi.fn();
874
+ render(
875
+ <NumberInput label="Qty" hasClear value={42} onChange={onChange} />,
876
+ );
877
+ const input = screen.getByLabelText('Qty');
878
+ fireEvent.change(input, {target: {value: ''}});
879
+ fireEvent.blur(input);
880
+ expect(onChange).toHaveBeenCalledWith(null);
881
+ });
882
+
883
+ it('commits null when the input is emptied and Enter is pressed', () => {
884
+ const onChange = vi.fn();
885
+ render(
886
+ <NumberInput label="Qty" hasClear value={42} onChange={onChange} />,
887
+ );
888
+ const input = screen.getByLabelText('Qty');
889
+ fireEvent.change(input, {target: {value: ''}});
890
+ fireEvent.keyDown(input, {key: 'Enter'});
891
+ expect(onChange).toHaveBeenCalledWith(null);
892
+ });
893
+
894
+ it('does not fire when emptied and blurred with no prior value', () => {
895
+ const onChange = vi.fn();
896
+ render(
897
+ <NumberInput label="Qty" hasClear value={null} onChange={onChange} />,
898
+ );
899
+ const input = screen.getByLabelText('Qty');
900
+ fireEvent.change(input, {target: {value: ''}});
901
+ fireEvent.blur(input);
902
+ expect(onChange).not.toHaveBeenCalled();
903
+ });
904
+
905
+ it('still reverts on blur when hasClear is not set', () => {
906
+ const onChange = vi.fn();
907
+ render(<NumberInput label="Qty" value={42} onChange={onChange} />);
908
+ const input = screen.getByLabelText('Qty');
909
+ fireEvent.change(input, {target: {value: ''}});
910
+ fireEvent.blur(input);
911
+ expect(onChange).not.toHaveBeenCalled();
912
+ expect((input as HTMLInputElement).value).toBe('42');
913
+ });
914
+ });
@@ -503,13 +503,21 @@ export function NumberInput({
503
503
  const handleBlur = useCallback(
504
504
  (e: FocusEvent<HTMLInputElement>) => {
505
505
  if (pendingInput !== null) {
506
- const parsed = parseNumberInput(pendingInput, {
507
- min,
508
- max,
509
- isIntegerOnly,
510
- });
511
- if (parsed !== null && parsed !== value) {
512
- onChange(parsed);
506
+ if (hasClear && pendingInput.trim() === '') {
507
+ // Keyboard clearing honors the clearable contract: an emptied
508
+ // input commits null instead of silently reverting on blur.
509
+ if (value != null) {
510
+ onChange(null);
511
+ }
512
+ } else {
513
+ const parsed = parseNumberInput(pendingInput, {
514
+ min,
515
+ max,
516
+ isIntegerOnly,
517
+ });
518
+ if (parsed !== null && parsed !== value) {
519
+ onChange(parsed);
520
+ }
513
521
  }
514
522
  }
515
523
 
@@ -517,7 +525,7 @@ export function NumberInput({
517
525
  setPendingInput(null);
518
526
  onBlur?.(e);
519
527
  },
520
- [pendingInput, value, onChange, min, max, isIntegerOnly, onBlur],
528
+ [pendingInput, value, onChange, min, max, isIntegerOnly, onBlur, hasClear],
521
529
  );
522
530
 
523
531
  // Handle keyboard events
@@ -526,13 +534,21 @@ export function NumberInput({
526
534
  if (e.key === 'Enter') {
527
535
  // Validate and commit on Enter
528
536
  if (pendingInput !== null) {
529
- const parsed = parseNumberInput(pendingInput, {
530
- min,
531
- max,
532
- isIntegerOnly,
533
- });
534
- if (parsed !== null && parsed !== value) {
535
- onChange(parsed);
537
+ if (hasClear && pendingInput.trim() === '') {
538
+ // Same clearable contract as blur: Enter on an emptied input
539
+ // commits null instead of reverting.
540
+ if (value != null) {
541
+ onChange(null);
542
+ }
543
+ } else {
544
+ const parsed = parseNumberInput(pendingInput, {
545
+ min,
546
+ max,
547
+ isIntegerOnly,
548
+ });
549
+ if (parsed !== null && parsed !== value) {
550
+ onChange(parsed);
551
+ }
536
552
  }
537
553
  }
538
554
  onEnter?.();
@@ -548,6 +564,7 @@ export function NumberInput({
548
564
  isIntegerOnly,
549
565
  onEnter,
550
566
  onKeyDown,
567
+ hasClear,
551
568
  ],
552
569
  );
553
570
 
@@ -180,6 +180,12 @@ export const docs = {
180
180
  description: 'Ref forwarded to the overlay root element.',
181
181
  },
182
182
  ],
183
+ theming: {
184
+ targets: [
185
+ {className: 'astryx-overlay'},
186
+ {className: 'astryx-overlay-scrim', visualProps: ['position']},
187
+ ],
188
+ },
183
189
  };
184
190
 
185
191
  /** @type {import('../docs-types').TranslationDoc} */
@@ -345,6 +345,149 @@ describe('Pagination', () => {
345
345
  const activeDot = screen.getByRole('button', {name: 'Go to page 3'});
346
346
  expect(activeDot).toHaveAttribute('aria-current', 'page');
347
347
  });
348
+
349
+ // -------------------------------------------------------------------------
350
+ // Keyboard navigation (roving tabindex + arrow keys via useListFocus).
351
+ // Selection follows focus: arrow/Home/End move focus and select that page.
352
+ // -------------------------------------------------------------------------
353
+
354
+ it('uses roving tabindex — active dot has tabIndex 0, others -1', () => {
355
+ render(
356
+ <Pagination
357
+ page={2}
358
+ onChange={() => {}}
359
+ totalPages={4}
360
+ variant="dots"
361
+ />,
362
+ );
363
+ expect(
364
+ screen.getByRole('button', {name: 'Go to page 2'}),
365
+ ).toHaveAttribute('tabindex', '0');
366
+ expect(
367
+ screen.getByRole('button', {name: 'Go to page 1'}),
368
+ ).toHaveAttribute('tabindex', '-1');
369
+ expect(
370
+ screen.getByRole('button', {name: 'Go to page 3'}),
371
+ ).toHaveAttribute('tabindex', '-1');
372
+ });
373
+
374
+ it('ArrowRight moves focus to the next dot and selects it', async () => {
375
+ const user = userEvent.setup();
376
+ const onChange = vi.fn();
377
+ render(
378
+ <Pagination
379
+ page={2}
380
+ onChange={onChange}
381
+ totalPages={4}
382
+ variant="dots"
383
+ />,
384
+ );
385
+ screen.getByRole('button', {name: 'Go to page 2'}).focus();
386
+ await user.keyboard('{ArrowRight}');
387
+ expect(onChange).toHaveBeenCalledWith(3);
388
+ expect(screen.getByRole('button', {name: 'Go to page 3'})).toHaveFocus();
389
+ });
390
+
391
+ it('ArrowLeft moves focus to the previous dot and selects it', async () => {
392
+ const user = userEvent.setup();
393
+ const onChange = vi.fn();
394
+ render(
395
+ <Pagination
396
+ page={3}
397
+ onChange={onChange}
398
+ totalPages={4}
399
+ variant="dots"
400
+ />,
401
+ );
402
+ screen.getByRole('button', {name: 'Go to page 3'}).focus();
403
+ await user.keyboard('{ArrowLeft}');
404
+ expect(onChange).toHaveBeenCalledWith(2);
405
+ expect(screen.getByRole('button', {name: 'Go to page 2'})).toHaveFocus();
406
+ });
407
+
408
+ it('Home selects the first page, End the last', async () => {
409
+ const user = userEvent.setup();
410
+ const onChange = vi.fn();
411
+ const {rerender} = render(
412
+ <Pagination
413
+ page={3}
414
+ onChange={onChange}
415
+ totalPages={5}
416
+ variant="dots"
417
+ />,
418
+ );
419
+ screen.getByRole('button', {name: 'Go to page 3'}).focus();
420
+ await user.keyboard('{Home}');
421
+ expect(onChange).toHaveBeenCalledWith(1);
422
+ expect(screen.getByRole('button', {name: 'Go to page 1'})).toHaveFocus();
423
+
424
+ onChange.mockClear();
425
+ rerender(
426
+ <Pagination
427
+ page={3}
428
+ onChange={onChange}
429
+ totalPages={5}
430
+ variant="dots"
431
+ />,
432
+ );
433
+ screen.getByRole('button', {name: 'Go to page 3'}).focus();
434
+ await user.keyboard('{End}');
435
+ expect(onChange).toHaveBeenCalledWith(5);
436
+ expect(screen.getByRole('button', {name: 'Go to page 5'})).toHaveFocus();
437
+ });
438
+
439
+ it('wraps from the last dot to the first with ArrowRight', async () => {
440
+ const user = userEvent.setup();
441
+ const onChange = vi.fn();
442
+ render(
443
+ <Pagination
444
+ page={4}
445
+ onChange={onChange}
446
+ totalPages={4}
447
+ variant="dots"
448
+ />,
449
+ );
450
+ screen.getByRole('button', {name: 'Go to page 4'}).focus();
451
+ await user.keyboard('{ArrowRight}');
452
+ expect(onChange).toHaveBeenCalledWith(1);
453
+ expect(screen.getByRole('button', {name: 'Go to page 1'})).toHaveFocus();
454
+ });
455
+
456
+ it('wraps from the first dot to the last with ArrowLeft', async () => {
457
+ const user = userEvent.setup();
458
+ const onChange = vi.fn();
459
+ render(
460
+ <Pagination
461
+ page={1}
462
+ onChange={onChange}
463
+ totalPages={4}
464
+ variant="dots"
465
+ />,
466
+ );
467
+ screen.getByRole('button', {name: 'Go to page 1'}).focus();
468
+ await user.keyboard('{ArrowLeft}');
469
+ expect(onChange).toHaveBeenCalledWith(4);
470
+ expect(screen.getByRole('button', {name: 'Go to page 4'})).toHaveFocus();
471
+ });
472
+
473
+ it('does not navigate with arrow keys when disabled', async () => {
474
+ const user = userEvent.setup();
475
+ const onChange = vi.fn();
476
+ render(
477
+ <Pagination
478
+ page={2}
479
+ onChange={onChange}
480
+ totalPages={4}
481
+ variant="dots"
482
+ isDisabled
483
+ />,
484
+ );
485
+ const dot = screen.getByRole('button', {name: 'Go to page 2'});
486
+ expect(dot).toBeDisabled();
487
+ dot.focus();
488
+ await user.keyboard('{ArrowRight}');
489
+ expect(onChange).not.toHaveBeenCalled();
490
+ });
348
491
  });
349
492
 
350
493
  // ---------------------------------------------------------------------------