@astryxdesign/core 0.4.0 → 0.4.1-canary.1ce0b4c

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 (271) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/dist/AspectRatio/AspectRatio.d.ts.map +1 -1
  3. package/dist/AspectRatio/AspectRatio.js +1 -2
  4. package/dist/AspectRatio/index.d.ts.map +1 -1
  5. package/dist/AspectRatio/index.js +1 -2
  6. package/dist/Badge/Badge.d.ts.map +1 -1
  7. package/dist/Badge/Badge.js +1 -2
  8. package/dist/Badge/index.d.ts.map +1 -1
  9. package/dist/Badge/index.js +1 -2
  10. package/dist/Banner/Banner.d.ts +3 -0
  11. package/dist/Banner/Banner.d.ts.map +1 -1
  12. package/dist/Banner/Banner.js +17 -3
  13. package/dist/Blockquote/Blockquote.d.ts.map +1 -1
  14. package/dist/Blockquote/Blockquote.js +1 -2
  15. package/dist/Blockquote/index.d.ts.map +1 -1
  16. package/dist/Blockquote/index.js +1 -2
  17. package/dist/Button/Button.d.ts.map +1 -1
  18. package/dist/Button/Button.js +11 -9
  19. package/dist/Card/Card.d.ts.map +1 -1
  20. package/dist/Card/Card.js +1 -2
  21. package/dist/Card/index.d.ts.map +1 -1
  22. package/dist/Card/index.js +1 -2
  23. package/dist/Center/Center.d.ts.map +1 -1
  24. package/dist/Center/Center.js +1 -2
  25. package/dist/Center/index.d.ts.map +1 -1
  26. package/dist/Center/index.js +1 -2
  27. package/dist/Code/Code.d.ts.map +1 -1
  28. package/dist/Code/Code.js +1 -2
  29. package/dist/CommandPalette/CommandPalette.d.ts.map +1 -1
  30. package/dist/CommandPalette/CommandPalette.js +5 -0
  31. package/dist/ComplexSelector/ComplexSelector.d.ts.map +1 -1
  32. package/dist/ComplexSelector/ComplexSelector.js +7 -8
  33. package/dist/DateInput/DateInput.d.ts.map +1 -1
  34. package/dist/DateInput/DateInput.js +23 -10
  35. package/dist/DateRangeInput/DateRangeInput.d.ts.map +1 -1
  36. package/dist/DateRangeInput/DateRangeInput.js +44 -16
  37. package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
  38. package/dist/DateTimeInput/DateTimeInput.js +32 -13
  39. package/dist/DropdownMenu/DropdownMenuRadioGroup.d.ts +1 -1
  40. package/dist/DropdownMenu/DropdownMenuRadioGroup.d.ts.map +1 -1
  41. package/dist/DropdownMenu/DropdownMenuRadioGroup.js +10 -3
  42. package/dist/Field/PanelSearchInput.d.ts +81 -0
  43. package/dist/Field/PanelSearchInput.d.ts.map +1 -0
  44. package/dist/Field/PanelSearchInput.js +151 -0
  45. package/dist/FileInput/FileInput.d.ts.map +1 -1
  46. package/dist/FileInput/FileInput.js +21 -10
  47. package/dist/Grid/Grid.d.ts.map +1 -1
  48. package/dist/Grid/Grid.js +1 -2
  49. package/dist/Grid/index.d.ts.map +1 -1
  50. package/dist/Grid/index.js +1 -2
  51. package/dist/HoverCard/HoverCard.d.ts +1 -1
  52. package/dist/HoverCard/HoverCard.d.ts.map +1 -1
  53. package/dist/HoverCard/HoverCard.js +5 -13
  54. package/dist/HoverCard/useHoverCard.d.ts.map +1 -1
  55. package/dist/HoverCard/useHoverCard.js +6 -5
  56. package/dist/Icon/globalIconRegistry.d.ts +5 -4
  57. package/dist/Icon/globalIconRegistry.d.ts.map +1 -1
  58. package/dist/Icon/globalIconRegistry.js +5 -4
  59. package/dist/Indicator/index.d.ts +1 -1
  60. package/dist/Indicator/index.d.ts.map +1 -1
  61. package/dist/Indicator/types.d.ts +10 -0
  62. package/dist/Indicator/types.d.ts.map +1 -1
  63. package/dist/InputGroup/groupStyles.d.ts.map +1 -1
  64. package/dist/InputGroup/groupStyles.js +7 -2
  65. package/dist/Layer/layerHost.d.ts +24 -0
  66. package/dist/Layer/layerHost.d.ts.map +1 -0
  67. package/dist/Layer/layerHost.js +79 -0
  68. package/dist/Layer/useLayer.d.ts +14 -6
  69. package/dist/Layer/useLayer.d.ts.map +1 -1
  70. package/dist/Layer/useLayer.js +155 -29
  71. package/dist/Lightbox/Lightbox.d.ts.map +1 -1
  72. package/dist/Lightbox/Lightbox.js +24 -50
  73. package/dist/MultiSelector/MultiSelector.d.ts +8 -1
  74. package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
  75. package/dist/MultiSelector/MultiSelector.js +134 -102
  76. package/dist/NavItem/navItemStyles.stylex.d.ts +17 -5
  77. package/dist/NavItem/navItemStyles.stylex.d.ts.map +1 -1
  78. package/dist/NavItem/navItemStyles.stylex.js +11 -5
  79. package/dist/Popover/usePopover.d.ts +15 -0
  80. package/dist/Popover/usePopover.d.ts.map +1 -1
  81. package/dist/Popover/usePopover.js +14 -2
  82. package/dist/ProgressBar/ProgressBar.d.ts.map +1 -1
  83. package/dist/ProgressBar/ProgressBar.js +2 -3
  84. package/dist/Section/Section.d.ts.map +1 -1
  85. package/dist/Section/Section.js +1 -2
  86. package/dist/Section/index.d.ts.map +1 -1
  87. package/dist/Section/index.js +1 -2
  88. package/dist/Selector/Selector.d.ts +10 -0
  89. package/dist/Selector/Selector.d.ts.map +1 -1
  90. package/dist/Selector/Selector.js +135 -95
  91. package/dist/SideNav/SideNav.d.ts +7 -9
  92. package/dist/SideNav/SideNav.d.ts.map +1 -1
  93. package/dist/SideNav/SideNav.js +32 -5
  94. package/dist/SideNav/SideNavCollapseButton.d.ts +25 -9
  95. package/dist/SideNav/SideNavCollapseButton.d.ts.map +1 -1
  96. package/dist/SideNav/SideNavCollapseButton.js +39 -15
  97. package/dist/SideNav/SideNavCollapseContext.d.ts +21 -0
  98. package/dist/SideNav/SideNavCollapseContext.d.ts.map +1 -1
  99. package/dist/SideNav/SideNavCollapseContext.js +16 -2
  100. package/dist/SideNav/SideNavHeading.d.ts.map +1 -1
  101. package/dist/SideNav/SideNavHeading.js +70 -24
  102. package/dist/SideNav/SideNavItem.d.ts +7 -2
  103. package/dist/SideNav/SideNavItem.d.ts.map +1 -1
  104. package/dist/SideNav/SideNavItem.js +114 -75
  105. package/dist/SideNav/SideNavSection.d.ts.map +1 -1
  106. package/dist/SideNav/SideNavSection.js +7 -16
  107. package/dist/SideNav/index.d.ts +1 -1
  108. package/dist/SideNav/index.d.ts.map +1 -1
  109. package/dist/Skeleton/Skeleton.d.ts.map +1 -1
  110. package/dist/Skeleton/Skeleton.js +1 -2
  111. package/dist/Skeleton/index.d.ts.map +1 -1
  112. package/dist/Skeleton/index.js +1 -2
  113. package/dist/Slider/Slider.d.ts.map +1 -1
  114. package/dist/Slider/Slider.js +2 -6
  115. package/dist/Switch/Switch.d.ts.map +1 -1
  116. package/dist/Switch/Switch.js +3 -3
  117. package/dist/Table/TableRow.d.ts +1 -1
  118. package/dist/Table/TableRow.d.ts.map +1 -1
  119. package/dist/Table/TableRow.js +4 -2
  120. package/dist/TimeInput/TimeInput.d.ts.map +1 -1
  121. package/dist/TimeInput/TimeInput.js +7 -2
  122. package/dist/Timestamp/Timestamp.d.ts.map +1 -1
  123. package/dist/Timestamp/Timestamp.js +14 -3
  124. package/dist/Timestamp/formatInstant.d.ts +11 -1
  125. package/dist/Timestamp/formatInstant.d.ts.map +1 -1
  126. package/dist/Timestamp/formatInstant.js +3 -1
  127. package/dist/TopNav/TopNavMegaMenu.js +1 -1
  128. package/dist/TopNav/TopNavMegaMenuItem.js +1 -1
  129. package/dist/TopNav/TopNavMenu.d.ts.map +1 -1
  130. package/dist/TopNav/TopNavMenu.js +2 -2
  131. package/dist/VisuallyHidden/VisuallyHidden.d.ts.map +1 -1
  132. package/dist/VisuallyHidden/VisuallyHidden.js +1 -2
  133. package/dist/VisuallyHidden/index.d.ts.map +1 -1
  134. package/dist/VisuallyHidden/index.js +1 -2
  135. package/dist/astryx.css +23 -24
  136. package/dist/astryx.umd.js +48 -48
  137. package/dist/astryx.umd.js.map +4 -4
  138. package/dist/hooks/index.d.ts +1 -1
  139. package/dist/hooks/index.d.ts.map +1 -1
  140. package/dist/hooks/index.js +1 -1
  141. package/dist/hooks/useFocusTrap.d.ts.map +1 -1
  142. package/dist/hooks/useFocusTrap.js +16 -2
  143. package/dist/hooks/useInputStatusIcon.d.ts.map +1 -1
  144. package/dist/hooks/useInputStatusIcon.js +17 -4
  145. package/dist/theme/defineTheme.d.ts +2 -2
  146. package/dist/theme/defineTheme.d.ts.map +1 -1
  147. package/dist/theme/defineTheme.js +2 -1
  148. package/dist/theme/index.d.ts +1 -1
  149. package/dist/theme/index.d.ts.map +1 -1
  150. package/dist/theme/index.js +1 -1
  151. package/dist/theme/tokens.stylex.d.ts +12 -0
  152. package/dist/theme/tokens.stylex.d.ts.map +1 -1
  153. package/dist/theme/tokens.stylex.js +23 -0
  154. package/dist/utils/focusOutline.stylex.d.ts +34 -19
  155. package/dist/utils/focusOutline.stylex.d.ts.map +1 -1
  156. package/dist/utils/focusOutline.stylex.js +46 -33
  157. package/dist/utils/index.d.ts +1 -0
  158. package/dist/utils/index.d.ts.map +1 -1
  159. package/dist/utils/index.js +5 -1
  160. package/dist/utils/interactionModality.d.ts +34 -0
  161. package/dist/utils/interactionModality.d.ts.map +1 -0
  162. package/dist/utils/interactionModality.js +70 -0
  163. package/locales/en.json +44 -0
  164. package/locales/pseudo.json +33 -0
  165. package/package.json +3 -3
  166. package/src/AppShell/AppShell.doc.mjs +3 -0
  167. package/src/AspectRatio/AspectRatio.tsx +0 -2
  168. package/src/AspectRatio/index.ts +0 -2
  169. package/src/Badge/Badge.tsx +0 -2
  170. package/src/Badge/index.ts +0 -2
  171. package/src/Banner/Banner.test.tsx +58 -1
  172. package/src/Banner/Banner.tsx +24 -5
  173. package/src/Blockquote/Blockquote.tsx +0 -2
  174. package/src/Blockquote/index.ts +0 -2
  175. package/src/Button/Button.doc.mjs +2 -2
  176. package/src/Button/Button.tsx +10 -7
  177. package/src/ButtonGroup/ButtonGroup.test.tsx +7 -4
  178. package/src/Card/Card.tsx +0 -2
  179. package/src/Card/index.ts +0 -2
  180. package/src/Center/Center.tsx +0 -2
  181. package/src/Center/index.ts +0 -2
  182. package/src/CheckboxInput/CheckboxInput.test.tsx +8 -3
  183. package/src/Code/Code.tsx +0 -2
  184. package/src/CommandPalette/CommandPalette.test.tsx +54 -0
  185. package/src/CommandPalette/CommandPalette.tsx +5 -0
  186. package/src/ComplexSelector/ComplexSelector.test.tsx +15 -6
  187. package/src/ComplexSelector/ComplexSelector.tsx +6 -10
  188. package/src/DateInput/DateInput.test.tsx +19 -0
  189. package/src/DateInput/DateInput.tsx +3 -6
  190. package/src/DateRangeInput/DateRangeInput.tsx +3 -9
  191. package/src/DateTimeInput/DateTimeInput.test.tsx +79 -1
  192. package/src/DateTimeInput/DateTimeInput.tsx +23 -9
  193. package/src/DropdownMenu/DropdownMenuRadioGroup.tsx +2 -1
  194. package/src/Field/PanelSearchInput.tsx +271 -0
  195. package/src/FileInput/FileInput.tsx +21 -8
  196. package/src/Grid/Grid.tsx +0 -2
  197. package/src/Grid/index.ts +0 -2
  198. package/src/HoverCard/HoverCard.doc.mjs +3 -3
  199. package/src/HoverCard/HoverCard.test.tsx +283 -55
  200. package/src/HoverCard/HoverCard.tsx +5 -13
  201. package/src/HoverCard/useHoverCard.tsx +8 -11
  202. package/src/Icon/globalIconRegistry.tsx +5 -4
  203. package/src/Indicator/Indicator.doc.mjs +1 -1
  204. package/src/Indicator/index.ts +1 -0
  205. package/src/Indicator/types.ts +11 -0
  206. package/src/InputGroup/groupStyles.ts +7 -8
  207. package/src/Layer/layerHost.test.ts +99 -0
  208. package/src/Layer/layerHost.ts +141 -0
  209. package/src/Layer/useLayer.doc.mjs +14 -4
  210. package/src/Layer/useLayer.test.tsx +318 -7
  211. package/src/Layer/useLayer.tsx +229 -35
  212. package/src/Lightbox/Lightbox.tsx +2 -11
  213. package/src/MultiSelector/MultiSelector.doc.mjs +9 -0
  214. package/src/MultiSelector/MultiSelector.test.tsx +135 -5
  215. package/src/MultiSelector/MultiSelector.tsx +170 -127
  216. package/src/NavItem/navItemStyles.stylex.ts +31 -5
  217. package/src/Popover/Popover.doc.mjs +2 -0
  218. package/src/Popover/usePopover.tsx +37 -4
  219. package/src/ProgressBar/ProgressBar.tsx +2 -8
  220. package/src/Section/Section.tsx +0 -2
  221. package/src/Section/index.ts +0 -2
  222. package/src/Selector/Selector.doc.mjs +10 -0
  223. package/src/Selector/Selector.test.tsx +386 -2
  224. package/src/Selector/Selector.tsx +182 -122
  225. package/src/SideNav/SideNav.doc.mjs +13 -4
  226. package/src/SideNav/SideNav.test.tsx +771 -2
  227. package/src/SideNav/SideNav.tsx +37 -16
  228. package/src/SideNav/SideNavCollapseButton.doc.mjs +28 -6
  229. package/src/SideNav/SideNavCollapseButton.tsx +67 -15
  230. package/src/SideNav/SideNavCollapseContext.ts +29 -9
  231. package/src/SideNav/SideNavHeading.tsx +28 -10
  232. package/src/SideNav/SideNavItem.doc.mjs +13 -0
  233. package/src/SideNav/SideNavItem.tsx +92 -84
  234. package/src/SideNav/SideNavSection.tsx +6 -20
  235. package/src/SideNav/index.ts +2 -0
  236. package/src/Skeleton/Skeleton.tsx +0 -2
  237. package/src/Skeleton/index.ts +0 -2
  238. package/src/Slider/Slider.tsx +2 -11
  239. package/src/Switch/Switch.tsx +10 -2
  240. package/src/Table/Table.test.tsx +31 -0
  241. package/src/Table/TableRow.tsx +6 -0
  242. package/src/Text/Text.doc.mjs +1 -1
  243. package/src/TimeInput/TimeInput.test.tsx +54 -0
  244. package/src/TimeInput/TimeInput.tsx +17 -2
  245. package/src/Timestamp/Timestamp.test.tsx +125 -30
  246. package/src/Timestamp/Timestamp.tsx +14 -2
  247. package/src/Timestamp/formatInstant.ts +16 -1
  248. package/src/Toolbar/Toolbar.test.tsx +14 -14
  249. package/src/TopNav/TopNavMegaMenu.doc.mjs +5 -0
  250. package/src/TopNav/TopNavMegaMenu.test.tsx +43 -0
  251. package/src/TopNav/TopNavMegaMenu.tsx +4 -1
  252. package/src/TopNav/TopNavMegaMenuItem.tsx +1 -1
  253. package/src/TopNav/TopNavMenu.test.tsx +41 -0
  254. package/src/TopNav/TopNavMenu.tsx +8 -2
  255. package/src/TreeList/TreeList.test.tsx +3 -2
  256. package/src/VisuallyHidden/VisuallyHidden.tsx +0 -2
  257. package/src/VisuallyHidden/index.ts +0 -2
  258. package/src/hooks/index.ts +5 -1
  259. package/src/hooks/useFocusTrap.test.tsx +16 -0
  260. package/src/hooks/useFocusTrap.ts +20 -2
  261. package/src/hooks/useInputStatusIcon.tsx +8 -8
  262. package/src/serverSafeComponents.test.ts +665 -0
  263. package/src/theme/defineTheme.ts +3 -0
  264. package/src/theme/generateThemeRules.test.ts +17 -0
  265. package/src/theme/index.ts +2 -0
  266. package/src/theme/themingTargets.test.ts +152 -26
  267. package/src/theme/tokens.stylex.ts +18 -0
  268. package/src/theme/tokens.test.ts +37 -0
  269. package/src/utils/focusOutline.stylex.ts +37 -23
  270. package/src/utils/index.ts +4 -0
  271. package/src/utils/interactionModality.ts +74 -0
