@astryxdesign/core 0.7.2 → 0.7.3-canary.0bf23bd

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 (183) hide show
  1. package/dist/BottomSheet/BottomSheet.d.ts.map +1 -1
  2. package/dist/BottomSheet/BottomSheet.js +8 -4
  3. package/dist/BottomSheet/BottomSheetSwitcher.d.ts.map +1 -1
  4. package/dist/BottomSheet/BottomSheetSwitcher.js +7 -0
  5. package/dist/CheckboxInput/CheckboxInput.d.ts.map +1 -1
  6. package/dist/CheckboxInput/CheckboxInput.js +19 -24
  7. package/dist/ClickableCard/ClickableCard.d.ts +2 -2
  8. package/dist/ClickableCard/ClickableCard.d.ts.map +1 -1
  9. package/dist/ClickableCard/ClickableCard.js +11 -9
  10. package/dist/CodeBlock/CodeBlock.d.ts.map +1 -1
  11. package/dist/CodeBlock/CodeBlock.js +11 -1
  12. package/dist/ComplexSelector/ComplexSelector.d.ts.map +1 -1
  13. package/dist/ComplexSelector/ComplexSelector.js +23 -5
  14. package/dist/DateInput/DateInput.d.ts +4 -4
  15. package/dist/DateInput/DateInput.d.ts.map +1 -1
  16. package/dist/DateInput/DateInput.js +27 -20
  17. package/dist/DateInput/NativeDateField.d.ts.map +1 -1
  18. package/dist/DateInput/NativeDateField.js +14 -8
  19. package/dist/DateInput/TouchDateField.d.ts.map +1 -1
  20. package/dist/DateInput/TouchDateField.js +14 -10
  21. package/dist/Dialog/Dialog.d.ts.map +1 -1
  22. package/dist/Dialog/Dialog.js +9 -4
  23. package/dist/DropdownMenu/DropdownMenu.d.ts.map +1 -1
  24. package/dist/DropdownMenu/DropdownMenu.js +52 -10
  25. package/dist/DropdownMenu/DropdownMenuContext.d.ts +34 -1
  26. package/dist/DropdownMenu/DropdownMenuContext.d.ts.map +1 -1
  27. package/dist/DropdownMenu/DropdownMenuContext.js +15 -0
  28. package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts +21 -2
  29. package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts.map +1 -1
  30. package/dist/DropdownMenu/DropdownMenuSubMenu.js +375 -42
  31. package/dist/DropdownMenu/menuRegionStops.d.ts +2 -2
  32. package/dist/DropdownMenu/menuRegionStops.js +2 -2
  33. package/dist/FileInput/FileInput.d.ts.map +1 -1
  34. package/dist/FileInput/FileInput.js +14 -12
  35. package/dist/Link/Link.d.ts +2 -3
  36. package/dist/Link/Link.d.ts.map +1 -1
  37. package/dist/Link/Link.js +11 -19
  38. package/dist/Markdown/Markdown.d.ts.map +1 -1
  39. package/dist/Markdown/Markdown.js +48 -15
  40. package/dist/MultiSelector/MultiSelector.d.ts +4 -4
  41. package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
  42. package/dist/MultiSelector/MultiSelector.disabledFocus.a11y.chromium.spec.d.ts +2 -0
  43. package/dist/MultiSelector/MultiSelector.disabledFocus.a11y.chromium.spec.d.ts.map +1 -0
  44. package/dist/MultiSelector/MultiSelector.disabledFocus.a11y.chromium.spec.js +48 -0
  45. package/dist/MultiSelector/MultiSelector.js +25 -18
  46. package/dist/Popover/Popover.d.ts.map +1 -1
  47. package/dist/Popover/Popover.js +8 -4
  48. package/dist/Selector/Selector.d.ts +4 -4
  49. package/dist/Selector/Selector.d.ts.map +1 -1
  50. package/dist/Selector/Selector.js +23 -14
  51. package/dist/Selector/SelectorOption.d.ts.map +1 -1
  52. package/dist/Selector/SelectorOption.js +1 -0
  53. package/dist/Slider/Slider.d.ts.map +1 -1
  54. package/dist/Slider/Slider.js +7 -14
  55. package/dist/Switch/Switch.d.ts.map +1 -1
  56. package/dist/Switch/Switch.js +25 -17
  57. package/dist/Switch/switch.markers.stylex.d.ts +2 -0
  58. package/dist/Switch/switch.markers.stylex.d.ts.map +1 -1
  59. package/dist/Switch/switch.markers.stylex.js +6 -0
  60. package/dist/TimeInput/TimeInput.d.ts +2 -1
  61. package/dist/TimeInput/TimeInput.d.ts.map +1 -1
  62. package/dist/Token/Token.d.ts.map +1 -1
  63. package/dist/Token/Token.js +8 -3
  64. package/dist/Tooltip/Tooltip.d.ts.map +1 -1
  65. package/dist/Tooltip/Tooltip.js +36 -14
  66. package/dist/Tooltip/useTooltip.d.ts.map +1 -1
  67. package/dist/Tooltip/useTooltip.js +11 -2
  68. package/dist/TopNav/TopNav.d.ts.map +1 -1
  69. package/dist/TopNav/TopNav.js +2 -2
  70. package/dist/TopNav/TopNavHeading.d.ts.map +1 -1
  71. package/dist/TopNav/TopNavHeading.js +1 -0
  72. package/dist/Typeahead/Typeahead.d.ts.map +1 -1
  73. package/dist/Typeahead/Typeahead.js +14 -2
  74. package/dist/astryx.css +8 -2
  75. package/dist/hooks/useMenuPress.d.ts +11 -1
  76. package/dist/hooks/useMenuPress.d.ts.map +1 -1
  77. package/dist/hooks/useMenuPress.js +43 -5
  78. package/dist/utils/disabledInteraction.d.ts +21 -0
  79. package/dist/utils/disabledInteraction.d.ts.map +1 -0
  80. package/dist/utils/disabledInteraction.js +33 -0
  81. package/dist/utils/timeParser.d.ts +4 -0
  82. package/dist/utils/timeParser.d.ts.map +1 -1
  83. package/dist/utils/timeParser.js +9 -2
  84. package/package.json +4 -4
  85. package/scripts/agent-doc-state.mjs +35 -8
  86. package/src/Banner/Banner.spec.md +37 -32
  87. package/src/BottomSheet/BottomSheet.spec.md +13 -6
  88. package/src/BottomSheet/BottomSheet.test.tsx +29 -0
  89. package/src/BottomSheet/BottomSheet.tsx +8 -4
  90. package/src/BottomSheet/BottomSheetSwitcher.spec.md +121 -51
  91. package/src/BottomSheet/BottomSheetSwitcher.test.tsx +26 -0
  92. package/src/BottomSheet/BottomSheetSwitcher.tsx +6 -0
  93. package/src/CheckboxInput/CheckboxInput.test.tsx +44 -5
  94. package/src/CheckboxInput/CheckboxInput.tsx +16 -26
  95. package/src/CheckboxInput/__tests__/Checkbox.a11y.states.ts +3 -3
  96. package/src/CheckboxList/CheckboxList.test.tsx +9 -6
  97. package/src/ClickableCard/ClickableCard.doc.mjs +2 -2
  98. package/src/ClickableCard/ClickableCard.test.tsx +5 -4
  99. package/src/ClickableCard/ClickableCard.tsx +9 -9
  100. package/src/CodeBlock/CodeBlock.highlightStyles.test.tsx +87 -0
  101. package/src/CodeBlock/CodeBlock.tsx +11 -1
  102. package/src/ComplexSelector/ComplexSelector.focusableDisabled.test.tsx +76 -0
  103. package/src/ComplexSelector/ComplexSelector.tsx +21 -4
  104. package/src/ContextMenu/ContextMenu.test.tsx +41 -0
  105. package/src/DateInput/DateInput.doc.mjs +9 -8
  106. package/src/DateInput/DateInput.test.tsx +88 -13
  107. package/src/DateInput/DateInput.tsx +49 -21
  108. package/src/DateInput/DateInputTouch.test.tsx +22 -1
  109. package/src/DateInput/NativeDateField.test.tsx +29 -3
  110. package/src/DateInput/NativeDateField.tsx +20 -7
  111. package/src/DateInput/TouchDateField.tsx +24 -9
  112. package/src/DateRangeInput/DateRangeInput.spec.md +13 -13
  113. package/src/Dialog/Dialog.test.tsx +39 -0
  114. package/src/Dialog/Dialog.tsx +9 -4
  115. package/src/DropdownMenu/DropdownMenu.spec.md +33 -2
  116. package/src/DropdownMenu/DropdownMenu.tsx +79 -8
  117. package/src/DropdownMenu/DropdownMenuContext.public.test.ts +22 -0
  118. package/src/DropdownMenu/DropdownMenuContext.tsx +39 -1
  119. package/src/DropdownMenu/DropdownMenuSubMenu.doc.mjs +16 -1
  120. package/src/DropdownMenu/DropdownMenuSubMenu.test.tsx +623 -1
  121. package/src/DropdownMenu/DropdownMenuSubMenu.tsx +556 -99
  122. package/src/DropdownMenu/__tests__/SubMenuHeaderFooter.a11y.chromium.spec.ts +267 -0
  123. package/src/DropdownMenu/menuRegionStops.ts +2 -2
  124. package/src/DropdownMenu/modules/useMenuPress.spec.md +9 -0
  125. package/src/FieldStatus/FieldStatus.spec.md +3 -2
  126. package/src/FileInput/FileInput.test.tsx +30 -7
  127. package/src/FileInput/FileInput.tsx +21 -11
  128. package/src/Link/Link.test.tsx +21 -7
  129. package/src/Link/Link.tsx +16 -24
  130. package/src/Link/__tests__/Link.a11y.states.ts +1 -1
  131. package/src/Markdown/Markdown.rerender.test.tsx +143 -0
  132. package/src/Markdown/Markdown.tsx +89 -35
  133. package/src/MultiSelector/MultiSelector.disabledFocus.a11y.chromium.spec.ts +71 -0
  134. package/src/MultiSelector/MultiSelector.doc.mjs +5 -5
  135. package/src/MultiSelector/MultiSelector.focusableDisabled.test.tsx +121 -0
  136. package/src/MultiSelector/MultiSelector.test.tsx +49 -7
  137. package/src/MultiSelector/MultiSelector.tsx +53 -29
  138. package/src/Popover/Popover.test.tsx +15 -0
  139. package/src/Popover/Popover.tsx +19 -8
  140. package/src/PowerSearch/PowerSearchFilterEditor.test.tsx +3 -2
  141. package/src/PowerSearch/PowerSearchToken.test.tsx +7 -5
  142. package/src/Selector/Selector.doc.mjs +4 -4
  143. package/src/Selector/Selector.focusableDisabled.test.tsx +127 -0
  144. package/src/Selector/Selector.spec.md +11 -11
  145. package/src/Selector/Selector.test.tsx +64 -6
  146. package/src/Selector/Selector.tsx +51 -21
  147. package/src/Selector/SelectorOption.tsx +4 -0
  148. package/src/SideNav/SideNav.test.tsx +35 -0
  149. package/src/Slider/Slider.spec.md +11 -11
  150. package/src/Slider/Slider.test.tsx +3 -3
  151. package/src/Slider/Slider.tsx +8 -15
  152. package/src/Switch/Switch.test.tsx +85 -8
  153. package/src/Switch/Switch.tsx +26 -16
  154. package/src/Switch/__tests__/Switch.a11y.states.ts +2 -2
  155. package/src/Switch/switch.markers.stylex.ts +4 -0
  156. package/src/Table/Table.spec.md +2 -0
  157. package/src/Table/plugins/infiniteScroll/useTableInfiniteScroll.spec.md +244 -0
  158. package/src/Table/plugins/selection/useTableSelection.test.tsx +5 -3
  159. package/src/Table/plugins/virtualization/useTableVirtualization.spec.md +266 -0
  160. package/src/TimeInput/TimeInput.doc.mjs +5 -3
  161. package/src/TimeInput/TimeInput.test.tsx +20 -0
  162. package/src/TimeInput/TimeInput.tsx +2 -1
  163. package/src/Token/Token.test.tsx +26 -4
  164. package/src/Token/Token.tsx +6 -2
  165. package/src/Tokenizer/Tokenizer.spec.md +28 -28
  166. package/src/Tooltip/Tooltip.test.tsx +51 -6
  167. package/src/Tooltip/Tooltip.tsx +41 -18
  168. package/src/Tooltip/__tests__/Tooltip.a11y.chromium.spec.ts +129 -0
  169. package/src/Tooltip/__tests__/Tooltip.a11y.renders.tsx +86 -0
  170. package/src/Tooltip/__tests__/Tooltip.a11y.states.ts +132 -0
  171. package/src/Tooltip/__tests__/Tooltip.a11y.test.tsx +126 -0
  172. package/src/Tooltip/__tests__/TooltipHoverBridge.a11y.chromium.spec.ts +74 -0
  173. package/src/Tooltip/useTooltip.test.tsx +23 -0
  174. package/src/Tooltip/useTooltip.tsx +22 -9
  175. package/src/TopNav/TopNav.tsx +11 -1
  176. package/src/TopNav/TopNavHeading.tsx +1 -0
  177. package/src/TopNav/__tests__/TopNavMobileLongHeading.a11y.chromium.spec.ts +74 -0
  178. package/src/Typeahead/Typeahead.test.tsx +89 -0
  179. package/src/Typeahead/Typeahead.tsx +15 -2
  180. package/src/hooks/useMenuPress.ts +45 -7
  181. package/src/utils/disabledInteraction.ts +40 -0
  182. package/src/utils/timeParser.test.ts +11 -0
  183. package/src/utils/timeParser.ts +9 -2
