@astryxdesign/core 0.4.0 → 0.4.1

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 (194) 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 +2 -2
  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/Icon/globalIconRegistry.d.ts +5 -4
  52. package/dist/Icon/globalIconRegistry.d.ts.map +1 -1
  53. package/dist/Icon/globalIconRegistry.js +5 -4
  54. package/dist/Indicator/index.d.ts +1 -1
  55. package/dist/Indicator/index.d.ts.map +1 -1
  56. package/dist/Indicator/types.d.ts +10 -0
  57. package/dist/Indicator/types.d.ts.map +1 -1
  58. package/dist/Lightbox/Lightbox.d.ts.map +1 -1
  59. package/dist/Lightbox/Lightbox.js +24 -50
  60. package/dist/MultiSelector/MultiSelector.d.ts +8 -1
  61. package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
  62. package/dist/MultiSelector/MultiSelector.js +134 -102
  63. package/dist/Popover/usePopover.d.ts +15 -0
  64. package/dist/Popover/usePopover.d.ts.map +1 -1
  65. package/dist/Popover/usePopover.js +14 -2
  66. package/dist/ProgressBar/ProgressBar.d.ts.map +1 -1
  67. package/dist/ProgressBar/ProgressBar.js +2 -3
  68. package/dist/Section/Section.d.ts.map +1 -1
  69. package/dist/Section/Section.js +1 -2
  70. package/dist/Section/index.d.ts.map +1 -1
  71. package/dist/Section/index.js +1 -2
  72. package/dist/Selector/Selector.d.ts +10 -0
  73. package/dist/Selector/Selector.d.ts.map +1 -1
  74. package/dist/Selector/Selector.js +135 -95
  75. package/dist/Skeleton/Skeleton.d.ts.map +1 -1
  76. package/dist/Skeleton/Skeleton.js +1 -2
  77. package/dist/Skeleton/index.d.ts.map +1 -1
  78. package/dist/Skeleton/index.js +1 -2
  79. package/dist/Slider/Slider.d.ts.map +1 -1
  80. package/dist/Slider/Slider.js +2 -6
  81. package/dist/Switch/Switch.d.ts.map +1 -1
  82. package/dist/Switch/Switch.js +3 -3
  83. package/dist/Table/TableRow.d.ts +1 -1
  84. package/dist/Table/TableRow.d.ts.map +1 -1
  85. package/dist/Table/TableRow.js +4 -2
  86. package/dist/TimeInput/TimeInput.d.ts.map +1 -1
  87. package/dist/TimeInput/TimeInput.js +7 -2
  88. package/dist/Timestamp/Timestamp.d.ts.map +1 -1
  89. package/dist/Timestamp/Timestamp.js +14 -3
  90. package/dist/Timestamp/formatInstant.d.ts +11 -1
  91. package/dist/Timestamp/formatInstant.d.ts.map +1 -1
  92. package/dist/Timestamp/formatInstant.js +3 -1
  93. package/dist/VisuallyHidden/VisuallyHidden.d.ts.map +1 -1
  94. package/dist/VisuallyHidden/VisuallyHidden.js +1 -2
  95. package/dist/VisuallyHidden/index.d.ts.map +1 -1
  96. package/dist/VisuallyHidden/index.js +1 -2
  97. package/dist/astryx.css +18 -17
  98. package/dist/astryx.umd.js +45 -45
  99. package/dist/astryx.umd.js.map +4 -4
  100. package/dist/hooks/useInputStatusIcon.d.ts.map +1 -1
  101. package/dist/hooks/useInputStatusIcon.js +17 -4
  102. package/dist/theme/defineTheme.d.ts +2 -2
  103. package/dist/theme/defineTheme.d.ts.map +1 -1
  104. package/dist/theme/defineTheme.js +2 -1
  105. package/dist/theme/index.d.ts +1 -1
  106. package/dist/theme/index.d.ts.map +1 -1
  107. package/dist/theme/index.js +1 -1
  108. package/dist/theme/tokens.stylex.d.ts +12 -0
  109. package/dist/theme/tokens.stylex.d.ts.map +1 -1
  110. package/dist/theme/tokens.stylex.js +23 -0
  111. package/dist/utils/focusOutline.stylex.d.ts +34 -19
  112. package/dist/utils/focusOutline.stylex.d.ts.map +1 -1
  113. package/dist/utils/focusOutline.stylex.js +46 -33
  114. package/dist/utils/index.d.ts +1 -0
  115. package/dist/utils/index.d.ts.map +1 -1
  116. package/dist/utils/index.js +5 -1
  117. package/dist/utils/interactionModality.d.ts +34 -0
  118. package/dist/utils/interactionModality.d.ts.map +1 -0
  119. package/dist/utils/interactionModality.js +70 -0
  120. package/locales/en.json +40 -0
  121. package/locales/pseudo.json +30 -0
  122. package/package.json +2 -2
  123. package/src/AppShell/AppShell.doc.mjs +3 -0
  124. package/src/AspectRatio/AspectRatio.tsx +0 -2
  125. package/src/AspectRatio/index.ts +0 -2
  126. package/src/Badge/Badge.tsx +0 -2
  127. package/src/Badge/index.ts +0 -2
  128. package/src/Banner/Banner.test.tsx +58 -1
  129. package/src/Banner/Banner.tsx +24 -5
  130. package/src/Blockquote/Blockquote.tsx +0 -2
  131. package/src/Blockquote/index.ts +0 -2
  132. package/src/Button/Button.doc.mjs +2 -2
  133. package/src/Button/Button.tsx +5 -4
  134. package/src/Card/Card.tsx +0 -2
  135. package/src/Card/index.ts +0 -2
  136. package/src/Center/Center.tsx +0 -2
  137. package/src/Center/index.ts +0 -2
  138. package/src/CheckboxInput/CheckboxInput.test.tsx +8 -3
  139. package/src/Code/Code.tsx +0 -2
  140. package/src/CommandPalette/CommandPalette.test.tsx +54 -0
  141. package/src/CommandPalette/CommandPalette.tsx +5 -0
  142. package/src/ComplexSelector/ComplexSelector.test.tsx +15 -6
  143. package/src/ComplexSelector/ComplexSelector.tsx +6 -10
  144. package/src/DateInput/DateInput.test.tsx +19 -0
  145. package/src/DateInput/DateInput.tsx +3 -6
  146. package/src/DateRangeInput/DateRangeInput.tsx +3 -9
  147. package/src/DateTimeInput/DateTimeInput.test.tsx +79 -1
  148. package/src/DateTimeInput/DateTimeInput.tsx +23 -9
  149. package/src/DropdownMenu/DropdownMenuRadioGroup.tsx +2 -1
  150. package/src/Field/PanelSearchInput.tsx +271 -0
  151. package/src/FileInput/FileInput.tsx +21 -8
  152. package/src/Grid/Grid.tsx +0 -2
  153. package/src/Grid/index.ts +0 -2
  154. package/src/Icon/globalIconRegistry.tsx +5 -4
  155. package/src/Indicator/Indicator.doc.mjs +1 -1
  156. package/src/Indicator/index.ts +1 -0
  157. package/src/Indicator/types.ts +11 -0
  158. package/src/Lightbox/Lightbox.tsx +2 -11
  159. package/src/MultiSelector/MultiSelector.doc.mjs +9 -0
  160. package/src/MultiSelector/MultiSelector.test.tsx +135 -5
  161. package/src/MultiSelector/MultiSelector.tsx +170 -127
  162. package/src/Popover/Popover.doc.mjs +2 -0
  163. package/src/Popover/usePopover.tsx +37 -4
  164. package/src/ProgressBar/ProgressBar.tsx +2 -8
  165. package/src/Section/Section.tsx +0 -2
  166. package/src/Section/index.ts +0 -2
  167. package/src/Selector/Selector.doc.mjs +10 -0
  168. package/src/Selector/Selector.test.tsx +386 -2
  169. package/src/Selector/Selector.tsx +182 -122
  170. package/src/Skeleton/Skeleton.tsx +0 -2
  171. package/src/Skeleton/index.ts +0 -2
  172. package/src/Slider/Slider.tsx +2 -11
  173. package/src/Switch/Switch.tsx +10 -2
  174. package/src/Table/Table.test.tsx +31 -0
  175. package/src/Table/TableRow.tsx +6 -0
  176. package/src/TimeInput/TimeInput.test.tsx +54 -0
  177. package/src/TimeInput/TimeInput.tsx +17 -2
  178. package/src/Timestamp/Timestamp.test.tsx +91 -14
  179. package/src/Timestamp/Timestamp.tsx +14 -2
  180. package/src/Timestamp/formatInstant.ts +16 -1
  181. package/src/TopNav/TopNavMegaMenu.doc.mjs +5 -0
  182. package/src/TreeList/TreeList.test.tsx +3 -2
  183. package/src/VisuallyHidden/VisuallyHidden.tsx +0 -2
  184. package/src/VisuallyHidden/index.ts +0 -2
  185. package/src/hooks/useInputStatusIcon.tsx +8 -8
  186. package/src/serverSafeComponents.test.ts +665 -0
  187. package/src/theme/defineTheme.ts +3 -0
  188. package/src/theme/generateThemeRules.test.ts +17 -0
  189. package/src/theme/index.ts +2 -0
  190. package/src/theme/tokens.stylex.ts +18 -0
  191. package/src/theme/tokens.test.ts +37 -0
  192. package/src/utils/focusOutline.stylex.ts +37 -23
  193. package/src/utils/index.ts +4 -0
  194. package/src/utils/interactionModality.ts +74 -0
