@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
@@ -130,9 +130,9 @@ intentional decision.
130
130
  region, including on first mount and when message or type changes, without
131
131
  moving focus. Inside a mounted live container, `spec:AST-086` FR4 applies.
132
132
  - **AR5 — Assistive-technology output.** Claims about whether, when, or how the
133
- message is spoken or presented in braille MUST be verified through the real-AT
134
- evidence process in `spec:AST-009`; DOM and accessibility-tree checks prove
135
- only programmatic exposure and routing.
133
+ message is spoken or presented in braille are recommended real-AT follow-up
134
+ under `spec:AST-009`, not a merge or release requirement; DOM and
135
+ accessibility-tree checks prove programmatic exposure and routing.
136
136
  - **AR2 — Visible description.** The rendered message box MUST remain available
137
137
  to assistive technology for `aria-describedby` association and MUST NOT become
138
138
  its own newly mounted live region.
@@ -182,7 +182,7 @@ change either presentation.
182
182
  | FR5, FR6 | `FieldStatus.test.tsx` forwarding and update suites | Initial render and rerender | Breaking the released root or update behavior fails compatibility assertions. | `audit:FieldStatus/behavior` |
183
183
  | AR1 | `StatusMessage.a11y.test.tsx` and `StatusMessage.a11y.chromium.spec.ts` | Error, warning, and success; post-mount message replacement | Removing the persistent channel, changing its role/urgency, or dropping a post-mount update fails the shared status-message contract. | `audit:FieldStatus/accessibility` |
184
184
  | AR1 | `FieldStatus.test.tsx` channel-routing suite | First non-empty mount; same-instance message and type changes | Breaking FieldStatus's first-use hook call or severity rerouting fails component-specific assertions. | `audit:FieldStatus/accessibility` |
185
- | AR5 | Pending/unverified real-AT evidence governed by `spec:AST-009` | First mount; message replacement; type/urgency change | Spoken and braille output remain unverified until a durable named AT/browser receipt exists. | `audit:FieldStatus/accessibility` |
185
+ | AR5 | Recommended real-AT follow-up under `spec:AST-009` | First mount; message replacement; type/urgency change | Spoken and braille output stay unverified until a real-AT receipt exists; this does not block merge or release. | `audit:FieldStatus/accessibility` |
186
186
  | AR2 | `FieldStatus.test.tsx` | Attached/detached visible message and empty message | The visible description becomes a second live region or is hidden from assistive technology. | `audit:FieldStatus/accessibility` |
187
187
  | Theming anatomy map | `scripts/check-knowledge.mjs` | Consumer anatomy and current targets | Missing, extra, duplicated, prefixed, or stale mappings fail repository validation. | `audit:FieldStatus/theming` |
188
188
 
@@ -10,7 +10,6 @@
10
10
  */
11
11
 
12
12
  import {useEffect, useState, type ReactElement, type ReactNode} from 'react';
13
- import {ChatSystemMessage} from '../../Chat/ChatSystemMessage';
14
13
  import {ProgressBar} from '../../ProgressBar/ProgressBar';
15
14
  import {Spinner} from '../../Spinner/Spinner';
16
15
  import {Toast} from '../../Toast/Toast';
@@ -198,22 +197,6 @@ function SpinnerHarness({visibleLabel}: {visibleLabel: boolean}) {
198
197
  );
199
198
  }
200
199
 