@@ -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
  },
@@ -0,0 +1,121 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file MultiSelector.focusableDisabled.test.tsx
5
+ * @input Uses vitest, @testing-library/react, StyleX, MultiSelector
6
+ * @output Conformance tests for spec:AST-085 FR1–FR3 and FR6 on MultiSelector;
7
+ * FR4 needs a real engine (MultiSelector.disabledFocus.a11y.chromium.spec.ts)
8
+ * @position Testing; validates the disabled trigger of MultiSelector.tsx
9
+ */
10
+
11
+ import {describe, it, expect, vi} from 'vitest';
12
+ import {render, screen} from '@testing-library/react';
13
+ import userEvent from '@testing-library/user-event';
14
+ import * as stylex from '@stylexjs/stylex';
15
+ import {colorVars} from '../theme/tokens.stylex';
16
+ import {MultiSelector} from './MultiSelector';
17
+
18
+ type Handler = () => void;
19
+ function Harness(props: {
20
+ isDisabled: boolean;
21
+ variant?: 'input' | 'secondary' | 'ghost';
22
+ onClick?: Handler;
23
+ onKeyDown?: Handler;
24
+ onMouseDown?: Handler;
25
+ onDoubleClick?: Handler;
26
+ }) {
27
+ return (
28
+ <form aria-label="Form">
29
+ <button type="button">Before</button>
30
+ <MultiSelector
31
+ label="Fruit"
32
+ htmlName="fruit"
33
+ options={['Apple', 'Banana']}
34
+ value={['Banana']}
35
+ onChange={() => {}}
36
+ {...props}
37
+ />
38
+ <button type="button">After</button>
39
+ </form>
40
+ );
41
+ }
42
+
43
+ const trigger = () => screen.getByRole('combobox', {name: 'Fruit'});
44
+
45
+ describe('MultiSelector disabled (spec:AST-085)', () => {
46
+ it('FR1: renders no native disabled and keeps its tab stop', async () => {
47
+ const user = userEvent.setup();
48
+ const {container} = render(<Harness isDisabled />);
49
+ expect(container.querySelector('[disabled]')).toBeNull();
50
+ expect(trigger()).toHaveAttribute('aria-disabled', 'true');
51
+ await user.click(screen.getByRole('button', {name: 'Before'}));
52
+ await user.tab();
53
+ expect(trigger()).toHaveFocus();
54
+ await user.tab();
55
+ expect(screen.getByRole('button', {name: 'After'})).toHaveFocus();
56
+ });
57
+
58
+ it('FR2: no press or key opens it or reaches a caller or ancestor handler', async () => {
59
+ const user = userEvent.setup();
60
+ const handlers = {
61
+ onClick: vi.fn(),
62
+ onKeyDown: vi.fn(),
63
+ onMouseDown: vi.fn(),
64
+ onDoubleClick: vi.fn(),
65
+ };
66
+ const onAncestorClick = vi.fn();
67
+ render(
68
+ <div onClick={onAncestorClick}>
69
+ <Harness isDisabled {...handlers} />
70
+ </div>,
71
+ );
72
+ await user.dblClick(trigger());
73
+ trigger().focus();
74
+ for (const key of ['{Enter}', ' ', '{ArrowDown}', 'b']) {
75
+ await user.keyboard(key);
76
+ }
77
+ expect(trigger()).toHaveAttribute('aria-expanded', 'false');
78
+ expect(screen.queryByRole('listbox')).toBeNull();
79
+ for (const handler of [...Object.values(handlers), onAncestorClick]) {
80
+ expect(handler).not.toHaveBeenCalled();
81
+ }
82
+ });
83
+
84
+ it('FR3: submits no value while disabled', () => {
85
+ const {rerender} = render(<Harness isDisabled={false} />);
86
+ const form = () =>
87
+ screen.getByRole<HTMLFormElement>('form', {name: 'Form'});
88
+ expect(new FormData(form()).getAll('fruit')).toEqual(['Banana']);
89
+ rerender(<Harness isDisabled />);
90
+ expect(new FormData(form()).getAll('fruit')).toEqual([]);
91
+ });
92
+
93
+ // jsdom cannot match `:focus-visible`: compare the ring's StyleX class hash.
94
+ const RING = stylex
95
+ .props(
96
+ stylex.create({
97
+ ring: {
98
+ boxShadow: {
99
+ default: null,
100
+ ':has(> [aria-disabled="true"]:focus-visible)': `inset 0px 0px 0px 2px ${colorVars['--color-accent-muted']}`,
101
+ },
102
+ },
103
+ }).ring,
104
+ )
105
+ .className!.split(' ')
106
+ .pop()!;
107
+
108
+ it.each([
109
+ ['input', true],
110
+ ['secondary', false],
111
+ ['ghost', false],
112
+ ] as const)(
113
+ 'FR6: a disabled %s trigger carries its own ring: %s',
114
+ (variant, ring) => {
115
+ render(<Harness isDisabled variant={variant} />);
116
+ const wrapper = trigger().parentElement!;
117
+ expect(wrapper.classList.contains(RING)).toBe(ring);
118
+ expect(getComputedStyle(wrapper).cursor).toBe('default');
119
+ },
120
+ );
121
+ });
@@ -35,7 +35,7 @@ import {generateThemeCSS} from '../theme/generateThemeRules';
35
35
  import {selectorPresentationStyles} from '../Selector/selectorPresentation.stylex';
