@astryxdesign/core 0.7.3-canary.f94e962 → 0.7.3

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 (133) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/dist/BottomSheet/BottomSheet.d.ts.map +1 -1
  3. package/dist/BottomSheet/BottomSheet.js +8 -4
  4. package/dist/BottomSheet/BottomSheetSwitcher.d.ts.map +1 -1
  5. package/dist/BottomSheet/BottomSheetSwitcher.js +7 -0
  6. package/dist/Button/Button.d.ts.map +1 -1
  7. package/dist/Button/Button.js +15 -2
  8. package/dist/Calendar/Calendar.js +3 -2
  9. package/dist/Chat/ChatComposer.d.ts.map +1 -1
  10. package/dist/Chat/ChatComposer.js +25 -2
  11. package/dist/Chat/ChatSystemMessage.d.ts +4 -0
  12. package/dist/Chat/ChatSystemMessage.d.ts.map +1 -1
  13. package/dist/Chat/ChatSystemMessage.js +4 -2
  14. package/dist/CodeBlock/CodeBlock.d.ts.map +1 -1
  15. package/dist/CodeBlock/CodeBlock.js +11 -1
  16. package/dist/DateRangeInput/DateRangeInput.d.ts +5 -5
  17. package/dist/DateRangeInput/DateRangeInput.d.ts.map +1 -1
  18. package/dist/DateRangeInput/DateRangeInput.js +27 -18
  19. package/dist/DateTimeInput/DateTimeInput.d.ts +5 -5
  20. package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
  21. package/dist/DateTimeInput/DateTimeInput.js +25 -27
  22. package/dist/DateTimeInput/NativeDateSegment.d.ts +3 -2
  23. package/dist/DateTimeInput/NativeDateSegment.d.ts.map +1 -1
  24. package/dist/DateTimeInput/NativeDateSegment.js +5 -5
  25. package/dist/DateTimeInput/NativeTimeSegment.d.ts +3 -2
  26. package/dist/DateTimeInput/NativeTimeSegment.d.ts.map +1 -1
  27. package/dist/DateTimeInput/NativeTimeSegment.js +5 -5
  28. package/dist/DateTimeInput/TouchDateTimeField.d.ts.map +1 -1
  29. package/dist/DateTimeInput/TouchDateTimeField.js +12 -9
  30. package/dist/Dialog/Dialog.d.ts.map +1 -1
  31. package/dist/Dialog/Dialog.js +9 -4
  32. package/dist/DropdownMenu/DropdownMenuRadioGroup.d.ts +9 -5
  33. package/dist/DropdownMenu/DropdownMenuRadioGroup.d.ts.map +1 -1
  34. package/dist/DropdownMenu/DropdownMenuRadioGroup.js +2 -0
  35. package/dist/Markdown/Markdown.d.ts.map +1 -1
  36. package/dist/Markdown/Markdown.js +48 -15
  37. package/dist/MultiSelector/MultiSelector.d.ts +4 -4
  38. package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
  39. package/dist/MultiSelector/MultiSelector.disabledFocus.a11y.chromium.spec.d.ts +2 -0
  40. package/dist/MultiSelector/MultiSelector.disabledFocus.a11y.chromium.spec.d.ts.map +1 -0
  41. package/dist/MultiSelector/MultiSelector.disabledFocus.a11y.chromium.spec.js +48 -0
  42. package/dist/MultiSelector/MultiSelector.js +25 -18
  43. package/dist/Pagination/Pagination.d.ts.map +1 -1
  44. package/dist/Pagination/Pagination.js +5 -2
  45. package/dist/Switch/Switch.d.ts.map +1 -1
  46. package/dist/Switch/Switch.js +13 -3
  47. package/dist/TimeInput/TimeInput.d.ts +5 -5
  48. package/dist/TimeInput/TimeInput.d.ts.map +1 -1
  49. package/dist/TimeInput/TimeInput.js +37 -20
  50. package/dist/TimeInput/TouchTimeField.d.ts.map +1 -1
  51. package/dist/TimeInput/TouchTimeField.js +17 -6
  52. package/dist/Timestamp/Timestamp.d.ts.map +1 -1
  53. package/dist/Timestamp/Timestamp.js +41 -8
  54. package/dist/TreeList/TreeListItem.d.ts.map +1 -1
  55. package/dist/TreeList/TreeListItem.js +8 -3
  56. package/dist/hooks/useGridFocus.d.ts.map +1 -1
  57. package/dist/hooks/useGridFocus.js +10 -1
  58. package/package.json +4 -4
  59. package/scripts/agent-doc-state.mjs +24 -2
  60. package/src/BottomSheet/BottomSheet.spec.md +26 -53
  61. package/src/BottomSheet/BottomSheet.test.tsx +29 -0
  62. package/src/BottomSheet/BottomSheet.tsx +8 -4
  63. package/src/BottomSheet/BottomSheetSwitcher.spec.md +45 -48
  64. package/src/BottomSheet/BottomSheetSwitcher.test.tsx +26 -0
  65. package/src/BottomSheet/BottomSheetSwitcher.tsx +6 -0
  66. package/src/Button/Button.test.tsx +13 -2
  67. package/src/Button/Button.tsx +23 -2
  68. package/src/Button/__tests__/Button.a11y.states.ts +4 -3
  69. package/src/Calendar/Calendar.test.tsx +68 -24
  70. package/src/Calendar/Calendar.tsx +2 -1
  71. package/src/Carousel/Carousel.spec.md +146 -0
  72. package/src/Chat/ChatComposer.spec.md +26 -26
  73. package/src/Chat/ChatComposer.test.tsx +58 -2
  74. package/src/Chat/ChatComposer.tsx +25 -1
  75. package/src/Chat/ChatSystemMessage.doc.mjs +1 -1
  76. package/src/Chat/ChatSystemMessage.test.tsx +25 -3
  77. package/src/Chat/ChatSystemMessage.tsx +6 -4
  78. package/src/Chat/__tests__/ChatSystemMessage.a11y.chromium.spec.ts +8 -9
  79. package/src/CheckboxList/__tests__/CheckboxList.a11y.chromium.spec.ts +7 -6
  80. package/src/CodeBlock/CodeBlock.highlightStyles.test.tsx +87 -0
  81. package/src/CodeBlock/CodeBlock.tsx +11 -1
  82. package/src/DateRangeInput/DateRangeInput.doc.mjs +6 -5
  83. package/src/DateRangeInput/DateRangeInput.test.tsx +82 -16
  84. package/src/DateRangeInput/DateRangeInput.tsx +40 -20
  85. package/src/DateTimeInput/DateTimeInput.doc.mjs +9 -8
  86. package/src/DateTimeInput/DateTimeInput.test.tsx +126 -11
  87. package/src/DateTimeInput/DateTimeInput.tsx +42 -26
  88. package/src/DateTimeInput/DateTimeInputTouch.test.tsx +24 -0
  89. package/src/DateTimeInput/NativeDateSegment.tsx +9 -6
  90. package/src/DateTimeInput/NativePickerSegments.test.tsx +31 -0
  91. package/src/DateTimeInput/NativeTimeSegment.tsx +9 -6
  92. package/src/DateTimeInput/TouchDateTimeField.tsx +22 -7
  93. package/src/Dialog/Dialog.test.tsx +39 -0
  94. package/src/Dialog/Dialog.tsx +9 -4
  95. package/src/DropdownMenu/DropdownMenuItem.doc.mjs +66 -7
  96. package/src/DropdownMenu/DropdownMenuItem.spec.md +209 -0
  97. package/src/DropdownMenu/DropdownMenuRadioGroup.doc.mjs +62 -7
  98. package/src/DropdownMenu/DropdownMenuRadioGroup.spec.md +190 -0
  99. package/src/DropdownMenu/DropdownMenuRadioGroup.tsx +10 -4
  100. package/src/DropdownMenu/DropdownMenuSelectable.test.tsx +93 -0
  101. package/src/DropdownMenu/__tests__/DropdownMenuItemAudit.a11y.chromium.spec.ts +366 -0
  102. package/src/DropdownMenu/__tests__/DropdownMenuRadioGroupAudit.a11y.chromium.spec.ts +327 -0
  103. package/src/FieldStatus/FieldStatus.spec.md +4 -4
  104. package/src/FieldStatus/__tests__/StatusMessage.a11y.renders.tsx +0 -18
  105. package/src/FieldStatus/__tests__/StatusMessage.a11y.states.ts +3 -18
  106. package/src/FieldStatus/__tests__/StatusMessage.a11y.test.tsx +1 -2
  107. package/src/Markdown/Markdown.helpers.perf.test.ts +25 -19
  108. package/src/Markdown/Markdown.rerender.test.tsx +143 -0
  109. package/src/Markdown/Markdown.tsx +89 -35
  110. package/src/MultiSelector/MultiSelector.disabledFocus.a11y.chromium.spec.ts +71 -0
  111. package/src/MultiSelector/MultiSelector.doc.mjs +5 -5
  112. package/src/MultiSelector/MultiSelector.focusableDisabled.test.tsx +121 -0
  113. package/src/MultiSelector/MultiSelector.test.tsx +49 -7
  114. package/src/MultiSelector/MultiSelector.tsx +53 -29
  115. package/src/Pagination/Pagination.test.tsx +5 -1
  116. package/src/Pagination/Pagination.tsx +3 -1
  117. package/src/Switch/Switch.test.tsx +56 -0
  118. package/src/Switch/Switch.tsx +13 -2
  119. package/src/Table/plugins/columnResize/useTableColumnResize.spec.md +11 -11
  120. package/src/Table/plugins/rowStatus/useTableRowStatus.spec.md +4 -3
  121. package/src/TimeInput/NativeTimeInput.test.tsx +25 -1
  122. package/src/TimeInput/Presentation.test.tsx +23 -0
  123. package/src/TimeInput/TimeInput.doc.mjs +8 -7
  124. package/src/TimeInput/TimeInput.test.tsx +119 -7
  125. package/src/TimeInput/TimeInput.tsx +51 -23
  126. package/src/TimeInput/TouchTimeField.tsx +27 -5
  127. package/src/Timestamp/Timestamp.doc.mjs +1 -1
  128. package/src/Timestamp/Timestamp.test.tsx +168 -0
  129. package/src/Timestamp/Timestamp.tsx +62 -11
  130. package/src/TreeList/TreeList.test.tsx +28 -0
  131. package/src/TreeList/TreeListItem.tsx +6 -2
  132. package/src/__tests__/DisabledTriggerFocus.a11y.chromium.spec.ts +86 -0
  133. package/src/hooks/useGridFocus.ts +16 -1