201
- function ChatSystemMessageHarness() {
202
- const initial = 'Conversation started';
203
- const [message, setMessage] = useState(initial);
204
- return (
205
- <Frame>
206
- <TransitionButton name="show" onClick={() => setMessage(initial)} />
207
- <TransitionButton
208
- name="replace"
209
- onClick={() => setMessage('A file was shared')}
210
- />
211
- <TransitionButton name="clear" onClick={() => setMessage('')} />
212
- <ChatSystemMessage>{message}</ChatSystemMessage>
213
- </Frame>
214
- );
215
- }
216
-
217
200
  function ProgressHarness({
218
201
  initial,
219
202
  progressValue = 40,
@@ -289,7 +272,6 @@ export const CORE_STATUS_MESSAGE_STATE_RENDERS: Record<
289
272
  <SpinnerHarness visibleLabel={false} />
290
273
  ),
291
274
  'spinner-visible-label-mounted': () => <SpinnerHarness visibleLabel />,
292
- 'chat-system-status-mounted': () => <ChatSystemMessageHarness />,
293
275
  'progress-loading-to-complete': () => <ProgressHarness />,
294
276
  'progress-custom-range': () => (
295
277
  <ProgressHarness
@@ -14,7 +14,6 @@ export type CoreStatusMessageBinding =
14
14
  | 'Toast card'
15
15
  | 'FieldStatus announcement channel'
16
16
  | 'Spinner'
17
- | 'ChatSystemMessage'
18
17
  | 'ProgressBar';
19
18
 
20
19
  export interface CoreStatusMessageBindingDefinition {
@@ -188,20 +187,6 @@ export const CORE_STATUS_MESSAGE_BINDING_STATES = [
188
187
  }),
189
188
  storyId: 'a11y-status-message-pattern--spinner-visible-label-mounted',
190
189
  },
191
- {
192
- id: 'chat-system-status-mounted',
193
- binding: 'ChatSystemMessage',
194
- summary: 'a chat status notice is mounted with its live-region content',
195
- facts: liveFacts({
196
- role: 'status',
197
- politeness: 'polite',
198
- initialMessage: 'Conversation started',
199
- message: 'Conversation started',
200
- replacement: 'A file was shared',
201
- semanticTransitions: ['replace', 'clear'],
202
- }),
203
- storyId: 'a11y-status-message-pattern--chat-system-status-mounted',
204
- },
205
190
  {
206
191
  id: 'progress-loading-to-complete',
207
192
  binding: 'ProgressBar',
@@ -279,10 +264,10 @@ export const CORE_STATUS_MESSAGE_EXCLUSIONS = [
279
264
  'A composing control that marks Spinner aria-hidden owns the surrounding busy semantics; the decorative ring is not another status message.',
280
265
  },
281
266
  {
282
- owner: 'ChatSystemMessage divider',
283
- part: 'date or section separator',
267
+ owner: 'ChatSystemMessage',
268
+ part: 'chat notice or date separator',
284
269
  reason:
285
- 'A divider does not report success, results, waiting, progress, or an error. Its current forced status role is tracked as a separate ownership mismatch rather than adopted here.',
270
+ 'spec:AST-086 FR4: a notice added to a chat log is delivered by the log, so neither the notice row nor the divider carries its own live role; a date or section separator is ordinary transcript content.',
286
271
  },
287
272
  {
288
273
  owner: 'ChatMessageList and streaming logs',
@@ -128,7 +128,6 @@ describe('Core status-message binding inventory', () => {
128
128
  ),
129
129
  ].sort(),
130
130
  ).toEqual([
131
- 'ChatSystemMessage',
132
131
  'FieldStatus announcement channel',
133
132
  'ProgressBar',
134
133
  'Spinner',
@@ -141,7 +140,7 @@ describe('Core status-message binding inventory', () => {
141
140
  expect(CORE_STATUS_MESSAGE_EXCLUSIONS.map(entry => entry.part)).toEqual([
142
141
  'visible validation text',
143
142
  'aria-hidden decorative spinner',
144
- 'date or section separator',
143
+ 'chat notice or date separator',
145
144
  'role=log transcript updates',
146
145
  'composer errors and tool-run states',
147
146
  'busy announcement',
@@ -357,17 +357,17 @@ describe('Markdown helper performance', () => {
357
357
  }),
358
358
  ];
359
359
 
360
- // Timed on the transform stage alone and compared at the FASTEST
361
- // sample of each side: this is a few hundred microseconds against a
362
- // parse of milliseconds, so measuring the whole parse buries it, and
363
- // a minimum is the least-disturbed sample each side achieved.
360
+ // Timed on the transform stage alone: this is a few hundred
361
+ // microseconds against a parse of milliseconds, so measuring the whole
362
+ // parse buries it. Each side is compared at its FASTEST sample, the
363
+ // least-disturbed run it achieved, and the two sides alternate sample by
364
+ // sample so a slow stretch of the machine reaches both instead of one.
364
365
  const root = parseMarkdownAst(source);
365
- const fastestTransform = (
366
+ const transformOnly = (
366
367
  plugins: ReadonlyArray<ReturnType<typeof createMarkdownPlugin>>,
367
- ): number => {
368
+ ) => {
368
369
  const prepared = prepareMarkdownPlugins(plugins);
369
- let fastest = Number.POSITIVE_INFINITY;
370
- for (let sample = 0; sample < 60; sample++) {
370
+ return () => {
371
371
  const start = performance.now();
372
372
  benchmarkSink ^= applyMarkdownTransforms(
373
373
  root,
@@ -376,18 +376,24 @@ describe('Markdown helper performance', () => {
376
376
  true,
377
377
  'block',
378
378
  ).children.length;
379
- fastest = Math.min(fastest, performance.now() - start);
380
- }
381
- void benchmarkSink;
382
- return fastest;
379
+ return performance.now() - start;
380
+ };
383
381
  };
384
-
385
- const single = plugin('scale-single', gaps);
386
- const double = plugin('scale-double', doubled);
387
- // Warm both before either is recorded.
388
- fastestTransform(single);
389
- fastestTransform(double);
390
- const ratio = fastestTransform(double) / fastestTransform(single);
382
+ const single = transformOnly(plugin('scale-single', gaps));
383
+ const double = transformOnly(plugin('scale-double', doubled));
384
+ let fastestSingle = Number.POSITIVE_INFINITY;
385
+ let fastestDouble = Number.POSITIVE_INFINITY;
386
+ for (let sample = 0; sample < 80; sample++) {
387
+ // The first rounds only warm both sides.
388
+ const singleTime = single();
389
+ const doubleTime = double();
390
+ if (sample >= 20) {
391
+ fastestSingle = Math.min(fastestSingle, singleTime);
392
+ fastestDouble = Math.min(fastestDouble, doubleTime);
393
+ }
394
+ }
395
+ void benchmarkSink;
396
+ const ratio = fastestDouble / fastestSingle;
391
397
  console.log(
392
398
  ` ${sections} sections, ${doubled.length} ranges over ${gaps.length}: ${ratio.toFixed(3)}`,
393
399
  );
@@ -0,0 +1,143 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Markdown.rerender.test.tsx
5
+ * @input Uses vitest, @testing-library/react, Markdown, a synthetic long document
6
+ * @output Tests that an unrelated caller re-render reuses the rendered document
7
+ * @position Performance regression evidence for long documents. Counting
8
+ * renders of a caller-supplied block renderer is deterministic, unlike a
9
+ * timing budget, and fails when Markdown rebuilds every block element on a
10
+ * re-render that changed nothing it reads.
11
+ */
12
+
13
+ import {useState} from 'react';
14
+ import {act, cleanup, fireEvent, render} from '@testing-library/react';
15
+ import {afterEach, describe, expect, it, vi} from 'vitest';
16
+ import {Markdown} from './Markdown';
17
+ import type {MarkdownComponents} from './Markdown';
18
+
19
+ afterEach(() => {
20
+ cleanup();
21
+ });
22
+
23
+ /** A long, review-style document: headings, prose, lists, a table, code. */
24
+ function longDocument(sections: number): string {
25
+ return Array.from({length: sections}, (_, i) =>
26
+ [
27
+ `## Section ${i}`,
28
+ '',
29
+ `Paragraph ${i} with **bold**, *emphasis*, \`code\` and a [link](/docs/${i}).`,
30
+ '',
31
+ `- Point ${i}`,
32
+ ` - Nested point ${i}`,
33
+ `- [x] Done ${i}`,
34
+ '',
35
+ '| Name | Count |',
36
+ '| --- | ---: |',
37
+ `| row ${i} | ${i * 3} |`,
38
+ '',
39
+ '```ts',
40
+ `const value${i} = ${i};`,
41
+ '```',
42
+ ].join('\n'),
43
+ ).join('\n\n');
44
+ }
45
+
46
+ const SECTIONS = 40;
47
+
48
+ function createCountingComponents(): {
49
+ components: Partial<MarkdownComponents>;
50
+ renders: () => number;
51
+ } {
52
+ let count = 0;
53
+ function CountingCode({code}: {code: string; language?: string}) {
54
+ count++;
55
+ return <pre data-testid="code">{code}</pre>;
56
+ }
57
+ return {components: {code: CountingCode}, renders: () => count};
58
+ }
59
+
60
+ describe('Markdown re-render reuse', () => {
61
+ it('does not re-render any block when a caller re-render changes nothing Markdown reads', () => {
62
+ const {components, renders} = createCountingComponents();
63
+ const source = longDocument(SECTIONS);
64
+ let bump: () => void = () => {};
65
+ function Page() {
66
+ const [tick, setTick] = useState(0);
67
+ bump = () => setTick(t => t + 1);
68
+ return (
69
+ <section data-tick={tick}>
70
+ {/* A new link handler each render, as inline callers write it. */}
71
+ <Markdown components={components} onLinkClick={() => {}}>
72
+ {source}
73
+ </Markdown>
74
+ </section>
75
+ );
76
+ }
77
+ const {container} = render(<Page />);
78
+ const document = container.querySelector('[role="document"]');
79
+ const html = document?.innerHTML;
80
+ expect(renders()).toBe(SECTIONS);
81
+
82
+ act(() => bump());
83
+ act(() => bump());
84
+
85
+ expect(renders()).toBe(SECTIONS);
86
+ expect(container.querySelector('[role="document"]')).toBe(document);
87
+ expect(document?.innerHTML).toBe(html);
88
+ });
89
+
90
+ it('re-renders when the text changes', () => {
91
+ const {components, renders} = createCountingComponents();
92
+ const {rerender, getAllByTestId} = render(
93
+ <Markdown components={components}>{longDocument(2)}</Markdown>,
94
+ );
95
+ expect(renders()).toBe(2);
96
+ rerender(
97
+ <Markdown components={components}>
98
+ {`${longDocument(2)}\n\n\`\`\`ts\nconst added = true;\n\`\`\``}
99
+ </Markdown>,
100
+ );
101
+ expect(getAllByTestId('code')).toHaveLength(3);
102
+ expect(getAllByTestId('code')[2].textContent).toBe('const added = true;');
103
+ });
104
+
105
+ it('re-renders when a rendering prop changes', () => {
106
+ const {container, rerender} = render(
107
+ <Markdown headingLevelStart={1}>{'# Title'}</Markdown>,
108
+ );
109
+ expect(container.querySelector('h1')).not.toBeNull();
110
+ rerender(<Markdown headingLevelStart={3}>{'# Title'}</Markdown>);
111
+ expect(container.querySelector('h1')).toBeNull();
112
+ expect(container.querySelector('h3')?.textContent).toBe('Title');
113
+ });
114
+
115
+ it('calls the latest link handler after a re-render that reused the document', () => {
116
+ const first = vi.fn();
117
+ const latest = vi.fn(() => false as const);
118
+ const {container, rerender} = render(
119
+ <Markdown onLinkClick={first}>{'[go](/next)'}</Markdown>,
120
+ );
121
+ rerender(<Markdown onLinkClick={latest}>{'[go](/next)'}</Markdown>);
122
+ const link = container.querySelector('a');
123
+ expect(link).not.toBeNull();
124
+ const event = new MouseEvent('click', {bubbles: true, cancelable: true});
125
+ act(() => {
126
+ link?.dispatchEvent(event);
127
+ });
128
+ expect(first).not.toHaveBeenCalled();
129
+ expect(latest).toHaveBeenCalledWith('/next', expect.anything());
130
+ expect(event.defaultPrevented).toBe(true);
131
+ });
132
+
133
+ it('starts and stops handling link clicks when the handler is added or removed', () => {
134
+ const handler = vi.fn();
135
+ const {container, rerender} = render(<Markdown>{'[go](/next)'}</Markdown>);
136
+ rerender(<Markdown onLinkClick={handler}>{'[go](/next)'}</Markdown>);
137
+ fireEvent.click(container.querySelector('a') as HTMLAnchorElement);
138
+ expect(handler).toHaveBeenCalledTimes(1);
139
+ rerender(<Markdown>{'[go](/next)'}</Markdown>);
140
+ fireEvent.click(container.querySelector('a') as HTMLAnchorElement);
141
+ expect(handler).toHaveBeenCalledTimes(1);
142
+ });
143
+ });
@@ -41,6 +41,7 @@ import {TableHeaderCell} from '../Table/TableHeaderCell';
41
41
  import {TableHeader} from '../Table/TableHeader';
42
42
  import {TableBody} from '../Table/TableBody';
43
43
  import {mergeProps} from '../utils';
44
+ import {useIsomorphicLayoutEffect} from '../hooks/useIsomorphicLayoutEffect';
44
45
  import {useStreamingText} from '../hooks/useStreamingText';
45
46
  import {computeBoundaries, computeSegments} from './streaming';
46
47
  import type {BaseProps} from '../BaseProps';
@@ -2122,6 +2123,21 @@ export function Markdown<
2122
2123
  }: MarkdownProps<Plugins>): React.ReactElement {
2123
2124
  const t = useTranslator();
2124
2125
  const LinkComponent = useLinkComponent();
2126
+ // Links read the caller's handler when clicked, so a handler recreated on
2127
+ // every caller render does not invalidate the memoized document below.
2128
+ // Only whether a handler exists changes the rendered links.
2129
+ const onLinkClickRef = useRef(onLinkClick);
2130
+ useIsomorphicLayoutEffect(() => {
2131
+ onLinkClickRef.current = onLinkClick;
2132
+ });
2133
+ const hasLinkClick = onLinkClick != null;
2134
+ const stableOnLinkClick = useMemo<MarkdownProps['onLinkClick']>(
2135
+ () =>
2136
+ hasLinkClick
2137
+ ? (href, event) => onLinkClickRef.current?.(href, event)
2138
+ : undefined,
2139
+ [hasLinkClick],
2140
+ );
2125
2141
  // Derive the set of source IDs for the parser (stable across renders when sources don't change)
2126
2142
  const sourceIds = useMemo(
2127
2143
  () => (sources ? new Set(Object.keys(sources)) : undefined),
@@ -2327,19 +2343,80 @@ export function Markdown<
2327
2343
  return next;
2328
2344
  }, [display, smoothedLen, maxSpans]);
2329
2345
 
2330
- const cursor: StreamingCursor = {
2331
- offset: 0,
2332
- boundaries,
2333
- active: isStreaming,
2334
- };
2335
-
2336
- // Build citation context — numbers are assigned in encounter order during rendering.
2337
- // This is recreated each render so numbering stays consistent with the AST.
2338
- const citationCtx: CitationContext | null = sources
2339
- ? {sources, numberMap: new Map(), nextNumber: 1, style: citationStyle}
2346
+ const contentWidthValue = contentWidth
2347
+ ? typeof contentWidth === 'number'
2348
+ ? `${contentWidth}px`
2349
+ : contentWidth
2340
2350
  : null;
2341
2351
 
2352
+ // The block document is the expensive part of a long Markdown render. It
2353
+ // depends only on these inputs, so a caller re-render that changes none of
2354
+ // them returns the same elements and React skips the whole subtree. The
2355
+ // cursor and citation numbering are rebuilt inside, from the start of the
2356
+ // document, exactly as a full render builds them.
2357
+ const renderedBlocks = useMemo(() => {
2358
+ if (display === 'inline') {
2359
+ return null;
2360
+ }
2361
+ const blockCursor: StreamingCursor = {
2362
+ offset: 0,
2363
+ boundaries,
2364
+ active: isStreaming,
2365
+ };
2366
+ // Numbers are assigned in encounter order during rendering.
2367
+ const blockCitationCtx: CitationContext | null = sources
2368
+ ? {sources, numberMap: new Map(), nextNumber: 1, style: citationStyle}
2369
+ : null;
2370
+ return blocks.map((block, i) =>
2371
+ renderBlock(
2372
+ block,
2373
+ i,
2374
+ blocks.length,
2375
+ density,
2376
+ headingLevelStart,
2377
+ stableOnLinkClick,
2378
+ blockCursor,
2379
+ blockCitationCtx,
2380
+ contentWidthValue,
2381
+ contentAlign,
2382
+ LinkComponent,
2383
+ inlinePlugins,
2384
+ components,
2385
+ preparedPlugins,
2386
+ t,
2387
+ headingProjection,
2388
+ ),
2389
+ );
2390
+ }, [
2391
+ display,
2392
+ blocks,
2393
+ boundaries,
2394
+ isStreaming,
2395
+ sources,
2396
+ citationStyle,
2397
+ density,
2398
+ headingLevelStart,
2399
+ stableOnLinkClick,
2400
+ contentWidthValue,
2401
+ contentAlign,
2402
+ LinkComponent,
2403
+ inlinePlugins,
2404
+ components,
2405
+ preparedPlugins,
2406
+ t,
2407
+ headingProjection,
2408
+ ]);
2409
+
2342
2410
  if (display === 'inline') {
2411
+ const cursor: StreamingCursor = {
2412
+ offset: 0,
2413
+ boundaries,
2414
+ active: isStreaming,
2415
+ };
2416
+ // Numbers are assigned in encounter order during rendering.
2417
+ const citationCtx: CitationContext | null = sources
2418
+ ? {sources, numberMap: new Map(), nextNumber: 1, style: citationStyle}
2419
+ : null;
2343
2420
  const renderedInline = (
2344
2421
  <span
2345
2422
  ref={ref}
@@ -2357,7 +2434,7 @@ export function Markdown<
2357
2434
  renderInline(
2358
2435
  node,
2359
2436
  i,
2360
- onLinkClick,
2437
+ stableOnLinkClick,
2361
2438
  cursor,
2362
2439
  citationCtx,
2363
2440
  LinkComponent,
@@ -2391,30 +2468,7 @@ export function Markdown<
2391
2468
  className,
2392
2469
  style,
2393
2470
  )}>
2394
- {blocks.map((block, i) =>
2395
- renderBlock(
2396
- block,
2397
- i,
2398
- blocks.length,
2399
- density,
2400
- headingLevelStart,
2401
- onLinkClick,
2402
- cursor,
2403
- citationCtx,
2404
- contentWidth
2405
- ? typeof contentWidth === 'number'
2406
- ? `${contentWidth}px`
2407
- : contentWidth
2408
- : null,
2409
- contentAlign,
2410
- LinkComponent,
2411
- inlinePlugins,
2412
- components,
2413
- preparedPlugins,
2414
- t,
2415
- headingProjection,
2416
- ),
2417
- )}
2471
+ {renderedBlocks}
2418
2472
  </div>
2419
2473
  );
2420
2474
 
@@ -0,0 +1,71 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file MultiSelector.disabledFocus.a11y.chromium.spec.ts
5
+ * @input The `Core/MultiSelector` Default story in a built Storybook.
6
+ * @output Real-engine proof of `spec:AST-085` FR4: a trigger disabled while it
7
+ * holds keyboard focus keeps focus and reports itself disabled.
8
+ * @position Run with `pnpm test:a11y-contract`; a DOM emulator does not move
9
+ * focus off an element that becomes disabled, so it cannot show the loss.
10
+ */
11
+
12
+ import {expect, test} from '@playwright/test';
13
+ import {
14
+ DEFAULT_STORYBOOK_DIR,
15
+ serveStorybook,
16
+ type StaticServer,
17
+ } from '@astryxdesign/a11y-spec/storybook';
18
+
19
+ let storybook: StaticServer;
20
+
21
+ test.beforeAll(async () => {
22
+ storybook = await serveStorybook(
23
+ process.env.ASTRYX_STORYBOOK_DIR ?? DEFAULT_STORYBOOK_DIR,
24
+ );
25
+ });
26
+
27
+ test.afterAll(async () => {
28
+ await storybook?.close();
29
+ });
30
+
31
+ test('a focused trigger keeps focus when it becomes disabled', async ({
32
+ page,
33
+ }) => {
34
+ const storyId = 'core-multiselector--default';
35
+ await page.goto(
36
+ `${storybook.origin}/iframe.html?id=${storyId}&viewMode=story`,
37
+ );
38
+ const trigger = page.getByRole('combobox', {name: 'Columns'});
39
+ await trigger.focus();
40
+ await expect(trigger).toBeFocused();
41
+
42
+ await page.evaluate(id => {
43
+ const channel = (
44
+ window as unknown as {
45
+ __STORYBOOK_ADDONS_CHANNEL__?: {
46
+ emit: (event: string, payload: unknown) => void;
47
+ };
48
+ }
49
+ ).__STORYBOOK_ADDONS_CHANNEL__;
50
+ if (channel == null) {
51
+ throw new Error('Storybook channel is unavailable');
52
+ }
53
+ channel.emit('updateStoryArgs', {
54
+ storyId: id,
55
+ updatedArgs: {isDisabled: true},
56
+ });
57
+ }, storyId);
58
+
59
+ // Wait for the disabled re-render in either form, so focus is checked
60
+ // after it lands.
61
+ await expect
62
+ .poll(async () =>
63
+ trigger.evaluate(
64
+ el =>
65
+ el.hasAttribute('disabled') ||
66
+ el.getAttribute('aria-disabled') === 'true',
67
+ ),
68
+ )
69
+ .toBe(true);
70
+ await expect(trigger).toBeFocused();
71
+ });
@@ -304,7 +304,7 @@ export const docs = {
304
304
  name: 'disabledMessage',
305
305
  type: 'string',
306
306
  description:
307
- 'Explains why the selector is disabled. With isDisabled, shows a tooltip on hover/keyboard focus and keeps the trigger focusable via aria-disabled (activation stays blocked). Use this instead of wrapping a disabled MultiSelector in Tooltip. Disabled controls swallow the hover events an external Tooltip needs.',
307
+ 'Explains why the selector is disabled. With isDisabled, shows the reason as a tooltip on hover and keyboard focus of the trigger, which stays focusable (aria-disabled) like every disabled selector; activation stays blocked. Use this instead of wrapping a disabled MultiSelector in Tooltip.',
308
308
  },
309
309
  {
310
310
  name: 'isLabelHidden',
@@ -511,7 +511,7 @@ export const docs = {
511
511
  {
512
512
  guidance: false,
513
513
  description:
514
- 'Wrap a disabled MultiSelector in Tooltip to explain why it is disabled; disabled triggers swallow the hover events the wrapper needs. Use the disabledMessage prop instead.',
514
+ 'Wrap a disabled MultiSelector in Tooltip to explain why it is disabled. Use the disabledMessage prop, which attaches the reason to the trigger itself.',
515
515
  },
516
516
  ],
517
517
  },
@@ -552,7 +552,7 @@ export const docsZh = {
552
552
  htmlName:
553
553
  '用于表单提交的 HTML name 属性。为每个已选值渲染一个隐藏输入,类似原生多选。',
554
554
  disabledMessage:
555
- '解释选择器被禁用的原因。与 isDisabled 一起使用时,悬停/键盘聚焦时显示工具提示,并通过 aria-disabled 保持触发器可聚焦(仍无法激活)。请使用此属性,而不是用 Tooltip 包裹被禁用的选择器。',
555
+ '解释选择器被禁用的原因。与 isDisabled 一起使用时,悬停/键盘聚焦时显示工具提示,触发器与所有被禁用的选择器一样通过 aria-disabled 保持可聚焦(仍无法激活)。请使用此属性,而不是用 Tooltip 包裹被禁用的选择器。',
556
556
  isLabelHidden: '视觉上隐藏标签同时保持其可访问性。',
557
557
  description: '标签下方显示的辅助文本。',
558
558
  isOptional: '将字段标记为可选。',
@@ -619,7 +619,7 @@ export const docsZh = {
619
619
  {
620
620
  guidance: false,
621
621
  description:
622
- 'Wrap a disabled MultiSelector in Tooltip to explain why it is disabled; disabled triggers swallow the hover events the wrapper needs. Use the disabledMessage prop instead.',
622
+ 'Wrap a disabled MultiSelector in Tooltip to explain why it is disabled. Use the disabledMessage prop, which attaches the reason to the trigger itself.',
623
623
  },
624
624
  ],
625
625
  },
@@ -674,7 +674,7 @@ export const docsDense = {
674
674
  {
675
675
  guidance: false,
676
676
  description:
677
- 'Wrap a disabled MultiSelector in Tooltip to explain why it is disabled; disabled triggers swallow the hover events the wrapper needs. Use the disabledMessage prop instead.',
677
+ 'Wrap a disabled MultiSelector in Tooltip to explain why it is disabled. Use the disabledMessage prop, which attaches the reason to the trigger itself.',
678
678
  },
679
679
  ],
680
680
  },