36
36
  import {groupStyles} from '../InputGroup/groupStyles';
37
37
  import {InputGroup, InputGroupText} from '../InputGroup';
38
- import {focusVars} from '../theme/tokens.stylex';
38
+ import {colorVars, focusVars} from '../theme/tokens.stylex';
39
39
 
40
40
  function generateThemeTestCSS(theme: Parameters<typeof generateThemeCSS>[0]) {
41
41
  const {prose, component} = generateThemeCSS(theme);
@@ -210,6 +210,45 @@ describe('MultiSelector', () => {
210
210
  );
211
211
  });
212
212
 
213
+ it('shows no disabled focus ring after a pointer pick (spec:AST-085 FR6)', async () => {
214
+ const field = (isDisabled: boolean) => (
215
+ <MultiSelector
216
+ label="Fruit"
217
+ options={defaultOptions}
218
+ value={[]}
219
+ onChange={() => {}}
220
+ presentation="bottom-sheet"
221
+ isDisabled={isDisabled}
222
+ />
223
+ );
224
+ const {rerender} = render(field(false));
225
+ const trigger = screen.getByRole('combobox');
226
+ fireEvent.pointerDown(trigger, {pointerType: 'touch'});
227
+ fireEvent.click(trigger, {detail: 1});
228
+ const dialog = await screen.findByRole('dialog', {name: 'Fruit'});
229
+ fireEvent.pointerDown(dialog, {pointerType: 'touch'});
230
+ fireEvent.click(dialog, {detail: 1});
231
+ rerender(field(true));
232
+ const ring = stylex
233
+ .props(
234
+ stylex.create({
235
+ ring: {
236
+ boxShadow: {
237
+ default: null,
238
+ ':has(> [aria-disabled="true"]:focus-visible)': `inset 0px 0px 0px 2px ${colorVars['--color-accent-muted']}`,
239
+ },
240
+ },
241
+ }).ring,
242
+ )
243
+ .className!.split(' ')
244
+ .pop()!;
245
+ const wrapper = screen.getByRole('combobox').parentElement!;
246
+ expect(wrapper).toHaveClass(
247
+ stylex.props(selectorPresentationStyles.pointerRestoredFocus).className!,
248
+ );
249
+ expect(wrapper).not.toHaveClass(ring);
250
+ });
251
+
213
252
  it('renders with label', () => {
214
253
  render(
215
254
  <MultiSelector
@@ -515,7 +554,9 @@ describe('MultiSelector', () => {
515
554
  isDisabled
516
555
  />,
517
556
  );
518
- expect(screen.getByRole('combobox')).toBeDisabled();
557
+ const trigger = screen.getByRole('combobox');
558
+ expect(trigger).not.toHaveAttribute('disabled');
559
+ expect(trigger).toHaveAttribute('aria-disabled', 'true');
519
560
  });
520
561
 
521
562
  it('has correct ARIA attributes', () => {
@@ -1602,7 +1643,7 @@ describe('MultiSelector', () => {
1602
1643
  expect(screen.getByRole('combobox')).toHaveFocus();
1603
1644
  });
1604
1645
 
1605
- it('trigger is not focusable when disabled', () => {
1646
+ it('trigger keeps its tab stop when disabled (spec:AST-085 FR1)', () => {
1606
1647
  render(
1607
1648
  <MultiSelector
1608
1649
  label="Fruit"
@@ -1612,7 +1653,7 @@ describe('MultiSelector', () => {
1612
1653
  isDisabled
1613
1654
  />,
1614
1655
  );
1615
- expect(screen.getByRole('combobox')).toHaveAttribute('tabIndex', '-1');
1656
+ expect(screen.getByRole('combobox')).toHaveAttribute('tabIndex', '0');
1616
1657
  });
1617
1658
 
1618
1659
  it('opens the listbox with ArrowDown from a focused trigger', async () => {
@@ -2076,7 +2117,7 @@ describe('MultiSelector', () => {
2076
2117
  expect(onChange).not.toHaveBeenCalled();
2077
2118
  });
2078
2119
 
2079
- it('remains non-focusable when disabled without a reason', () => {
2120
+ it('stays focusable when disabled without a reason (spec:AST-085 FR1)', () => {
2080
2121
  render(
2081
2122
  <MultiSelector
2082
2123
  label="Fruit"
@@ -2087,8 +2128,9 @@ describe('MultiSelector', () => {
2087
2128
  />,
2088
2129
  );
2089
2130
  const trigger = screen.getByRole('combobox');
2090
- expect(trigger).toBeDisabled();
2091
- expect(trigger).toHaveAttribute('tabIndex', '-1');
2131
+ expect(trigger).not.toHaveAttribute('disabled');
2132
+ expect(trigger).toHaveAttribute('aria-disabled', 'true');
2133
+ expect(trigger).toHaveAttribute('tabIndex', '0');
2092
2134
  });
2093
2135
  });
2094
2136
  describe('isReadOnly', () => {