@@ -14,6 +14,7 @@ import {render, screen, fireEvent, waitFor} from '@testing-library/react';
14
14
  import userEvent from '@testing-library/user-event';
15
15
  import {TimeInput} from './TimeInput';
16
16
  import {InputGroup, InputGroupText} from '../InputGroup';
17
+ import {InternationalizationProvider} from '../i18n';
17
18
  import type {ISOTimeString} from '../utils';
18
19
  import {__resetLiveRegionsForTest} from '../hooks/useAnnounce';
19
20
 
@@ -209,6 +210,59 @@ describe('TimeInput', () => {
209
210
  expect(screen.queryByText('Invalid time')).not.toBeInTheDocument();
210
211
  });
211
212
 
213
+ it('resolves the invalid-time announcement from the i18n catalog', () => {
214
+ render(
215
+ <InternationalizationProvider
216
+ locale="en"
217
+ overrides={{en: {'@astryx.timeInput.invalidTime': 'Ungültige Zeit'}}}>
218
+ <TimeInput label="Time" onChange={() => {}} />
219
+ </InternationalizationProvider>,
220
+ );
221
+
222
+ fireEvent.change(screen.getByRole('textbox'), {target: {value: '25:99'}});
223
+
224
+ expect(screen.getByRole('alert')).toHaveTextContent('Ungültige Zeit');
225
+ });
226
+
227
+ // Arrow-key stepping mutates a plain textbox programmatically, and screen
228
+ // readers do not announce programmatic textbox changes — the new value must
229
+ // be announced through the polite live region (WCAG 4.1.2).
230
+ it('politely announces the new time after ArrowUp stepping', async () => {
231
+ const onChange = vi.fn();
232
+ render(
233
+ <TimeInput
234
+ label="Time"
235
+ value={'14:30' as ISOTimeString}
236
+ onChange={onChange}
237
+ />,
238
+ );
239
+
240
+ fireEvent.keyDown(screen.getByRole('textbox'), {key: 'ArrowUp'});
241
+
242
+ expect(onChange).toHaveBeenCalledWith('14:31');
243
+ await waitFor(() => {
244
+ expect(politeRegion()).toHaveTextContent('2:31 PM');
245
+ });
246
+ });
247
+
248
+ it('politely announces the new time after ArrowDown stepping', async () => {
249
+ const onChange = vi.fn();
250
+ render(
251
+ <TimeInput
252
+ label="Time"
253
+ value={'14:30' as ISOTimeString}
254
+ onChange={onChange}
255
+ />,
256
+ );
257
+
258
+ fireEvent.keyDown(screen.getByRole('textbox'), {key: 'ArrowDown'});
259
+
260
+ expect(onChange).toHaveBeenCalledWith('14:29');
261
+ await waitFor(() => {
262
+ expect(politeRegion()).toHaveTextContent('2:29 PM');
263
+ });
264
+ });
265
+
212
266
  it('calls onChange on blur when input is valid', async () => {
213
267
  const user = userEvent.setup();
214
268
  const onChange = vi.fn();
@@ -558,10 +558,25 @@ export function TimeInput({
558
558
  // Check if within range
559
559
  if (isTimeInRange(newTime, min, max)) {
560
560
  fireChange(newTime);
561
+ // Stepping programmatically rewrites a plain textbox's value, and
562
+ // screen readers do not announce programmatic textbox changes — the
563
+ // new value must be spoken explicitly or stepping is silent
564
+ // (WCAG 4.1.2).
565
+ announce(formatDisplayTime(newTime, hasSeconds));
561
566
  }
562
567
  }
563
568
  },
564
- [value, hasSeconds, increment, min, max, fireChange, isDisabled],
569
+ [
570
+ value,
571
+ hasSeconds,
572
+ increment,
573
+ min,
574
+ max,
575
+ fireChange,
576
+ isDisabled,
577
+ announce,
578
+ formatDisplayTime,
579
+ ],
565
580
  );
566
581
 
567
582
  // Handle clear button click
@@ -659,7 +674,7 @@ export function TimeInput({
659
674
  user would get no feedback that their entry was rejected (WCAG 3.3.1).
660
675
  */}
661
676
  <VisuallyHidden as="div" role="alert" aria-live="assertive">
662
- {!isInputValid ? 'Invalid time' : ''}
677
+ {!isInputValid ? t('@astryx.timeInput.invalidTime') : ''}
663
678
  </VisuallyHidden>
664
679
  {isBusy && <Spinner size="sm" />}
665
680
  {hasClear && value && !isDisabled && (
@@ -408,6 +408,50 @@ describe('Timestamp', () => {
408
408
  expect(el.getAttribute('aria-label')).toBeNull();
409
409
  });
410
410
 
411
+ // Timezone abbreviations like "PST" or "GMT+2" are unexpanded abbreviations
412
+ // to a screen-reader user (WCAG 3.1.4) — the AT-facing aria-label must spell
413
+ // the timezone out in full, while visible text keeps the compact short form.
414
+ it('uses the long timezone name in the aria-label (WCAG 3.1.4)', () => {
415
+ const oneHourAgo = new Date(Date.now() - 3600 * 1000);
416
+ render(
417
+ <Timestamp
418
+ value={oneHourAgo.getTime() / 1000}
419
+ format="relative"
420
+ hasTooltip={false}
421
+ data-testid="ts"
422
+ />,
423
+ );
424
+
425
+ const longTz =
426
+ new Intl.DateTimeFormat(undefined, {timeZoneName: 'long'})
427
+ .formatToParts(oneHourAgo)
428
+ .find(p => p.type === 'timeZoneName')?.value ?? '';
429
+ expect(longTz).not.toBe('');
430
+ expect(screen.getByTestId('ts').getAttribute('aria-label')).toContain(
431
+ longTz,
432
+ );
433
+ });
434
+
435
+ it('keeps the short timezone form in visible text when isTimezoneShown', () => {
436
+ const date = new Date('2026-03-25T10:00:00Z');
437
+ render(
438
+ <Timestamp
439
+ value="2026-03-25T10:00:00Z"
440
+ format="time"
441
+ isTimezoneShown
442
+ data-testid="ts"
443
+ />,
444
+ );
445
+
446
+ const tzPart = (form: 'short' | 'long') =>
447
+ new Intl.DateTimeFormat(undefined, {timeZoneName: form})
448
+ .formatToParts(date)
449
+ .find(p => p.type === 'timeZoneName')?.value ?? '';
450
+ const text = screen.getByTestId('ts').textContent ?? '';
451
+ expect(text).toContain(tzPart('short'));
452
+ expect(text).not.toContain(tzPart('long'));
453
+ });
454
+
411
455
  // --- Input handling ---
412
456
 
413
457
  it('accepts Unix timestamp in seconds', () => {
@@ -602,15 +646,26 @@ describe('Timestamp', () => {
602
646
  );
603
647
  const el = screen.getByTestId('ts');
604
648
 
605
- // The card layer mounts inline and carries the full absolute time (the
606
- // same string as the aria-label) as its single default copyable row.
649
+ // The card layer mounts inline and carries the full absolute time in
650
+ // its visible form (short timezone abbreviation) as its single default
651
+ // copyable row. The aria-label spells the timezone out in full
652
+ // (WCAG 3.1.4), so the two strings intentionally differ — compare the
653
+ // card against an independently formatted short-form string.
607
654
  // Compare with normalized whitespace: Intl output can contain narrow
608
655
  // no-break spaces that jest-dom's matcher normalization would break on.
609
656
  const card = await screen.findByRole('dialog', {hidden: true});
610
657
  const normalize = (s: string) => s.replace(/\s+/g, ' ');
611
- expect(normalize(card.textContent ?? '')).toContain(
612
- normalize(el.getAttribute('aria-label') ?? '\0'),
613
- );
658
+ const datetime = new Date(el.getAttribute('datetime') ?? '');
659
+ const expected = new Intl.DateTimeFormat(undefined, {
660
+ year: 'numeric',
661
+ month: 'long',
662
+ day: 'numeric',
663
+ hour: 'numeric',
664
+ minute: '2-digit',
665
+ second: '2-digit',
666
+ timeZoneName: 'short',
667
+ }).format(datetime);
668
+ expect(normalize(card.textContent ?? '')).toContain(normalize(expected));
614
669
 
615
670
  // Tab onto the timestamp — the only tab stop in the document.
616
671
  await user.tab();
@@ -705,15 +760,28 @@ describe('Timestamp', () => {
705
760
  // The default card is the named details card, exactly as the configured
706
761
  // one is.
707
762
  expect(card).toHaveAttribute('aria-label', 'Timestamp details');
708
- // Exactly one row, carrying the full absolute time (the same string as
709
- // the aria-label) with its own copy button.
763
+ // Exactly one row, carrying the full absolute time in its visible form
764
+ // (short timezone abbreviation) with its own copy button. The
765
+ // aria-label spells the timezone out in full (WCAG 3.1.4), so the two
766
+ // strings intentionally differ — compare the row against an
767
+ // independently formatted short-form string.
710
768
  expect(card.querySelectorAll('dd')).toHaveLength(1);
711
769
  expect(card.querySelectorAll('button')).toHaveLength(1);
712
770
 
713
771
  const normalize = (s: string) => s.replace(/\s+/g, ' ');
714
- expect(normalize(card.textContent ?? '')).toContain(
715
- normalize(screen.getByTestId('ts').getAttribute('aria-label') ?? '\0'),
772
+ const datetime = new Date(
773
+ screen.getByTestId('ts').getAttribute('datetime') ?? '',
716
774
  );
775
+ const expected = new Intl.DateTimeFormat(undefined, {
776
+ year: 'numeric',
777
+ month: 'long',
778
+ day: 'numeric',
779
+ hour: 'numeric',
780
+ minute: '2-digit',
781
+ second: '2-digit',
782
+ timeZoneName: 'short',
783
+ }).format(datetime);
784
+ expect(normalize(card.textContent ?? '')).toContain(normalize(expected));
717
785
  });
718
786
 
719
787
  it("copies the default absolute row's value", async () => {
@@ -1171,10 +1239,14 @@ describe('Timestamp', () => {
1171
1239
  },
1172
1240
  );
1173
1241
 
1174
- it('renders the full style identically as the aria-label and as a line', () => {
1242
+ it('renders the full style as the aria-label and as a line, differing only in zone-name spelling', () => {
1175
1243
  // 'full' is the one member with no visible-text counterpart — it backs
1176
1244
  // the accessible name of a relative timestamp and the tooltip's default
1177
- // line. Those two must not drift apart either.
1245
+ // line. The two must not drift apart on anything but the zone name: the
1246
+ // aria-label spells the zone out in full for assistive tech (WCAG 3.1.4),
1247
+ // the visible line keeps the abbreviation. Comparing them after swapping
1248
+ // the abbreviation for the spelled-out form keeps this a direct
1249
+ // surface-to-surface check, so re-forking either path still fails here.
1178
1250
  render(
1179
1251
  <Timestamp
1180
1252
  value={VALUE}
@@ -1183,9 +1255,14 @@ describe('Timestamp', () => {
1183
1255
  data-testid="ts"
1184
1256
  />,
1185
1257
  );
1186
- const [line] = formatTooltipLines(new Date(VALUE), [{format: 'full'}]);
1187
- expect(line.value).toBe(
1188
- screen.getByTestId('ts').getAttribute('aria-label'),
1258
+ const date = new Date(VALUE);
1259
+ const tzPart = (form: 'short' | 'long') =>
1260
+ new Intl.DateTimeFormat(undefined, {timeZoneName: form})
1261
+ .formatToParts(date)
1262
+ .find(p => p.type === 'timeZoneName')?.value ?? '';
1263
+ const [line] = formatTooltipLines(date, [{format: 'full'}]);
1264
+ expect(screen.getByTestId('ts').getAttribute('aria-label')).toBe(
1265
+ line.value.replace(tzPart('short'), tzPart('long')),
1189
1266
  );
1190
1267
  });
1191
1268
  });
@@ -451,8 +451,14 @@ export function Timestamp({
451
451
  ? formatInstant(date, effectiveFormat, {isTimezoneShown})
452
452
  : '';
453
453
 
454
- // Full absolute text for tooltip and aria-label
454
+ // Full absolute text for the tooltip (visible — keeps the compact timezone
455
+ // abbreviation) and for the AT-facing aria-label, which spells the timezone
456
+ // out in full: abbreviations like "PST" or "GMT+2" are unexpanded
457
+ // abbreviations to a screen-reader user (WCAG 3.1.4).
455
458
  const fullAbsoluteText = isValidDate ? formatInstant(date, 'full') : '';
459
+ const ariaLabelText = isValidDate
460
+ ? formatInstant(date, 'full', {timeZoneNameStyle: 'long'})
461
+ : '';
456
462
 
457
463
  // Live updates
458
464
  useEffect(() => {
@@ -518,8 +524,14 @@ export function Timestamp({
518
524
  <time
519
525
  ref={mergeRefs(ref, timeRef)}
520
526
  dateTime={isoString}
527
+ // `ariaLabelText` is '' only for an invalid date, which bails out
528
+ // before rendering — but keep the guard local: an empty aria-label
529
+ // must be omitted entirely (not rendered as aria-label="") so AT
530
+ // falls back to reading the visible <time> content.
521
531
  aria-label={
522
- isRelativeFormat(effectiveFormat) ? fullAbsoluteText : undefined
532
+ isRelativeFormat(effectiveFormat) && ariaLabelText !== ''
533
+ ? ariaLabelText
534
+ : undefined
523
535
  }
524
536
  // The hover card is anchored here with focusTrigger="always", which
525
537
  // attaches focus listeners but does not itself make the anchor
@@ -69,6 +69,16 @@ export interface FormatInstantOptions {
69
69
  * @default false
70
70
  */
71
71
  isTimezoneShown?: boolean;
72
+ /**
73
+ * How the zone name is spelled when a format carries one. `'short'` is the
74
+ * abbreviation ("PST"); `'long'` spells it out ("Pacific Standard Time").
75
+ * Only `'full'` honours it — it is the one format that always names its zone.
76
+ * The spelled-out form backs a relative timestamp's accessible name, where an
77
+ * abbreviation reads as an unexpanded initialism to a screen reader
78
+ * (WCAG 3.1.4); every visible surface keeps the abbreviation.
79
+ * @default 'short'
80
+ */
81
+ timeZoneNameStyle?: 'short' | 'long';
72
82
  }
73
83
 
74
84
  // =============================================================================
@@ -155,7 +165,11 @@ function getWallClock(date: Date, timeZone: string | undefined): WallClock {
155
165
  export function formatInstant(
156
166
  date: Date,
157
167
  format: InstantFormat,
158
- {timeZone, isTimezoneShown = false}: FormatInstantOptions = {},
168
+ {
169
+ timeZone,
170
+ isTimezoneShown = false,
171
+ timeZoneNameStyle = 'short',
172
+ }: FormatInstantOptions = {},
159
173
  ): string {
160
174
  const zone = timeZone === undefined ? {} : {timeZone};
161
175
  const zoneName = isTimezoneShown ? {timeZoneName: 'short' as const} : {};
@@ -164,6 +178,7 @@ export function formatInstant(
164
178
  case 'full':
165
179
  return new Intl.DateTimeFormat(undefined, {
166
180
  ...FULL_OPTIONS,
181
+ timeZoneName: timeZoneNameStyle,
167
182
  ...zone,
168
183
  }).format(date);
169
184
 
@@ -7,6 +7,11 @@ export const docs = {
7
7
  subComponentOf: 'TopNav',
8
8
  displayName: 'Top Nav Mega Menu',
9
9
  description: 'Navigation item that displays a full-width mega menu panel on hover. Uses a slots API with items and featured props. TopNavMegaMenuItem renders itself in both desktop and mobile drawer modes. Supports inline collapsible drawer via render mode context.',
10
+ // The panel anchors to the nearest <nav>; wrap the preview in a TopNav so
11
+ // it opens below the bar instead of pinning to the viewport corner (#4905).
12
+ playground: {
13
+ wrapper: {component: 'TopNav'},
14
+ },
10
15
  props: [
11
16
  {
12
17
  name: 'label',
@@ -16,6 +16,7 @@ import {TreeList} from './TreeList';
16
16
  import type {TreeListItemData} from './TreeListTypes';
17
17
  import {defineTheme} from '../theme/defineTheme';
18
18
  import {generateThemeCSS} from '../theme/generateThemeRules';
19
+ import {FOCUS_OUTLINE} from '../utils/focusOutline.stylex';
19
20
 
20
21
  function generateThemeTestCSS(theme: Parameters<typeof generateThemeCSS>[0]) {
21
22
  const {prose, component} = generateThemeCSS(theme);
@@ -299,8 +300,8 @@ describe('TreeList', () => {
299
300
  expect(root).toHaveFocus();
300
301
 
301
302
  expect(
302
- getComputedStyle(root).getPropertyValue('--_focus-outline'),
303
- ).toContain('solid');
303
+ getComputedStyle(root).getPropertyValue('--_focus-outline').trim(),
304
+ ).toBe(FOCUS_OUTLINE);
304
305
  // Mid and Leaf are DOM descendants of Root's <li> (nested <ul role="group">
305
306
  // subtrees) — their own outline var must stay unset, not inherit Root's.
306
307
  expect(getComputedStyle(mid).getPropertyValue('--_focus-outline')).toBe(
@@ -1,7 +1,5 @@
1
1
  // Copyright (c) Meta Platforms, Inc. and affiliates.
2
2
 
3
- 'use client';
4
-
5
3
  /**
6
4
  * @file VisuallyHidden.tsx
7
5
  * @input Uses React createElement/ElementType, stylex
@@ -1,7 +1,5 @@
1
1
  // Copyright (c) Meta Platforms, Inc. and affiliates.
2
2
 
3
- 'use client';
4
-
5
3
  /**
6
4
  * @file index.ts
7
5
  * @input Imports from VisuallyHidden.tsx
@@ -40,8 +40,9 @@ import type {FieldStatusVariant} from '../FieldStatus/FieldStatus';
40
40
  import type {InputStatus, InputStatusType} from '../Field/types';
41
41
  import {useTooltip} from '../Tooltip';
42
42
  import {useTranslator} from '../i18n';
43
- import {colorVars, radiusVars} from '../theme/tokens.stylex';
43
+ import {radiusVars} from '../theme/tokens.stylex';
44
44
  import {themeProps} from '../utils/themeProps';
45
+ import {focusOutlineStyles} from '../utils/focusOutline.stylex';
45
46
 
46
47
  /**
47
48
  * Maps each status type to its glyph. Shared so every input shows the same icon
@@ -82,12 +83,8 @@ const styles = stylex.create({
82
83
  color: 'inherit',
83
84
  cursor: 'pointer',
84
85
  borderRadius: radiusVars['--radius-full'],
85
- outlineWidth: {default: null, ':focus-visible': '2px'},
86
- outlineStyle: {default: null, ':focus-visible': 'solid'},
87
- outlineColor: {
88
- default: null,
89
- ':focus-visible': colorVars['--color-accent'],
90
- },
86
+ // Not the shared offset: this button sits inside the field, and measured at
87
+ // the standard 3px its ring crosses the field border.
91
88
  outlineOffset: {default: null, ':focus-visible': '2px'},
92
89
  },
93
90
  });
@@ -224,7 +221,10 @@ export function useInputStatusIcon({
224
221
  aria-describedby={tooltip.describedBy}
225
222
  onClick={handleButtonClick}
226
223
  onBlur={handleButtonBlur}
227
- {...stylex.props(styles.statusButton)}>
224
+ {...stylex.props(
225
+ focusOutlineStyles.focusVisible,
226
+ styles.statusButton,
227
+ )}>
228
228
  {icon}
229
229
  </button>
230
230
  {tooltip.renderTooltip(status.message)}