@@ -9,6 +9,7 @@
9
9
 
10
10
  import {describe, it, expect, vi, beforeEach} from 'vitest';
11
11
  import {render, screen, waitFor, fireEvent, act} from '@testing-library/react';
12
+ import {useState} from 'react';
12
13
  import {CommandPalette} from './CommandPalette';
13
14
  import {createStaticSource} from '@astryxdesign/core/Typeahead';
14
15
  import type {SearchSource, SearchableItem} from '@astryxdesign/core/Typeahead';
@@ -274,6 +275,59 @@ describe('CommandPalette', () => {
274
275
  );
275
276
  });
276
277
 
278
+ it('discards a search response that resolves after the palette closed', async () => {
279
+ // A source whose search resolves only when released, and no cancel()
280
+ // implementation — closing the palette must invalidate the request itself.
281
+ const resolvers: ((items: SearchableItem[]) => void)[] = [];
282
+ const source: SearchSource = {
283
+ bootstrap: () => [],
284
+ async search(): Promise<SearchableItem[]> {
285
+ return new Promise<SearchableItem[]>(resolve => {
286
+ resolvers.push(resolve);
287
+ });
288
+ },
289
+ };
290
+
291
+ function Harness() {
292
+ const [isOpen, setIsOpen] = useState(true);
293
+ return (
294
+ <CommandPalette
295
+ isOpen={isOpen}
296
+ onOpenChange={setIsOpen}
297
+ searchSource={source}
298
+ />
299
+ );
300
+ }
301
+ render(<Harness />);
302
+
303
+ const input = screen.getByRole('combobox');
304
+ fireEvent.change(input, {target: {value: 'a'}});
305
+ await waitFor(() => expect(resolvers).toHaveLength(1));
306
+
307
+ // Close while the search is still in flight.
308
+ const dialog = screen.getByRole('dialog');
309
+ const escapeEvent = new KeyboardEvent('keydown', {
310
+ key: 'Escape',
311
+ bubbles: true,
312
+ cancelable: true,
313
+ });
314
+ dialog.dispatchEvent(escapeEvent);
315
+ await waitFor(() =>
316
+ expect(screen.getByRole('dialog', {hidden: true})).not.toHaveAttribute(
317
+ 'open',
318
+ ),
319
+ );
320
+
321
+ // The stale response arrives after close — it must not re-commit the
322
+ // abandoned query/results into the closed palette.
323
+ await act(async () => {
324
+ resolvers[0]([{id: 'stale', label: 'Stale item'}]);
325
+ });
326
+
327
+ expect(screen.getByRole('combobox', {hidden: true})).toHaveValue('');
328
+ expect(screen.queryByText('Stale item')).not.toBeInTheDocument();
329
+ });
330
+
277
331
  it('does not move the highlight while typing in the search input', async () => {
278
332
  // The palette's input already filters; letting the shared combobox
279
333
  // typeahead also chase prefixes would drag the highlight — and Enter —
@@ -319,6 +319,11 @@ export function CommandPalette<T extends SearchableItem = SearchableItem>({
319
319
  );
320
320
 
321
321
  const handleClose = useCallback(() => {
322
+ // Invalidate any in-flight search. Most sources don't implement cancel(),
323
+ // and a response that resolves after close would still pass runSearch's
324
+ // version check and re-commit the stale query/results into the closed
325
+ // palette (visible as a ghost query on reopen while bootstrap is pending).
326
+ searchVersionRef.current++;
322
327
  // Reset both committed and optimistic search on close
323
328
  setSearch('');
324
329
  setSearchResults([]);
@@ -177,7 +177,7 @@ describe('ComplexSelector', () => {
177
177
  });
178
178
 
179
179
  describe('ComplexSelector popup theme target', () => {
180
- it('puts astryx-complex-selector-popup on the content box inside the layer', async () => {
180
+ it('puts astryx-complex-selector-popup on the surface that paints, not the content box', async () => {
181
181
  const user = userEvent.setup();
182
182
  render(
183
183
  <ComplexSelector label="Fruit blend" value="Apple" triggerLabel="Apple">
@@ -186,18 +186,27 @@ describe('ComplexSelector popup theme target', () => {
186
186
  );
187
187
  await user.click(screen.getByRole('button', {name: 'Fruit blend'}));
188
188
 
189
- const layer = document.querySelector('[popover]') as HTMLElement;
190
189
  const popup = document.querySelector(
191
190
  '.astryx-complex-selector-popup',
192
191
  ) as HTMLElement;
193
192
  expect(popup).not.toBeNull();
194
- // The layer is a bare positioning box; the target must be the content box
195
- // it contains, which is what actually paints.
196
- expect(popup).not.toBe(layer);
197
- expect(layer.contains(popup)).toBe(true);
193
+
194
+ // The surface is the element usePopover renders: it carries the dialog
195
+ // role and the shared surface class, and the component's content box —
196
+ // the one with the padding and the scroll — sits INSIDE it. A target on
197
+ // that inner box cannot paint the popup's background or radius, which is
198
+ // what a theme reaches for this class to do.
199
+ expect(popup).toHaveAttribute('role', 'dialog');
200
+ expect(popup).toHaveClass('astryx-popover-surface');
201
+ expect(popup.querySelector('[id]')).not.toBeNull();
198
202
  expect(popup).toContainElement(
199
203
  screen.getByRole('button', {name: 'Done', ...h}),
200
204
  );
205
+
206
+ // And it is not the bare positioning layer either.
207
+ const layer = document.querySelector('[popover]') as HTMLElement;
208
+ expect(popup).not.toBe(layer);
209
+ expect(layer.contains(popup)).toBe(true);
201
210
  });
202
211
 
203
212
  it('keeps the target when the consumer also passes contentXstyle', async () => {
@@ -292,6 +292,11 @@ export function ComplexSelector<Value>({
292
292
  dialogLabel: label,
293
293
  hasCloseButton: false,
294
294
  hasAutoFocus: true,
295
+ // The popup's theme target belongs on the SURFACE — the element painting
296
+ // background, radius and elevation — which `usePopover` owns. Rendered on
297
+ // the content box below it, a theme's background or radius rule paints the
298
+ // wrong box.
299
+ surfaceTarget: 'complex-selector-popup',
295
300
  onHide: () => {
296
301
  document.getElementById(triggerId)?.focus();
297
302
  },
@@ -313,16 +318,7 @@ export function ComplexSelector<Value>({
313
318
  const triggerContent = triggerLabel ?? placeholder;
314
319
 
315
320
  const content = (
316
- // The theme target sits here, not on the layer: the layer element is a
317
- // bare positioning box (useLayer zeroes its borders, padding and
318
- // background), so this content box is the surface a theme has to reach to
319
- // paint the popup.
320
- <div
321
- id={contentId}
322
- {...mergeProps(
323
- themeProps('complex-selector-popup'),
324
- stylex.props(styles.content, contentXstyle),
325
- )}>
321
+ <div id={contentId} {...stylex.props(styles.content, contentXstyle)}>
326
322
  {children(optimisticValue, commitValue, popover.hide, {
327
323
  isOpen: popover.isOpen,
328
324
  isBusy,
@@ -25,6 +25,7 @@ import {InputGroup} from '../InputGroup';
25
25
  import {InputGroupText} from '../InputGroup/InputGroupText';
26
26
  import {defineTheme} from '../theme/defineTheme';
27
27
  import {generateThemeCSS} from '../theme/generateThemeRules';
28
+ import {InternationalizationProvider} from '../i18n';
28
29
 
29
30
  function generateThemeTestCSS(theme: Parameters<typeof generateThemeCSS>[0]) {
30
31
  const {prose, component} = generateThemeCSS(theme);
@@ -170,6 +171,24 @@ describe('DateInput', () => {
170
171
  expect(screen.queryByText('Invalid date')).not.toBeInTheDocument();
171
172
  });
172
173
 
174
+ it('resolves the invalid-date announcement from the i18n catalog', () => {
175
+ const {container} = render(
176
+ <InternationalizationProvider
177
+ locale="en"
178
+ overrides={{en: {'@astryx.dateInput.invalidDate': 'Ungültiges Datum'}}}>
179
+ <DateInput label="Date" onChange={() => {}} />
180
+ </InternationalizationProvider>,
181
+ );
182
+
183
+ fireEvent.change(screen.getByRole('combobox'), {
184
+ target: {value: '13/45/2024'},
185
+ });
186
+
187
+ expect(within(container).getByRole('alert')).toHaveTextContent(
188
+ 'Ungültiges Datum',
189
+ );
190
+ });
191
+
173
192
  it('reverts to previous value on blur when input is invalid', async () => {
174
193
  const onChange = vi.fn();
175
194
  render(<DateInput label="Date" value="2026-01-25" onChange={onChange} />);
@@ -80,11 +80,6 @@ const styles = stylex.create({
80
80
  backgroundColor: 'transparent',
81
81
  cursor: 'pointer',
82
82
  borderRadius: radiusVars['--radius-element'],
83
- outline: {
84
- default: 'none',
85
- ':focus-visible': `${borderVars['--border-width']} solid ${colorVars['--color-accent']}`,
86
- },
87
- outlineOffset: 1,
88
83
  },
89
84
  iconButtonDisabled: {
90
85
  cursor: 'not-allowed',
@@ -163,6 +158,7 @@ import {mergeProps, mergeRefs, isFocusDetached} from '../utils';
163
158
  import type {BaseProps} from '../BaseProps';
164
159
  import type {SizeValue} from '../utils/types';
165
160
  import {themeProps} from '../utils/themeProps';
161
+ import {focusOutlineStyles} from '../utils/focusOutline.stylex';
166
162
  import {stableClassName} from '../naming';
167
163
  import {useTranslator} from '../i18n';
168
164
 
@@ -694,6 +690,7 @@ export function DateInput({
694
690
  : t('@astryx.dateInput.openCalendar')
695
691
  }
696
692
  {...stylex.props(
693
+ focusOutlineStyles.focusVisible,
697
694
  styles.iconButton,
698
695
  isEffectivelyDisabled && styles.iconButtonDisabled,
699
696
  )}>
@@ -754,7 +751,7 @@ export function DateInput({
754
751
  user would get no feedback that their entry was rejected (WCAG 3.3.1).
755
752
  */}
756
753
  <VisuallyHidden as="div" role="alert" aria-live="assertive">
757
- {!isInputValid ? 'Invalid date' : ''}
754
+ {!isInputValid ? t('@astryx.dateInput.invalidDate') : ''}
758
755
  </VisuallyHidden>
759
756
  {hasClear && value !== undefined && !isEffectivelyDisabled && (
760
757
  <InputClearButton
@@ -61,6 +61,7 @@ import type {SizeValue} from '../utils/types';
61
61
  import {useSize} from '../SizeContext/SizeContext';
62
62
  import {useInputStatusIcon} from '../hooks/useInputStatusIcon';
63
63
  import {themeProps} from '../utils/themeProps';
64
+ import {focusOutlineStyles} from '../utils/focusOutline.stylex';
64
65
  import {stableClassName} from '../naming';
65
66
  import {useTranslator} from '../i18n';
66
67
 
@@ -118,11 +119,6 @@ const styles = stylex.create({
118
119
  backgroundColor: 'transparent',
119
120
  cursor: 'pointer',
120
121
  borderRadius: radiusVars['--radius-element'],
121
- outline: {
122
- default: 'none',
123
- ':focus-visible': `${borderVars['--border-width']} solid ${colorVars['--color-accent']}`,
124
- },
125
- outlineOffset: 1,
126
122
  },
127
123
  iconButtonDisabled: {
128
124
  cursor: 'not-allowed',
@@ -160,10 +156,6 @@ const styles = stylex.create({
160
156
  color: colorVars['--color-text-primary'],
161
157
  cursor: 'pointer',
162
158
  textAlign: 'start',
163
- outline: {
164
- default: 'none',
165
- ':focus-visible': `${borderVars['--border-width']} solid ${colorVars['--color-accent']}`,
166
- },
167
159
  },
168
160
  presetButtonActive: {
169
161
  backgroundColor: colorVars['--color-accent-muted'],
@@ -592,6 +584,7 @@ export function DateRangeInput({
592
584
  }
593
585
  tabIndex={-1}
594
586
  {...stylex.props(
587
+ focusOutlineStyles.focusVisible,
595
588
  styles.iconButton,
596
589
  isEffectivelyDisabled && styles.iconButtonDisabled,
597
590
  )}>
@@ -661,6 +654,7 @@ export function DateRangeInput({
661
654
  aria-current={isActive ? 'true' : undefined}
662
655
  onClick={() => handlePresetClick(preset)}
663
656
  {...stylex.props(
657
+ focusOutlineStyles.focusVisible,
664
658
  styles.presetButton,
665
659
  isActive && styles.presetButtonActive,
666
660
  )}>
@@ -9,7 +9,7 @@
9
9
  * SYNC: When DateTimeInput.tsx changes, update tests to match new behavior
10
10
  */
11
11
 
12
- import {describe, it, expect, vi, beforeEach} from 'vitest';
12
+ import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest';
13
13
  import {render, screen, fireEvent, waitFor} from '@testing-library/react';
14
14
  import userEvent from '@testing-library/user-event';
15
15
  import {getButton, queryButton} from '../__tests__/fastRoleQueries';
@@ -17,6 +17,16 @@ import {DateTimeInput} from './DateTimeInput';
17
17
  import type {ISODateTimeString} from './DateTimeInput';
18
18
  import {defineTheme} from '../theme/defineTheme';
19
19
  import {generateThemeCSS} from '../theme/generateThemeRules';
20
+ import {InternationalizationProvider} from '../i18n';
21
+ import {__resetLiveRegionsForTest} from '../hooks/useAnnounce';
22
+
23
+ function politeRegion(): HTMLElement | null {
24
+ return document.querySelector('[data-astryx-live-region="polite"]');
25
+ }
26
+
27
+ afterEach(() => {
28
+ __resetLiveRegionsForTest();
29
+ });
20
30
 
21
31
  function generateThemeTestCSS(theme: Parameters<typeof generateThemeCSS>[0]) {
22
32
  const {prose, component} = generateThemeCSS(theme);
@@ -626,6 +636,35 @@ describe('DateTimeInput', () => {
626
636
 
627
637
  expect(screen.getByText('Invalid time')).toBeInTheDocument();
628
638
  });
639
+
640
+ it('resolves the invalid date and time announcements from the i18n catalog', () => {
641
+ render(
642
+ <InternationalizationProvider
643
+ locale="en"
644
+ overrides={{
645
+ en: {
646
+ '@astryx.dateInput.invalidDate': 'Ungültiges Datum',
647
+ '@astryx.timeInput.invalidTime': 'Ungültige Zeit',
648
+ },
649
+ }}>
650
+ <DateTimeInput
651
+ label="Meeting"
652
+ value={'2026-03-15T10:00' as ISODateTimeString}
653
+ onChange={() => {}}
654
+ />
655
+ </InternationalizationProvider>,
656
+ );
657
+
658
+ fireEvent.change(screen.getByRole('combobox'), {
659
+ target: {value: '13/45/2024'},
660
+ });
661
+ expect(screen.getByText('Ungültiges Datum')).toBeInTheDocument();
662
+
663
+ fireEvent.change(screen.getByLabelText('Meeting time'), {
664
+ target: {value: '99:99 zz'},
665
+ });
666
+ expect(screen.getByText('Ungültige Zeit')).toBeInTheDocument();
667
+ });
629
668
  });
630
669
  describe('disabledMessage', () => {
631
670
  // jsdom does not implement the Popover API used by the tooltip, so mock
@@ -819,6 +858,45 @@ describe('DateTimeInput', () => {
819
858
  expect(onChange).toHaveBeenCalledTimes(1);
820
859
  expect(onChange.mock.calls[0][0]).toContain('14:31');
821
860
  });
861
+
862
+ // Arrow-key stepping mutates a plain textbox programmatically, and screen
863
+ // readers do not announce programmatic textbox changes — the new value
864
+ // must be announced through the polite live region (WCAG 4.1.2).
865
+ it('politely announces the new time after ArrowUp stepping', async () => {
866
+ render(
867
+ <DateTimeInput
868
+ label="Meeting"
869
+ value={'2026-03-15T14:30' as ISODateTimeString}
870
+ onChange={() => {}}
871
+ />,
872
+ );
873
+
874
+ fireEvent.keyDown(screen.getByLabelText('Meeting time'), {
875
+ key: 'ArrowUp',
876
+ });
877
+
878
+ await waitFor(() => {
879
+ expect(politeRegion()).toHaveTextContent('2:31 PM');
880
+ });
881
+ });
882
+
883
+ it('politely announces the new time after ArrowDown stepping', async () => {
884
+ render(
885
+ <DateTimeInput
886
+ label="Meeting"
887
+ value={'2026-03-15T14:30' as ISODateTimeString}
888
+ onChange={() => {}}
889
+ />,
890
+ );
891
+
892
+ fireEvent.keyDown(screen.getByLabelText('Meeting time'), {
893
+ key: 'ArrowDown',
894
+ });
895
+
896
+ await waitFor(() => {
897
+ expect(politeRegion()).toHaveTextContent('2:29 PM');
898
+ });
899
+ });
822
900
  });
823
901
 
824
902
  describe('weekStartsOn', () => {
@@ -4,7 +4,7 @@
4
4
 
5
5
  /**
6
6
  * @file DateTimeInput.tsx
7
- * @input Uses React, Field, Calendar, usePopover, time parsing utilities
7
+ * @input Uses React, Field, Calendar, usePopover, useAnnounce, time parsing utilities
8
8
  * @output Exports DateTimeInput component, DateTimeInputProps
9
9
  * @position Core implementation; consumed by index.ts, tested by DateTimeInput.test.tsx
10
10
  *
@@ -82,8 +82,10 @@ import {
82
82
 
83
83
  import type {BaseProps} from '../BaseProps';
84
84
  import type {SizeValue} from '../utils/types';
85
+ import {useAnnounce} from '../hooks/useAnnounce';
85
86
  import {useSize} from '../SizeContext/SizeContext';
86
87
  import {themeProps} from '../utils/themeProps';
88
+ import {focusOutlineStyles} from '../utils/focusOutline.stylex';
87
89
  import {useTranslator} from '../i18n';
88
90
 
89
91
  export type ISODateTimeString = string & {
@@ -118,11 +120,6 @@ const styles = stylex.create({
118
120
  backgroundColor: 'transparent',
119
121
  cursor: 'pointer',
120
122
  borderRadius: radiusVars['--radius-element'],
121
- outline: {
122
- default: 'none',
123
- ':focus-visible': `${borderVars['--border-width']} solid ${colorVars['--color-accent']}`,
124
- },
125
- outlineOffset: 1,
126
123
  },
127
124
  iconButtonDisabled: {
128
125
  cursor: 'not-allowed',
@@ -450,6 +447,10 @@ export function DateTimeInput({
450
447
  ...rest
451
448
  }: DateTimeInputProps) {
452
449
  const t = useTranslator();
450
+ // Speaks arrow-key stepping results through the persistent live regions:
451
+ // stepping programmatically rewrites a plain textbox's value, which screen
452
+ // readers do not announce on their own (WCAG 4.1.2).
453
+ const announce = useAnnounce();
453
454
  const placeholder =
454
455
  placeholderFromProps ?? t('@astryx.dateTimeInput.placeholder');
455
456
  const timePlaceholder =
@@ -855,11 +856,23 @@ export function DateTimeInput({
855
856
  const combined = combineDateTime(valueParts.date, newTime);
856
857
  if (combined) {
857
858
  fireChange(combined);
859
+ // Screen readers do not announce the programmatic value rewrite,
860
+ // so speak the new time explicitly (WCAG 4.1.2).
861
+ announce(formatDisplayTime(newTime, hasSeconds));
858
862
  }
859
863
  }
860
864
  }
861
865
  },
862
- [valueParts, hasSeconds, timeIncrement, timeMin, timeMax, fireChange],
866
+ [
867
+ valueParts,
868
+ hasSeconds,
869
+ timeIncrement,
870
+ timeMin,
871
+ timeMax,
872
+ fireChange,
873
+ announce,
874
+ formatDisplayTime,
875
+ ],
863
876
  );
864
877
 
865
878
  // --- Clear ---
@@ -941,6 +954,7 @@ export function DateTimeInput({
941
954
  : t('@astryx.dateInput.openCalendar')
942
955
  }
943
956
  {...stylex.props(
957
+ focusOutlineStyles.focusVisible,
944
958
  styles.iconButton,
945
959
  isEffectivelyDisabled && styles.iconButtonDisabled,
946
960
  )}>
@@ -988,7 +1002,7 @@ export function DateTimeInput({
988
1002
  rejected (WCAG 3.3.1).
989
1003
  */}
990
1004
  <VisuallyHidden as="div" role="alert" aria-live="assertive">
991
- {!isDateInputValid ? 'Invalid date' : ''}
1005
+ {!isDateInputValid ? t('@astryx.dateInput.invalidDate') : ''}
992
1006
  </VisuallyHidden>
993
1007
  {hasClear && value !== undefined && !isEffectivelyDisabled && (
994
1008
  <InputClearButton
@@ -1061,7 +1075,7 @@ export function DateTimeInput({
1061
1075
  technology (WCAG 3.3.1).
1062
1076
  */}
1063
1077
  <VisuallyHidden as="div" role="alert" aria-live="assertive">
1064
- {!isTimeInputValid ? 'Invalid time' : ''}
1078
+ {!isTimeInputValid ? t('@astryx.timeInput.invalidTime') : ''}
1065
1079
  </VisuallyHidden>
1066
1080
  </div>
1067
1081
  </div>
@@ -92,6 +92,7 @@ export function DropdownMenuRadioGroup({
92
92
  label,
93
93
  hasCloseOnSelect = true,
94
94
  children,
95
+ xstyle,
95
96
  className,
96
97
  style,
97
98
  ...rest
@@ -106,7 +107,7 @@ export function DropdownMenuRadioGroup({
106
107
  {...rest}
107
108
  role="group"
108
109
  aria-label={label}
109
- {...mergeProps(stylex.props(styles.group), {className, style})}>
110
+ {...mergeProps(stylex.props(styles.group, xstyle), {className, style})}>
110
111
  <DropdownMenuRadioGroupContext value={contextValue}>
111
112
  {children}
112
113
  </DropdownMenuRadioGroupContext>