@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
@@ -26,6 +26,7 @@
26
26
  import React, {
27
27
  useState,
28
28
  useCallback,
29
+ useEffect,
29
30
  useRef,
30
31
  useMemo,
31
32
  type ReactNode,
@@ -53,6 +54,7 @@ import type {ChatComposerInputControl} from './ChatContext';
53
54
  import {ChatSendButton} from './ChatSendButton';
54
55
  import {themeProps} from '../utils/themeProps';
55
56
  import {useTranslator} from '../i18n';
57
+ import {useAnnounce} from '../hooks/useAnnounce';
56
58
  import {focusOutlineStyles} from '../utils/focusOutline.stylex';
57
59
  import {
58
60
  getInteractionModality,
@@ -455,9 +457,31 @@ export function ChatComposer(props: ChatComposerProps) {
455
457
  [],
456
458
  );
457
459
 
460
+ // The status bar mounts together with its message, and a live region born
461
+ // holding its text is not reliably announced, so the message goes through
462
+ // the shared channel instead: errors assertively, warnings politely
463
+ // (spec:AST-086 FR2, FR5). The ref keeps a rerender or a StrictMode effect
464
+ // replay from repeating it; clearing the status re-arms it, so the same
465
+ // message shown again is a new occasion (FR6).
466
+ const announce = useAnnounce();
467
+ const statusType = status?.type;
468
+ const statusMessage = status?.message;
469
+ const announcedStatusRef = useRef<string | null>(null);
470
+ useEffect(() => {
471
+ if (!statusMessage) {
472
+ announcedStatusRef.current = null;
473
+ return;
474
+ }
475
+ const occasion = `${statusType}\u0000${statusMessage}`;
476
+ if (announcedStatusRef.current === occasion) {
477
+ return;
478
+ }
479
+ announcedStatusRef.current = occasion;
480
+ announce(statusMessage, statusType === 'error' ? 'assertive' : 'polite');
481
+ }, [announce, statusType, statusMessage]);
482
+
458
483
  const statusEl = status ? (
459
484
  <div
460
- role={status.type === 'error' ? 'alert' : 'status'}
461
485
  {...stylex.props(
462
486
  styles.statusBar,
463
487
  statusPosition === 'top' ? styles.statusTop : styles.statusBottom,
@@ -52,7 +52,7 @@ export const docs = {
52
52
  },
53
53
  usage: {
54
54
  description:
55
- 'Use ChatSystemMessage for concise, non-sender content inside a chat transcript. Choose the default variant for factual status notices and the divider variant for date or section breaks. Long default content wraps within the available width. The component exposes status semantics and the divider branch includes a labelled separator.',
55
+ 'Use ChatSystemMessage for concise, non-sender content inside a chat transcript. Choose the default variant for factual status notices and the divider variant for date or section breaks. Long default content wraps within the available width. It carries no live role: inside a ChatMessageList, the log announces new notices. The divider branch includes a labelled separator.',
56
56
  bestPractices: [
57
57
  {
58
58
  guidance: true,
@@ -4,6 +4,7 @@ import {createRef, Fragment} from 'react';
4
4
  import {describe, expect, it} from 'vitest';
5
5
  import {render, screen} from '@testing-library/react';
6
6
  import * as stylex from '@stylexjs/stylex';
7
+ import {ChatMessageList} from './ChatMessageList';
7
8
  import {ChatSystemMessage} from './ChatSystemMessage';
8
9
 
9
10
  const xstyleProbe = stylex.create({
@@ -100,7 +101,7 @@ describe('ChatSystemMessage', () => {
100
101
  expect(root).toHaveStyle({outlineOffset: '3px'});
101
102
  expectProbeClasses(root);
102
103
  expect(root).toHaveAttribute('aria-label', `${variant} system message`);
103
- expect(root).toHaveAttribute('role', 'status');
104
+ expect(root).toHaveAttribute('role', 'note');
104
105
  expect(root).toHaveAttribute('data-variant', variant);
105
106
  },
106
107
  );
@@ -125,7 +126,7 @@ describe('ChatSystemMessage', () => {
125
126
  </ChatSystemMessage>,
126
127
  );
127
128
  const root = screen.getByTestId('empty-partition');
128
- expect(root).toHaveAttribute('role', 'status');
129
+ expect(root).not.toHaveAttribute('role');
129
130
  expect(root).toHaveTextContent(text);
130
131
  });
131
132
 
@@ -148,7 +149,7 @@ describe('ChatSystemMessage', () => {
148
149
  </ChatSystemMessage>,
149
150
  );
150
151
  const root = screen.getByTestId('empty-partition');
151
- expect(root).toHaveAttribute('role', 'status');
152
+ expect(root).not.toHaveAttribute('role');
152
153
  expect(root).toContainElement(screen.getByRole('separator'));
153
154
  });
154
155
 
@@ -164,4 +165,25 @@ describe('ChatSystemMessage', () => {
164
165
  expect(separator).toHaveAccessibleName('0');
165
166
  expect(separator).toHaveAttribute('aria-labelledby');
166
167
  });
168
+
169
+ it.each(['default', 'divider'] as const)(
170
+ 'leaves a %s row inside a chat log to the log (spec:AST-086 FR4)',
171
+ variant => {
172
+ render(
173
+ <ChatMessageList>
174
+ <ChatSystemMessage variant={variant} data-testid="notice">
175
+ Ana joined
176
+ </ChatSystemMessage>
177
+ </ChatMessageList>,
178
+ );
179
+ const notice = screen.getByTestId('notice');
180
+ expect(notice.closest('[role="log"]')).not.toBeNull();
181
+ for (const el of [notice, ...notice.querySelectorAll('*')]) {
182
+ expect(el).not.toHaveAttribute('aria-live');
183
+ expect(['status', 'alert', 'log']).not.toContain(
184
+ el.getAttribute('role'),
185
+ );
186
+ }
187
+ },
188
+ );
167
189
  });
@@ -104,6 +104,10 @@ const styles = stylex.create({
104
104
  * "user joined", status changes. Supports a divider variant with horizontal
105
105
  * lines on each side of the text.
106
106
  *
107
+ * It carries no live role: a notice added to a `ChatMessageList` is announced
108
+ * by the list's log, which owns content added inside it (`spec:AST-086` FR4),
109
+ * and a date or divider row is ordinary transcript content.
110
+ *
107
111
  * @example
108
112
  * ```
109
113
  * <ChatSystemMessage>Conversation started</ChatSystemMessage>
@@ -131,8 +135,7 @@ export function ChatSystemMessage({
131
135
  className,
132
136
  styleProp,
133
137
  )}
134
- {...rest}
135
- role="status">
138
+ {...rest}>
136
139
  <Divider label={children} />
137
140
  </div>
138
141
  );
@@ -147,8 +150,7 @@ export function ChatSystemMessage({
147
150
  className,
148
151
  styleProp,
149
152
  )}
150
- {...rest}
151
- role="status">
153
+ {...rest}>
152
154
  <span {...stylex.props(styles.content)}>
153
155
  {icon != null && <span {...stylex.props(styles.icon)}>{icon}</span>}
154
156
  {children}
@@ -458,7 +458,8 @@ async function capture(
458
458
  forcedColors: false,
459
459
  coarsePointer: options.coarsePointer ?? false,
460
460
  touchPoints: options.touchPoints ?? false,
461
- role: 'status',
461
+ // No live role of its own: the chat log delivers it (spec:AST-086 FR4).
462
+ role: null,
462
463
  variant: auditCase.variant,
463
464
  text: auditCase.text,
464
465
  separatorCount: auditCase.variant === 'divider' ? 1 : 0,
@@ -488,7 +489,7 @@ async function capture(
488
489
  check(actual.selectorCount === 1, 'expected exactly one audit subject');
489
490
  check(actual.visible, 'audit subject has no visible geometry');
490
491
  check(actual.text === expected.text, 'visible content drifted');
491
- check(actual.role === expected.role, 'status role drifted');
492
+ check(actual.role === expected.role, 'root role drifted');
492
493
  check(actual.variant === expected.variant, 'variant reflection drifted');
493
494
  check(
494
495
  actual.separatorCount === expected.separatorCount,
@@ -915,11 +916,11 @@ test.afterAll(async () => {
915
916
  },
916
917
  emptyContentPartitions: {
917
918
  emptyString:
918
- 'Focused unit evidence: the default variant keeps role=status and renders the content unchanged (empty). In the divider variant, the status root and separator remain; no visible label or accessible name is produced.',
919
+ 'Focused unit evidence: the default variant has no root role and renders the content unchanged (empty). In the divider variant, the root and separator remain; no visible label or accessible name is produced.',
919
920
  numericZero:
920
- 'Focused unit evidence: the default variant keeps role=status and renders the content unchanged as one 0. In the divider variant, the status root and separator remain; Divider paints two zero text nodes (textContent="00") with no accessible name.',
921
+ 'Focused unit evidence: the default variant has no root role and renders the content unchanged as one 0. In the divider variant, the root and separator remain; Divider paints two zero text nodes (textContent="00") with no accessible name.',
921
922
  emptyFragment:
922
- 'Focused unit evidence: the default variant keeps role=status and renders the content unchanged (empty). In the divider variant, the status root and separator remain; no visible label or accessible name is produced.',
923
+ 'Focused unit evidence: the default variant has no root role and renders the content unchanged (empty). In the divider variant, the root and separator remain; no visible label or accessible name is produced.',
923
924
  },
924
925
  sharedAdvisories: {
925
926
  dividerFalseyLabel:
@@ -933,9 +934,7 @@ test.afterAll(async () => {
933
934
  forcedColors:
934
935
  'Forced-colors rendering was not exercised. The default row makes no support claim, and the disappearing Divider rule is a shared component:Divider advisory.',
935
936
  assistiveTechnology:
936
- 'Browser role exposure is measured, but spoken announcement timing and output were not verified with real assistive technology.',
937
- nestedLiveRegions:
938
- 'The nested-log frame records role=status inside ChatMessageList role=log aria-live=polite, but double-announcement behavior is unverified under spec:AST-009/FR4 and FR10.',
937
+ 'Browser role exposure is measured; spoken output was not recorded with real assistive technology, which spec:AST-009 does not require for routing claims.',
939
938
  },
940
939
  stateVisualConformance: {
941
940
  default: ['text', 'text-with-icon', 'wrapped-long-content'],
@@ -943,7 +942,7 @@ test.afterAll(async () => {
943
942
  'labelled-separator',
944
943
  'unresolved-divider-icon-observation',
945
944
  ],
946
- nestedLiveLog: ['status-inside-polite-log-unverified'],
945
+ nestedLiveLog: ['no-live-role-inside-polite-log'],
947
946
  colorModes: ['light', 'dark'],
948
947
  narrowCoarsePointer: [
949
948
  'default-with-icon',
@@ -10,7 +10,8 @@
10
10
  * indicators, row paint, hit targets, wrapping, list structure) are recorded
11
11
  * for the audit and never gate. Only frames that prove a shipped remediation
12
12
  * gate on its outcome. The select-all CLI block renders through its own
13
- * story, and its source blob is bound in the manifest.
13
+ * story, and its source blob is bound in the manifest. Disabled rows expect
14
+ * focusable aria-disabled controls under AST-085 FR1 and FR7.
14
15
  */
15
16
 
16
17
  import {createHash} from 'node:crypto';
@@ -271,9 +272,9 @@ const CASES: AuditCase[] = [
271
272
  expect: {
272
273
  groupName: GROUP,
273
274
  rows: [
274
- {checked: true, disabled: 'native'},
275
- {checked: false, disabled: 'native'},
276
- {checked: false, disabled: 'native'},
275
+ {checked: true, disabled: 'focusable'},
276
+ {checked: false, disabled: 'focusable'},
277
+ {checked: false, disabled: 'focusable'},
277
278
  ],
278
279
  },
279
280
  },
@@ -285,7 +286,7 @@ const CASES: AuditCase[] = [
285
286
  groupName: GROUP,
286
287
  rows: [
287
288
  {checked: false, disabled: false},
288
- {checked: false, disabled: 'native'},
289
+ {checked: false, disabled: 'focusable'},
289
290
  {checked: false, disabled: false},
290
291
  ],
291
292
  },
@@ -471,7 +472,7 @@ const CASES: AuditCase[] = [
471
472
  rows: [
472
473
  {checked: true},
473
474
  {checked: false},
474
- {checked: false, disabled: 'native'},
475
+ {checked: false, disabled: 'focusable'},
475
476
  ],
476
477
  },
477
478
  },
@@ -0,0 +1,87 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file CodeBlock.highlightStyles.test.tsx
5
+ * @input Uses vitest, @testing-library/react, CodeBlock, a mocked CSS Custom Highlight API
6
+ * @output Tests that the ::highlight() rules exist before a commit's layout effects run
7
+ * @position Performance regression evidence. Rules inserted after layout
8
+ * effects make the browser restyle the whole document a second time once
9
+ * any layout effect has read style, so their insertion point is pinned here.
10
+ */
11
+
12
+ import {useLayoutEffect} from 'react';
13
+ import {cleanup, render} from '@testing-library/react';
14
+ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
15
+
16
+ class MockHighlight extends Set<Range> {}
17
+ const realCss = globalThis.CSS;
18
+
19
+ const STYLE_SELECTOR = 'style[data-astryx-highlight-styles]';
20
+
21
+ beforeEach(() => {
22
+ // A fresh module instance, so the once-per-document insertion flag starts
23
+ // unset in every test.
24
+ vi.resetModules();
25
+ document.head.querySelectorAll(STYLE_SELECTOR).forEach(el => el.remove());
26
+ const highlights = new Map<string, MockHighlight>();
27
+ globalThis.CSS = {
28
+ escape: realCss.escape,
29
+ highlights: {
30
+ get: (name: string) => highlights.get(name),
31
+ set: (name: string, h: MockHighlight) => highlights.set(name, h),
32
+ } as unknown as HighlightRegistry,
33
+ } as typeof CSS;
34
+ vi.stubGlobal('Highlight', MockHighlight);
35
+ });
36
+
37
+ afterEach(() => {
38
+ cleanup();
39
+ globalThis.CSS = realCss;
40
+ vi.unstubAllGlobals();
41
+ });
42
+
43
+ describe('CodeBlock ::highlight() rules', () => {
44
+ it('are in the document before layout effects in the same commit run', async () => {
45
+ const {CodeBlock} = await import('./CodeBlock');
46
+ const seen: boolean[] = [];
47
+ function LayoutReader() {
48
+ useLayoutEffect(() => {
49
+ seen.push(document.head.querySelector(STYLE_SELECTOR) != null);
50
+ }, []);
51
+ return null;
52
+ }
53
+ render(
54
+ <>
55
+ <CodeBlock code="const a = 1;" language="ts" highlightMode="ranges" />
56
+ <LayoutReader />
57
+ </>,
58
+ );
59
+ expect(seen).toEqual([true]);
60
+ });
61
+
62
+ it('are inserted once for many code blocks', async () => {
63
+ const {CodeBlock} = await import('./CodeBlock');
64
+ render(
65
+ <>
66
+ {Array.from({length: 20}, (_, i) => (
67
+ <CodeBlock
68
+ key={i}
69
+ code={`const v${i} = ${i};`}
70
+ language="ts"
71
+ highlightMode="ranges"
72
+ />
73
+ ))}
74
+ </>,
75
+ );
76
+ expect(document.head.querySelectorAll(STYLE_SELECTOR)).toHaveLength(1);
77
+ });
78
+
79
+ it('are not inserted by range highlighting without the Highlight API', async () => {
80
+ vi.stubGlobal('Highlight', undefined);
81
+ const {CodeBlock} = await import('./CodeBlock');
82
+ render(
83
+ <CodeBlock code="const a = 1;" language="ts" highlightMode="ranges" />,
84
+ );
85
+ expect(document.head.querySelector(STYLE_SELECTOR)).toBeNull();
86
+ });
87
+ });
@@ -803,11 +803,21 @@ function RangeCodeContent({
803
803
  }) {
804
804
  const codeRef = useRef<HTMLElement>(null);
805
805
 
806
+ // Insert the ::highlight() rules before the commit's layout reads, as the
807
+ // span path does. Inserted later, the new rules restyle the whole document
808
+ // a second time after any layout read in the same commit has already
809
+ // styled it, which on a long page full of code blocks costs a full style
810
+ // recalculation.
811
+ useInsertionEffect(() => {
812
+ if (hasHighlightAPI()) {
813
+ ensureHighlightStyles();
814
+ }
815
+ }, []);
816
+
806
817
  useEffect(() => {
807
818
  if (!hasHighlightAPI()) {
808
819
  return;
809
820
  }
810
- ensureHighlightStyles();
811
821
 
812
822
  const codeEl = codeRef.current;
813
823
  if (!codeEl || tokenLines.length === 0) {
@@ -45,14 +45,15 @@ export const docs = {
45
45
  {
46
46
  name: 'isDisabled',
47
47
  type: 'boolean',
48
- description: 'Disable the trigger and picker.',
48
+ description:
49
+ 'Disable the trigger and picker. The trigger stays focusable and exposes `aria-disabled`; opening is blocked.',
49
50
  default: 'false',
50
51
  },
51
52
  {
52
53
  name: 'disabledMessage',
53
54
  type: 'string',
54
55
  description:
55
- 'Explains why the input is disabled. With isDisabled, shows a tooltip on hover/keyboard focus and keeps the field focusable via aria-disabled (activation stays blocked). Use this instead of wrapping a disabled DateRangeInput in Tooltip. Disabled controls swallow the hover events an external Tooltip needs.',
56
+ 'Explains why the input is disabled. With isDisabled, shows a tooltip on hover/keyboard focus and describes the trigger (activation stays blocked). Use this instead of wrapping a disabled DateRangeInput in Tooltip.',
56
57
  },
57
58
  {
58
59
  name: 'value',
@@ -240,7 +241,7 @@ export const docs = {
240
241
  {
241
242
  guidance: false,
242
243
  description:
243
- 'Wrap a disabled DateRangeInput in Tooltip to explain why it is disabled; disabled triggers swallow the hover events the wrapper needs. Use the disabledMessage prop instead.',
244
+ 'Wrap a disabled DateRangeInput in Tooltip to explain why it is disabled. Use the disabledMessage prop instead, which ties the reason to the field.',
244
245
  },
245
246
  ],
246
247
  anatomy: [
@@ -344,7 +345,7 @@ export const docsDense = {
344
345
  {
345
346
  guidance: false,
346
347
  description:
347
- 'Wrap a disabled DateRangeInput in Tooltip to explain why it is disabled; disabled triggers swallow the hover events the wrapper needs. Use the disabledMessage prop instead.',
348
+ 'Wrap a disabled DateRangeInput in Tooltip to explain why it is disabled. Use the disabledMessage prop instead, which ties the reason to the field.',
348
349
  },
349
350
  ],
350
351
  },
@@ -356,7 +357,7 @@ export const docsDense = {
356
357
  isRequired: 'mark field required',
357
358
  isDisabled: 'disable trigger+picker',
358
359
  disabledMessage:
359
- 'reason shown in a tooltip on hover/focus when disabled; keeps trigger focusable via aria-disabled',
360
+ 'reason shown in a tooltip on hover/focus when disabled; describes the trigger',
360
361
  value:
361
362
  'selected range {start, end} or null; import DateRange type from @astryxdesign/core/DateRangeInput (do not redeclare)',
362
363
  onChange: 'callback on range change; null on clear',
@@ -23,6 +23,8 @@ function generateThemeTestCSS(theme: Parameters<typeof generateThemeCSS>[0]) {
23
23
  // made every role+name query compute ~85 accessible names through jsdom's
24
24
  // slow getComputedStyle (~450ms per query). See fastRoleQueries.ts.
25
25
  import {getButton, queryButton} from '../__tests__/fastRoleQueries';
26
+ import * as stylex from '@stylexjs/stylex';
27
+ import {colorVars} from '../theme/tokens.stylex';
26
28
  import {DateRangeInput} from './DateRangeInput';
27
29
  import type {DateRange} from './DateRangeInput';
28
30
  import {Icon} from '../Icon';
@@ -155,7 +157,46 @@ describe('DateRangeInput', () => {
155
157
  />,
156
158
  );
157
159
  const trigger = getButton(/Range/);
158
- expect(trigger).toBeDisabled();
160
+ expect(trigger).not.toHaveAttribute('disabled');
161
+ expect(trigger).toHaveAttribute('aria-disabled', 'true');
162
+ trigger.focus();
163
+ expect(trigger).toHaveFocus();
164
+ });
165
+
166
+ it('restores the focus ring on a focused disabled field (AST-085 FR6)', () => {
167
+ // The same declarations as the field's ring style compile to the same
168
+ // atomic classes.
169
+ const ringClasses = stylex
170
+ .props(
171
+ stylex.create({
172
+ ring: {
173
+ borderColor: {
174
+ default: colorVars['--color-border-emphasized'],
175
+ ':focus-within': colorVars['--color-accent'],
176
+ },
177
+ boxShadow: {
178
+ default: 'none',
179
+ ':focus-within': `inset 0px 0px 0px 2px ${colorVars['--color-accent-muted']}`,
180
+ },
181
+ },
182
+ }).ring,
183
+ )
184
+ .className!.split(' ')
185
+ // Keep the atomic classes, not the dev-only debug name.
186
+ .filter(name => !name.includes('__'));
187
+ render(
188
+ <DateRangeInput
189
+ label="Range"
190
+ isDisabled
191
+ value={null}
192
+ onChange={() => {}}
193
+ />,
194
+ );
195
+ // The shared disabled style alone drops these :focus-within declarations.
196
+ const wrapper = getButton(/Range/).parentElement!;
197
+ for (const name of ringClasses) {
198
+ expect(wrapper).toHaveClass(name);
199
+ }
159
200
  });
160
201
 
161
202
  it('is not disabled by default', () => {
@@ -272,7 +313,8 @@ describe('DateRangeInput', () => {
272
313
  onChange={() => {}}
273
314
  />,
274
315
  );
275
- expect(getButton('Open calendar')).toBeDisabled();
316
+ expect(getButton('Open calendar')).not.toHaveAttribute('disabled');
317
+ expect(getButton('Open calendar')).toHaveAttribute('aria-disabled', 'true');
276
318
  });
277
319
 
278
320
  it('renders with size="lg"', () => {
@@ -526,7 +568,8 @@ describe('DateRangeInput', () => {
526
568
  );
527
569
 
528
570
  const preset = getButton('Last 7 days');
529
- expect(preset).toBeDisabled();
571
+ expect(preset).not.toHaveAttribute('disabled');
572
+ expect(preset).toHaveAttribute('aria-disabled', 'true');
530
573
  fireEvent.click(preset);
531
574
  expect(handleChange).not.toHaveBeenCalled();
532
575
  });
@@ -544,7 +587,8 @@ describe('DateRangeInput', () => {
544
587
  );
545
588
 
546
589
  const preset = getButton('Last 7 days');
547
- expect(preset).toBeDisabled();
590
+ expect(preset).not.toHaveAttribute('disabled');
591
+ expect(preset).toHaveAttribute('aria-disabled', 'true');
548
592
  fireEvent.click(preset);
549
593
  expect(handleChange).not.toHaveBeenCalled();
550
594
  });
@@ -562,7 +606,8 @@ describe('DateRangeInput', () => {
562
606
  );
563
607
 
564
608
  const preset = getButton('Last 7 days');
565
- expect(preset).toBeDisabled();
609
+ expect(preset).not.toHaveAttribute('disabled');
610
+ expect(preset).toHaveAttribute('aria-disabled', 'true');
566
611
  fireEvent.click(preset);
567
612
  expect(handleChange).not.toHaveBeenCalled();
568
613
  });
@@ -590,7 +635,7 @@ describe('DateRangeInput', () => {
590
635
 
591
636
  const getRangeCallsBeforeClick = getRange.mock.calls.length;
592
637
  const preset = getButton('Allowed range');
593
- expect(preset).not.toBeDisabled();
638
+ expect(preset).not.toHaveAttribute('aria-disabled');
594
639
  fireEvent.click(preset);
595
640
  expect(handleChange).toHaveBeenCalledWith(range);
596
641
  expect(getRangeCallsAtChange).toBe(getRangeCallsBeforeClick);
@@ -613,7 +658,10 @@ describe('DateRangeInput', () => {
613
658
  // accessibility tree; use hidden: true to find it.
614
659
  const h = {hidden: true} as const;
615
660
 
616
- function renderDisabled(props?: {disabledMessage?: string}) {
661
+ function renderDisabled(props?: {
662
+ disabledMessage?: string;
663
+ onClick?: () => void;
664
+ }) {
617
665
  return render(
618
666
  <DateRangeInput
619
667
  label="Range"
@@ -699,11 +747,22 @@ describe('DateRangeInput', () => {
699
747
  expect(trigger).toHaveAttribute('aria-expanded', 'false');
700
748
  });
701
749
 
702
- it('remains natively disabled when disabled without a reason', () => {
703
- renderDisabled();
750
+ it('blocks activation and a caller onClick when disabled without a reason (AST-085 FR1, FR2)', async () => {
751
+ const user = userEvent.setup();
752
+ const onClick = vi.fn();
753
+ renderDisabled({onClick});
704
754
  const trigger = getButton(/Range:/);
705
- expect(trigger).toBeDisabled();
706
- expect(trigger).not.toHaveAttribute('aria-disabled');
755
+ expect(trigger).not.toHaveAttribute('disabled');
756
+ await user.click(trigger);
757
+ await user.keyboard('{Enter}');
758
+ expect(trigger).toHaveAttribute('aria-expanded', 'false');
759
+ expect(onClick).not.toHaveBeenCalled();
760
+ });
761
+
762
+ it('blocks the calendar button while disabled (AST-085 FR2)', () => {
763
+ renderDisabled();
764
+ fireEvent.click(getButton('Open calendar'));
765
+ expect(getButton(/Range:/)).toHaveAttribute('aria-expanded', 'false');
707
766
  });
708
767
  });
709
768
  });
@@ -988,13 +1047,19 @@ describe('DateRangeInput range-span forwarding', () => {
988
1047
  fireEvent.click(getButton('Open calendar'));
989
1048
 
990
1049
  // Before a start is picked, a far-off day is selectable.
991
- expect(dayButton('2026-01-20')).not.toBeDisabled();
1050
+ expect(dayButton('2026-01-20')).not.toHaveAttribute(
1051
+ 'aria-disabled',
1052
+ 'true',
1053
+ );
992
1054
 
993
1055
  fireEvent.click(dayButton('2026-01-10') as HTMLButtonElement);
994
1056
 
995
1057
  // A 7-day window spans start ± 6 days: Jan 16 is the edge, Jan 17 is out.
996
- expect(dayButton('2026-01-16')).not.toBeDisabled();
997
- expect(dayButton('2026-01-17')).toBeDisabled();
1058
+ expect(dayButton('2026-01-16')).not.toHaveAttribute(
1059
+ 'aria-disabled',
1060
+ 'true',
1061
+ );
1062
+ expect(dayButton('2026-01-17')).toHaveAttribute('aria-disabled', 'true');
998
1063
  });
999
1064
 
1000
1065
  it('disables a preset whose range violates the span cap', () => {
@@ -1030,9 +1095,10 @@ describe('DateRangeInput range-span forwarding', () => {
1030
1095
  // The 3-day preset fits the 7-day cap; the 30-day preset can't be committed.
1031
1096
  const withinCap = getButton('Last 3 days');
1032
1097
  const overCap = getButton('Last 30 days');
1033
- expect(withinCap).not.toBeDisabled();
1098
+ expect(withinCap).not.toHaveAttribute('aria-disabled');
1034
1099
  expect(withinCap).not.toHaveAttribute('data-disabled');
1035
- expect(overCap).toBeDisabled();
1100
+ expect(overCap).not.toHaveAttribute('disabled');
1101
+ expect(overCap).toHaveAttribute('aria-disabled', 'true');
1036
1102
  expect(overCap).toHaveClass('astryx-date-range-input-preset');
1037
1103
  expect(overCap).toHaveAttribute('data-disabled', 'disabled');
1038
1104