@@ -17,7 +17,14 @@
17
17
  * - /packages/cli/assets/templates/blocks/components/Timestamp/ (showcase blocks)
18
18
  */
19
19
 
20
- import {lazy, Suspense, useEffect, useRef, useState} from 'react';
20
+ import {
21
+ lazy,
22
+ Suspense,
23
+ useEffect,
24
+ useRef,
25
+ useState,
26
+ useSyncExternalStore,
27
+ } from 'react';
21
28
  import * as stylex from '@stylexjs/stylex';
22
29
  import {Text} from '../Text';
23
30
  import type {TextType, TextSize, TextColor, TextWeight} from '../theme/types';
@@ -215,6 +222,13 @@ function getLiveInterval(diffSeconds: number): number {
215
222
  return 300_000; // every 5 minutes
216
223
  }
217
224
 
225
+ // Whether this render may read the current time. Server renders (including
226
+ // static prerenders) and the hydration pass read `false`, so the markup never
227
+ // depends on when it was rendered; client renders read `true`.
228
+ const subscribeToNothing = () => () => {};
229
+ const canReadClockOnClient = () => true;
230
+ const canReadClockOnServer = () => false;
231
+
218
232
  /** Whether a format is non-relative (i.e. shows a fixed date/time). */
219
233
  function isAbsoluteFormat(
220
234
  format: TimestampFormat,
@@ -279,7 +293,29 @@ export function Timestamp({
279
293
  const locale = useLocale();
280
294
  const timeRef = useRef<HTMLTimeElement>(null);
281
295
  const mergedTimeRef = useMergedRefs(ref, timeRef);
282
- const [now, setNow] = useState(() => new Date());
296
+ // Only relative and auto formats depend on the current time. Absolute
297
+ // formats never read the clock, so they render identically on the server,
298
+ // in a static prerender, and on the client. A clock-dependent format reads
299
+ // it on the client only: server renders and the hydration pass show the
300
+ // full date and time in UTC, and the client switches to the relative phrase
301
+ // right after hydration.
302
+ const needsClock = !isAbsoluteFormat(format);
303
+ const canReadClock = useSyncExternalStore(
304
+ subscribeToNothing,
305
+ canReadClockOnClient,
306
+ canReadClockOnServer,
307
+ );
308
+ const [now, setNow] = useState<Date | null>(() =>
309
+ needsClock && canReadClock ? new Date() : null,
310
+ );
311
+ // Read the clock during render once it may be read: after hydration (the
312
+ // store re-renders with its client value) and when `format` switches from
313
+ // an absolute value to a clock-dependent one, so the placeholder never
314
+ // commits on the client.
315
+ if (needsClock && canReadClock && now === null) {
316
+ // eslint-disable-next-line @eslint-react/purity -- seeds clock state once, like the initializer; never runs on the server or while hydrating
317
+ setNow(new Date());
318
+ }
283
319
 
284
320
  const date = parseValue(value);
285
321
  // An unparseable value (a malformed date string, or a NaN timestamp from
@@ -289,25 +325,40 @@ export function Timestamp({
289
325
  const isValidDate = !Number.isNaN(date.getTime());
290
326
  const isoString = isValidDate ? date.toISOString() : '';
291
327
 
292
- // Determine effective format
293
- const diffSeconds = Math.round((now.getTime() - date.getTime()) / 1000);
294
- const effectiveFormat: TimestampFormat =
295
- format === 'auto'
328
+ // Determine effective format. Until the clock is read, a clock-dependent
329
+ // format renders as the full date and time.
330
+ const isClockPending = needsClock && now === null;
331
+ const diffSeconds =
332
+ now === null ? 0 : Math.round((now.getTime() - date.getTime()) / 1000);
333
+ const effectiveFormat: TimestampFormat = isClockPending
334
+ ? 'date_time'
335
+ : format === 'auto'
296
336
  ? Math.abs(diffSeconds) <= autoThreshold
297
337
  ? 'relative'
298
338
  : 'date_time'
299
339
  : format;
300
340
 
301
- // Format the display text. No time zone is passed: the visible text always
302
- // reads in the viewer's own zone, and only the tooltip names others.
341
+ // Format the display text in the viewer's own zone; only the tooltip names
342
+ // others. The one exception is the pre-hydration placeholder, written in
343
+ // UTC with its label so every zone produces the same markup.
303
344
  const displayText = !isValidDate
304
345
  ? ''
305
346
  : effectiveFormat === 'relative'
306
- ? formatRelativeTime(date, now, locale, 'long')
347
+ ? formatRelativeTime(date, now ?? date, locale, 'long')
307
348
  : effectiveFormat === 'relative_short'
308
- ? formatRelativeTime(date, now, locale, 'narrow')
349
+ ? formatRelativeTime(date, now ?? date, locale, 'narrow')
309
350
  : isAbsoluteFormat(effectiveFormat)
310
- ? formatInstant(date, effectiveFormat, locale, {isTimezoneShown})
351
+ ? formatInstant(
352
+ date,
353
+ effectiveFormat,
354
+ locale,
355
+ // The pre-hydration placeholder is written in UTC, labelled, so
356
+ // the server and the hydrating client produce the same text in
357
+ // every time zone.
358
+ isClockPending
359
+ ? {timeZone: 'UTC', isTimezoneShown: true}
360
+ : {isTimezoneShown},
361
+ )
311
362
  : '';
312
363
 
313
364
  // Full absolute text for the tooltip (visible — keeps the compact timezone
@@ -942,6 +942,34 @@ describe('TreeList', () => {
942
942
  expect(screen.queryByText('File')).not.toBeInTheDocument();
943
943
  });
944
944
 
945
+ it("exposes a disabled row's toggle and action as aria-disabled and keeps them inert", () => {
946
+ const onClick = vi.fn();
947
+ const {container} = render(
948
+ <TreeList
949
+ items={underRoot([
950
+ {id: 'run', label: 'Run', onClick, isDisabled: true},
951
+ {...folder, isDisabled: true},
952
+ ])}
953
+ />,
954
+ );
955
+ // spec:AST-085 FR1/FR2: no native disabled anywhere, and a click
956
+ // dispatched straight on the inner controls does nothing.
957
+ expect(container.querySelector('[disabled]')).toBeNull();
958
+ const toggle = container.querySelector<HTMLElement>(
959
+ '[data-tree-toggle][aria-disabled="true"]',
960
+ )!;
961
+ expect(toggle).not.toBeNull();
962
+ toggle.click();
963
+ fireEvent.keyDown(toggle, {key: 'Enter'});
964
+ expect(screen.queryByText('File')).not.toBeInTheDocument();
965
+ const action = Array.from(container.querySelectorAll('button')).find(
966
+ button => button.textContent?.includes('Run'),
967
+ )!;
968
+ expect(action).toHaveAttribute('aria-disabled', 'true');
969
+ action.click();
970
+ expect(onClick).not.toHaveBeenCalled();
971
+ });
972
+
945
973
  it('leaves a role-based control in start or end content to itself', () => {
946
974
  // Native and role-based controls alike keep their own activation,
947
975
  // the same rule link rows get from the shared enlarged-surface hook.
@@ -38,6 +38,7 @@ import {TreeListBranches} from './TreeListBranches';
38
38
  import type {TreeListDensity, TreeListVariant} from './TreeListTypes';
39
39
  import {themeProps} from '../utils/themeProps';
40
40
  import {useTranslator} from '../i18n';
41
+ import {disabledInteractionProps} from '../utils/disabledInteraction';
41
42
 
42
43
  // =============================================================================
43
44
  // Styles
@@ -552,11 +553,13 @@ export function TreeListItem({
552
553
  // Stable identity for TreeList's activateItem selector — do not
553
554
  // remove without also updating TreeList.tsx.
554
555
  data-tree-toggle=""
555
- disabled={isDisabled}
556
+ // Disabled stays focusable (spec:AST-085 FR1) but inert (FR2).
557
+ aria-disabled={isDisabled || undefined}
556
558
  // Roving tabindex lives on the treeitem row; the chevron toggle is not
557
559
  // a separate tab stop. Row-level Enter/Space forwards to this button.
558
560
  tabIndex={-1}
559
561
  onClick={handleToggle}
562
+ {...(isDisabled ? disabledInteractionProps() : null)}
560
563
  {...mergeProps(
561
564
  // Stable theme target for the expand/collapse control. `data-tree-toggle`
562
565
  // stays as the functional activation hook; this adds a themeable
@@ -608,7 +611,8 @@ export function TreeListItem({
608
611
  <button
609
612
  type="button"
610
613
  onClick={onClick}
611
- disabled={isDisabled}
614
+ aria-disabled={isDisabled || undefined}
615
+ {...(isDisabled ? disabledInteractionProps() : null)}
612
616
  aria-labelledby={labelId}
613
617
  aria-describedby={description != null ? descriptionId : undefined}
614
618
  // Roving tabindex lives on the treeitem row; inner action is not a
@@ -0,0 +1,86 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file DisabledTriggerFocus.a11y.chromium.spec.ts
5
+ * @input The `a11y/Disabled trigger focus` stories in a built Storybook.
6
+ * @output Real-engine proof of `spec:AST-085` FR4 for Selector and
7
+ * ComplexSelector: a trigger disabled while it holds keyboard focus keeps
8
+ * focus and reports itself disabled.
9
+ * @position Run in `pr-a11y`; a DOM emulator does not move focus off an
10
+ * element that becomes disabled, so it cannot show the loss.
11
+ */
12
+
13
+ import {expect, test} from '@playwright/test';
14
+ import {
15
+ DEFAULT_STORYBOOK_DIR,
16
+ serveStorybook,
17
+ type StaticServer,
18
+ } from '@astryxdesign/a11y-spec/storybook';
19
+
20
+ let storybook: StaticServer;
21
+
22
+ test.beforeAll(async () => {
23
+ storybook = await serveStorybook(
24
+ process.env.ASTRYX_STORYBOOK_DIR ?? DEFAULT_STORYBOOK_DIR,
25
+ );
26
+ });
27
+
28
+ test.afterAll(async () => {
29
+ await storybook?.close();
30
+ });
31
+
32
+ const CASES = [
33
+ {
34
+ storyId: 'a11y-disabled-trigger-focus--selector-trigger',
35
+ role: 'combobox',
36
+ name: 'Fruit',
37
+ },
38
+ {
39
+ storyId: 'a11y-disabled-trigger-focus--complex-selector-trigger',
40
+ role: 'button',
41
+ name: 'View options',
42
+ },
43
+ ] as const;
44
+
45
+ for (const {storyId, role, name} of CASES) {
46
+ test(`${name}: a focused trigger keeps focus when it becomes disabled`, async ({
47
+ page,
48
+ }) => {
49
+ await page.goto(
50
+ `${storybook.origin}/iframe.html?id=${storyId}&viewMode=story`,
51
+ );
52
+ const trigger = page.getByRole(role, {name});
53
+ await trigger.focus();
54
+ await expect(trigger).toBeFocused();
55
+
56
+ await page.evaluate(id => {
57
+ const channel = (
58
+ window as unknown as {
59
+ __STORYBOOK_ADDONS_CHANNEL__?: {
60
+ emit: (event: string, payload: unknown) => void;
61
+ };
62
+ }
63
+ ).__STORYBOOK_ADDONS_CHANNEL__;
64
+ if (channel == null) {
65
+ throw new Error('Storybook channel is unavailable');
66
+ }
67
+ channel.emit('updateStoryArgs', {
68
+ storyId: id,
69
+ updatedArgs: {isDisabled: true},
70
+ });
71
+ }, storyId);
72
+
73
+ // Wait for the disabled re-render in either form, so focus is checked
74
+ // after it lands.
75
+ await expect
76
+ .poll(async () =>
77
+ trigger.evaluate(
78
+ el =>
79
+ el.hasAttribute('disabled') ||
80
+ el.getAttribute('aria-disabled') === 'true',
81
+ ),
82
+ )
83
+ .toBe(true);
84
+ await expect(trigger).toBeFocused();
85
+ });
86
+ }
@@ -298,7 +298,22 @@ export function useGridFocus<T extends HTMLElement = HTMLElement>(
298
298
  for (const el of targets) {
299
299
  setTabIndex(el, el === tabbable ? 0 : -1);
300
300
  }
301
- }, [getFocusTargets, setTabIndex]);
301
+ // A cell that stopped being focusable (an aria-disabled day, for example,
302
+ // which stays reachable by focus) can still hold the stop from earlier
303
+ // navigation; it never stays tabbable, so the grid keeps one Tab stop.
304
+ for (const cell of getCells()) {
305
+ const el = cellFocusable(cell) ? null : resolveFocusTarget(cell);
306
+ if (el != null) {
307
+ setTabIndex(el, -1);
308
+ }
309
+ }
310
+ }, [
311
+ getFocusTargets,
312
+ getCells,
313
+ cellFocusable,
314
+ resolveFocusTarget,
315
+ setTabIndex,
316
+ ]);
302
317
 
303
318
  // Keep the tab stop valid across renders (cells added/removed, focusability
304
319
  // toggled). Runs after every commit but only when roving tabindex is on.