@astryxdesign/core 0.6.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (203) hide show
  1. package/CHANGELOG.md +41 -3
  2. package/dist/AppShell/AppShell.d.ts.map +1 -1
  3. package/dist/BottomSheet/BottomSheetSwitcher.d.ts +12 -1
  4. package/dist/BottomSheet/BottomSheetSwitcher.d.ts.map +1 -1
  5. package/dist/BottomSheet/BottomSheetSwitcher.js +44 -15
  6. package/dist/Breadcrumbs/BreadcrumbItem.d.ts +3 -2
  7. package/dist/Breadcrumbs/BreadcrumbItem.d.ts.map +1 -1
  8. package/dist/Breadcrumbs/BreadcrumbItem.js +3 -7
  9. package/dist/Center/Center.d.ts +23 -16
  10. package/dist/Center/Center.d.ts.map +1 -1
  11. package/dist/Center/Center.js +7 -5
  12. package/dist/CodeBlock/CodeBlock.js +2 -2
  13. package/dist/DateInput/DateInput.d.ts.map +1 -1
  14. package/dist/DateInput/DateInput.js +12 -2
  15. package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
  16. package/dist/DateTimeInput/DateTimeInput.js +12 -2
  17. package/dist/Field/Field.d.ts.map +1 -1
  18. package/dist/Field/Field.js +1 -0
  19. package/dist/Field/InputClearButton.d.ts +2 -2
  20. package/dist/Field/InputClearButton.d.ts.map +1 -1
  21. package/dist/Field/InputClearButton.js +5 -1
  22. package/dist/Field/PanelSearchInput.d.ts.map +1 -1
  23. package/dist/Field/PanelSearchInput.js +16 -4
  24. package/dist/FileInput/FileInput.d.ts.map +1 -1
  25. package/dist/FileInput/FileInput.js +12 -1
  26. package/dist/HoverCard/useHoverCard.js +2 -2
  27. package/dist/Indicator/CheckboxIndicator.js +2 -2
  28. package/dist/Indicator/RadioIndicator.js +2 -2
  29. package/dist/Layer/layerStack.d.ts +10 -0
  30. package/dist/Layer/layerStack.d.ts.map +1 -1
  31. package/dist/Layer/layerStack.js +21 -9
  32. package/dist/Layer/useLayerDismissal.d.ts +2 -3
  33. package/dist/Layer/useLayerDismissal.d.ts.map +1 -1
  34. package/dist/Layer/useLayerDismissal.js +2 -3
  35. package/dist/NavIcon/NavIcon.js +2 -2
  36. package/dist/NumberInput/NumberInput.d.ts.map +1 -1
  37. package/dist/NumberInput/NumberInput.js +12 -2
  38. package/dist/Popover/usePopover.d.ts +3 -2
  39. package/dist/Popover/usePopover.d.ts.map +1 -1
  40. package/dist/Popover/usePopover.js +4 -2
  41. package/dist/ProgressBar/ProgressBar.js +2 -2
  42. package/dist/ScrollableArea/ScrollableArea.d.ts +79 -0
  43. package/dist/ScrollableArea/ScrollableArea.d.ts.map +1 -0
  44. package/dist/ScrollableArea/ScrollableArea.js +144 -0
  45. package/dist/ScrollableArea/index.d.ts +11 -0
  46. package/dist/ScrollableArea/index.d.ts.map +1 -0
  47. package/dist/ScrollableArea/index.js +11 -0
  48. package/dist/StatusDot/StatusDot.js +2 -2
  49. package/dist/TextArea/TextArea.js +2 -2
  50. package/dist/TextInput/TextInput.d.ts.map +1 -1
  51. package/dist/TextInput/TextInput.js +19 -4
  52. package/dist/TimeInput/TimeInput.d.ts.map +1 -1
  53. package/dist/TimeInput/TimeInput.js +12 -2
  54. package/dist/Typeahead/BaseTypeahead.d.ts +21 -14
  55. package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
  56. package/dist/Typeahead/BaseTypeahead.js +56 -20
  57. package/dist/astryx.css +15 -0
  58. package/dist/hooks/index.d.ts +2 -0
  59. package/dist/hooks/index.d.ts.map +1 -1
  60. package/dist/hooks/index.js +1 -0
  61. package/dist/hooks/scrollGeometry.d.ts +24 -0
  62. package/dist/hooks/scrollGeometry.d.ts.map +1 -0
  63. package/dist/hooks/scrollGeometry.js +86 -0
  64. package/dist/hooks/scrollOwnerRegistry.d.ts +15 -0
  65. package/dist/hooks/scrollOwnerRegistry.d.ts.map +1 -0
  66. package/dist/hooks/scrollOwnerRegistry.js +24 -0
  67. package/dist/hooks/useFocusTrap.d.ts +8 -0
  68. package/dist/hooks/useFocusTrap.d.ts.map +1 -1
  69. package/dist/hooks/useFocusTrap.js +22 -11
  70. package/dist/hooks/useScrollableArea.d.ts +51 -0
  71. package/dist/hooks/useScrollableArea.d.ts.map +1 -0
  72. package/dist/hooks/useScrollableArea.js +287 -0
  73. package/dist/index.d.ts +1 -0
  74. package/dist/index.d.ts.map +1 -1
  75. package/dist/index.js +1 -0
  76. package/dist/theme/defineTheme.d.ts +2 -6
  77. package/dist/theme/defineTheme.d.ts.map +1 -1
  78. package/dist/theme/defineTheme.js +1 -1
  79. package/dist/theme/derivedVarRegistry.js +1 -1
  80. package/dist/theme/localTokens.d.ts +8 -11
  81. package/dist/theme/localTokens.d.ts.map +1 -1
  82. package/dist/theme/localTokens.js +17 -71
  83. package/dist/theme/themeAdaptations.d.ts.map +1 -1
  84. package/dist/theme/themeAdaptations.js +4 -4
  85. package/dist/utils/themeProps.d.ts +10 -10
  86. package/dist/utils/themeProps.d.ts.map +1 -1
  87. package/dist/utils/themeProps.js +27 -10
  88. package/locales/en.json +16 -0
  89. package/locales/pseudo.json +12 -0
  90. package/package.json +7 -2
  91. package/src/AppShell/AppShell.test.tsx +36 -0
  92. package/src/AppShell/AppShell.tsx +4 -1
  93. package/src/AspectRatio/AspectRatio.doc.mjs +3 -3
  94. package/src/Banner/Banner.test.tsx +3 -1
  95. package/src/BottomSheet/BottomSheetSwitcher.doc.mjs +56 -1
  96. package/src/BottomSheet/BottomSheetSwitcher.spec.md +211 -0
  97. package/src/BottomSheet/BottomSheetSwitcher.test.tsx +134 -2
  98. package/src/BottomSheet/BottomSheetSwitcher.tsx +43 -20
  99. package/src/Breadcrumbs/BreadcrumbItem.doc.mjs +10 -5
  100. package/src/Breadcrumbs/BreadcrumbItem.spec.md +225 -0
  101. package/src/Breadcrumbs/BreadcrumbItem.tsx +8 -13
  102. package/src/Breadcrumbs/Breadcrumbs.doc.mjs +2 -2
  103. package/src/Breadcrumbs/Breadcrumbs.test.tsx +49 -2
  104. package/src/Center/Center.doc.mjs +32 -28
  105. package/src/Center/Center.spec.md +225 -0
  106. package/src/Center/Center.test.tsx +42 -4
  107. package/src/Center/Center.tsx +24 -17
  108. package/src/Chat/ChatSystemMessage.test.tsx +2 -9
  109. package/src/CodeBlock/CodeBlock.doc.mjs +2 -2
  110. package/src/CodeBlock/CodeBlock.tsx +2 -2
  111. package/src/DateInput/DateInput.test.tsx +4 -4
  112. package/src/DateInput/DateInput.tsx +15 -4
  113. package/src/DateRangeInput/DateRangeInput.test.tsx +2 -2
  114. package/src/DateTimeInput/DateTimeInput.test.tsx +6 -4
  115. package/src/DateTimeInput/DateTimeInput.tsx +18 -7
  116. package/src/DropdownMenu/DropdownMenuSelectable.test.tsx +4 -77
  117. package/src/Field/Field.test.tsx +42 -0
  118. package/src/Field/Field.tsx +6 -0
  119. package/src/Field/InputClearButton.test.tsx +35 -1
  120. package/src/Field/InputClearButton.tsx +7 -3
  121. package/src/Field/PanelSearchInput.tsx +21 -8
  122. package/src/FieldStatus/FieldStatus.spec.md +27 -17
  123. package/src/FieldStatus/FieldStatus.test.tsx +7 -5
  124. package/src/FieldStatus/__tests__/StatusMessage.a11y.chromium.spec.ts +198 -0
  125. package/src/FieldStatus/__tests__/StatusMessage.a11y.known-failures.ts +13 -0
  126. package/src/FieldStatus/__tests__/StatusMessage.a11y.renders.tsx +305 -0
  127. package/src/FieldStatus/__tests__/StatusMessage.a11y.states.ts +317 -0
  128. package/src/FieldStatus/__tests__/StatusMessage.a11y.test.tsx +155 -0
  129. package/src/FileInput/FileInput.tsx +10 -1
  130. package/src/FormLayout/__snapshots__/FormLayout.test.tsx.snap +3 -3
  131. package/src/HoverCard/HoverCard.doc.mjs +4 -4
  132. package/src/HoverCard/useHoverCard.tsx +2 -2
  133. package/src/Indicator/CheckboxIndicator.tsx +2 -2
  134. package/src/Indicator/Indicator.doc.mjs +2 -2
  135. package/src/Indicator/Indicator.test.tsx +1 -1
  136. package/src/Indicator/RadioIndicator.tsx +2 -2
  137. package/src/Layer/layerStack.ts +20 -9
  138. package/src/Layer/useLayerDismissal.ts +2 -3
  139. package/src/MultiSelector/MultiSelector.test.tsx +4 -4
  140. package/src/NavIcon/NavIcon.doc.mjs +4 -4
  141. package/src/NavIcon/NavIcon.tsx +2 -2
  142. package/src/NumberInput/NumberInput.tsx +18 -7
  143. package/src/Popover/Popover.doc.mjs +10 -10
  144. package/src/Popover/Popover.spec.md +55 -65
  145. package/src/Popover/Popover.test.tsx +29 -0
  146. package/src/Popover/usePopover.doc.mjs +4 -4
  147. package/src/Popover/usePopover.tsx +7 -4
  148. package/src/ProgressBar/ProgressBar.doc.mjs +4 -4
  149. package/src/ProgressBar/ProgressBar.test.tsx +1 -31
  150. package/src/ProgressBar/ProgressBar.tsx +2 -2
  151. package/src/RadioList/RadioList.test.tsx +5 -144
  152. package/src/RadioList/__tests__/RadioGroup.a11y.chromium.spec.ts +255 -0
  153. package/src/RadioList/__tests__/RadioGroup.a11y.known-failures.ts +12 -0
  154. package/src/RadioList/__tests__/RadioGroup.a11y.renders.tsx +232 -0
  155. package/src/RadioList/__tests__/RadioGroup.a11y.states.ts +503 -0
  156. package/src/RadioList/__tests__/RadioGroup.a11y.test.tsx +217 -0
  157. package/src/ScrollableArea/ScrollableArea.doc.mjs +100 -0
  158. package/src/ScrollableArea/ScrollableArea.spec.md +189 -0
  159. package/src/ScrollableArea/ScrollableArea.test.tsx +299 -0
  160. package/src/ScrollableArea/ScrollableArea.tsx +259 -0
  161. package/src/ScrollableArea/index.ts +26 -0
  162. package/src/ScrollableArea/modules/useScrollableArea.spec.md +121 -0
  163. package/src/SegmentedControl/SegmentedControl.test.tsx +5 -172
  164. package/src/Selector/Selector.test.tsx +4 -4
  165. package/src/Spinner/Spinner.test.tsx +0 -18
  166. package/src/StatusDot/StatusDot.doc.mjs +4 -4
  167. package/src/StatusDot/StatusDot.tsx +2 -2
  168. package/src/TabList/TabList.test.tsx +5 -9
  169. package/src/TabList/__tests__/Tabs.a11y.chromium.spec.ts +191 -0
  170. package/src/TabList/__tests__/Tabs.a11y.known-failures.ts +45 -0
  171. package/src/TabList/__tests__/Tabs.a11y.renders.tsx +92 -0
  172. package/src/TabList/__tests__/Tabs.a11y.states.ts +247 -0
  173. package/src/TabList/__tests__/Tabs.a11y.test.tsx +153 -0
  174. package/src/Table/Table.doc.mjs +2 -2
  175. package/src/TextArea/TextArea.doc.mjs +4 -4
  176. package/src/TextArea/TextArea.tsx +2 -2
  177. package/src/TextInput/TextInput.doc.mjs +2 -1
  178. package/src/TextInput/TextInput.test.tsx +94 -0
  179. package/src/TextInput/TextInput.tsx +22 -6
  180. package/src/TimeInput/TimeInput.tsx +18 -7
  181. package/src/Toast/ToastViewport.test.tsx +1 -39
  182. package/src/Typeahead/BaseTypeahead.doc.mjs +229 -33
  183. package/src/Typeahead/BaseTypeahead.spec.md +269 -0
  184. package/src/Typeahead/BaseTypeahead.test.tsx +200 -0
  185. package/src/Typeahead/BaseTypeahead.tsx +99 -30
  186. package/src/hooks/index.ts +13 -0
  187. package/src/hooks/scrollGeometry.ts +155 -0
  188. package/src/hooks/scrollOwnerRegistry.ts +47 -0
  189. package/src/hooks/useFocusTrap.ts +22 -11
  190. package/src/hooks/useFocusTrapEscapeShim.test.tsx +4 -3
  191. package/src/hooks/useScrollableArea.doc.mjs +108 -0
  192. package/src/hooks/useScrollableArea.test.tsx +437 -0
  193. package/src/hooks/useScrollableArea.ts +469 -0
  194. package/src/index.ts +1 -0
  195. package/src/theme/defineTheme.test.ts +65 -105
  196. package/src/theme/defineTheme.ts +3 -9
  197. package/src/theme/derivedVarRegistry.ts +1 -1
  198. package/src/theme/localTokens.ts +25 -96
  199. package/src/theme/publicThemeHelperContract.test.ts +2 -2
  200. package/src/theme/themeAdaptations.test.ts +16 -42
  201. package/src/theme/themeAdaptations.ts +6 -9
  202. package/src/utils/themeProps.test.ts +29 -10
  203. package/src/utils/themeProps.ts +36 -17
@@ -3,14 +3,14 @@
3
3
  /**
4
4
  * @file DropdownMenuSelectable.test.tsx
5
5
  * @input vitest, @testing-library/react, DropdownMenu + selectable items
6
- * @output Unit tests for DropdownMenuCheckboxItem / RadioGroup / RadioItem (#3829)
7
- * @position Component-local callback and composition coverage; shared checkbox
8
- * role, name, state, and interaction outcomes live in the reusable contract.
6
+ * @output Component-local callback, composition, and marker styling tests for
7
+ * DropdownMenuCheckboxItem / RadioGroup / RadioItem (#3829)
8
+ * @position Shared checkbox and radio-group role, name, state, and interaction
9
+ * outcomes live in their reusable contracts; Menu retains navigation.
9
10
  */
10
11
 
11
12
  import {describe, it, expect, vi, beforeEach} from 'vitest';
12
13
  import {render, screen} from '@testing-library/react';
13
- import {useState} from 'react';
14
14
  import userEvent from '@testing-library/user-event';
15
15
  import {DropdownMenu} from './DropdownMenu';
16
16
  import {DropdownMenuCheckboxItem} from './DropdownMenuCheckboxItem';
@@ -120,61 +120,6 @@ describe('DropdownMenuCheckboxItem', () => {
120
120
  });
121
121
 
122
122
  describe('DropdownMenuRadioGroup / RadioItem', () => {
123
- it('keeps option-2 unchecked until it is activated', async () => {
124
- const user = userEvent.setup();
125
-
126
- function RadioPreview() {
127
- const [value, setValue] = useState('option-1');
128
- return (
129
- <DropdownMenu button={{label: 'Sort'}}>
130
- <DropdownMenuRadioGroup
131
- value={value}
132
- onChange={setValue}
133
- label="Radio group">
134
- <DropdownMenuRadioItem value="option-2" label="Option 2" />
135
- </DropdownMenuRadioGroup>
136
- </DropdownMenu>
137
- );
138
- }
139
-
140
- render(<RadioPreview />);
141
- await user.click(screen.getByRole('button', {name: /Sort/}));
142
-
143
- const option = screen.getByRole('menuitemradio', {
144
- name: 'Option 2',
145
- hidden: true,
146
- });
147
- expect(option).toHaveAttribute('aria-checked', 'false');
148
-
149
- await user.click(option);
150
- expect(option).toHaveAttribute('aria-checked', 'true');
151
- });
152
-
153
- it('renders a named group with radios reflecting the selected value', async () => {
154
- const user = userEvent.setup();
155
- render(
156
- <DropdownMenu button={{label: 'Sort'}}>
157
- <DropdownMenuRadioGroup
158
- value="newest"
159
- onChange={() => {}}
160
- label="Sort by">
161
- <DropdownMenuRadioItem value="newest" label="Newest" />
162
- <DropdownMenuRadioItem value="oldest" label="Oldest" />
163
- </DropdownMenuRadioGroup>
164
- </DropdownMenu>,
165
- );
166
- await user.click(screen.getByRole('button', {name: /Sort/}));
167
- expect(
168
- screen.getByRole('menuitemradio', {name: 'Newest', hidden: true}),
169
- ).toHaveAttribute('aria-checked', 'true');
170
- expect(
171
- screen.getByRole('menuitemradio', {name: 'Oldest', hidden: true}),
172
- ).toHaveAttribute('aria-checked', 'false');
173
- expect(
174
- screen.getByRole('group', {name: 'Sort by', hidden: true}),
175
- ).toBeInTheDocument();
176
- });
177
-
178
123
  it('renders the shared radio indicator in the menu marker', async () => {
179
124
  const user = userEvent.setup();
180
125
  render(
@@ -236,24 +181,6 @@ describe('DropdownMenuRadioGroup / RadioItem', () => {
236
181
  expect(onChange).toHaveBeenCalledWith('oldest');
237
182
  });
238
183
 
239
- it('names the group from the required label prop', async () => {
240
- const user = userEvent.setup();
241
- render(
242
- <DropdownMenu button={{label: 'Sort'}}>
243
- <DropdownMenuRadioGroup
244
- value="newest"
245
- onChange={() => {}}
246
- label="Sort by">
247
- <DropdownMenuRadioItem value="newest" label="Newest" />
248
- </DropdownMenuRadioGroup>
249
- </DropdownMenu>,
250
- );
251
- await user.click(screen.getByRole('button', {name: /Sort/}));
252
- expect(
253
- screen.getByRole('group', {name: 'Sort by', hidden: true}),
254
- ).toBeInTheDocument();
255
- });
256
-
257
184
  it('throws when a radio item is used outside a group', () => {
258
185
  const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
259
186
  expect(() =>
@@ -538,4 +538,46 @@ describe('Field', () => {
538
538
  expect(labelWrapper.querySelector('label')).not.toBeNull();
539
539
  });
540
540
  });
541
+
542
+ describe('local stacking ownership', () => {
543
+ // Input wrappers paint their surface above the attached status message
544
+ // box through a local z-index, so that layer must be contained by a
545
+ // Field-owned isolation boundary. Otherwise a detached or tooltip field
546
+ // — whose input wrapper renders outside the attached-status wrapper —
547
+ // competes with page-level stacking (AppShell sticky header, later
548
+ // siblings) and can paint over unrelated chrome (#5689).
549
+ it.each(['attached', 'detached', 'tooltip'] as const)(
550
+ 'isolates the field surface for the %s status variant',
551
+ variant => {
552
+ render(
553
+ <Field
554
+ label="Name"
555
+ inputID="name-input"
556
+ status={{type: 'error', message: 'Required'}}
557
+ statusVariant={variant}
558
+ data-testid="field">
559
+ <input id="name-input" data-testid="control" />
560
+ </Field>,
561
+ );
562
+ const field = screen.getByTestId('field');
563
+ // The Field root is the isolation owner: it stays at its normal
564
+ // parent paint level (it carries no positioning or z-index of its
565
+ // own) while containing every local layer — the input wrapper's 1
566
+ // and the attached status -1.
567
+ expect(getComputedStyle(field).isolation).toBe('isolate');
568
+ // Unpositioned and unranked — the owner never competes itself.
569
+ expect(getComputedStyle(field).zIndex).toBe('');
570
+ },
571
+ );
572
+
573
+ it('keeps a custom control without status isolated too', () => {
574
+ render(
575
+ <Field label="Name" inputID="name-input" data-testid="field">
576
+ <input id="name-input" data-testid="control" />
577
+ </Field>,
578
+ );
579
+ const field = screen.getByTestId('field');
580
+ expect(getComputedStyle(field).isolation).toBe('isolate');
581
+ });
582
+ });
541
583
  });
@@ -35,6 +35,12 @@ const styles = stylex.create({
35
35
  container: {
36
36
  display: 'flex',
37
37
  flexDirection: 'column',
38
+ // The Field root owns the local stacking boundary (AST-027): the input
39
+ // wrapper's z-index (1, above the attached status box) and the attached
40
+ // status layer (-1) order parts inside this surface only. Without this,
41
+ // detached/tooltip fields — whose input wrapper renders outside the
42
+ // attached-status wrapper — compete with page-level stacking (#5689).
43
+ isolation: 'isolate',
38
44
  },
39
45
  containerGap: {
40
46
  gap: spacingVars['--spacing-1'],
@@ -19,7 +19,11 @@ import path from 'node:path';
19
19
  import {describe, it, expect, vi, afterEach} from 'vitest';
20
20
  import {render, screen, fireEvent} from '@testing-library/react';
21
21
  import * as stylex from '@stylexjs/stylex';
22
- import {InputClearButton, type InputClearButtonProps} from './InputClearButton';
22
+ import {
23
+ InputClearButton,
24
+ InternalInputClearButton,
25
+ type InputClearButtonProps,
26
+ } from './InputClearButton';
23
27
  import {Icon, registerIcons, resetIcons} from '../Icon';
24
28
  import {defineTheme} from '../theme/defineTheme';
25
29
  import {generateThemeCSS} from '../theme/generateThemeRules';
@@ -323,3 +327,33 @@ describe('InputClearButton glyph source', () => {
323
327
  expect(getGlyph()).toContainElement(screen.getByTestId('custom-close'));
324
328
  });
325
329
  });
330
+
331
+ describe('InputClearButton pointer and focus interactions', () => {
332
+ it('prevents default on pointerdown and mousedown to keep focus on input', () => {
333
+ render(<InputClearButton label="Clear" onClick={() => {}} />);
334
+ const button = screen.getByRole('button', {name: 'Clear'});
335
+
336
+ const pointerDownEvent = fireEvent.pointerDown(button);
337
+ expect(pointerDownEvent).toBe(false);
338
+
339
+ const mouseDownEvent = fireEvent.mouseDown(button);
340
+ expect(mouseDownEvent).toBe(false);
341
+ });
342
+
343
+ it('composes custom onPointerDown while preventing default in InternalInputClearButton', () => {
344
+ const handlePointerDown = vi.fn();
345
+ render(
346
+ <InternalInputClearButton
347
+ label="Clear"
348
+ onClick={() => {}}
349
+ onPointerDown={handlePointerDown}
350
+ onClickCapture={() => {}}
351
+ />,
352
+ );
353
+ const button = screen.getByRole('button', {name: 'Clear'});
354
+ const pointerDownEvent = fireEvent.pointerDown(button);
355
+
356
+ expect(handlePointerDown).toHaveBeenCalledTimes(1);
357
+ expect(pointerDownEvent).toBe(false);
358
+ });
359
+ });
@@ -73,8 +73,8 @@ export interface InputClearButtonProps {
73
73
  * Extra class(es) for the clear glyph itself, merged onto the shared
74
74
  * `astryx-input-clear-icon` target. Used by inputs that shipped a
75
75
  * component-specific clear-icon target before the family converged here
76
- * (e.g. `astryx-date-input-clear-icon`) to keep emitting it for a
77
- * deprecation window; new callers don't need it.
76
+ * (e.g. `astryx-date-input-clear-icon`) to keep emitting it for backwards
77
+ * compatibility; new callers don't need it.
78
78
  */
79
79
  iconClassName?: string;
80
80
  }
@@ -117,8 +117,12 @@ function renderInputClearButton({
117
117
  }
118
118
  />
119
119
  }
120
+ onPointerDown={e => {
121
+ e.preventDefault();
122
+ onPointerDown?.(e);
123
+ }}
124
+ onMouseDown={e => e.preventDefault()}
120
125
  onClick={onClick}
121
- onPointerDown={onPointerDown}
122
126
  onClickCapture={onClickCapture}
123
127
  isIconOnly
124
128
  xstyle={[styles.button, xstyle]}
@@ -225,14 +225,27 @@ export function PanelSearchInput({
225
225
  [onBlur],
226
226
  );
227
227
 
228
- const handleClear = useCallback(() => {
229
- onValueChange('');
230
- // Clearing puts the caret back where the user was typing, matching
231
- // TextInput's built-in clear.
232
- if (typeof ref === 'object' && ref?.current) {
233
- ref.current.focus();
234
- }
235
- }, [onValueChange, ref]);
228
+ const handleClear = useCallback(
229
+ (e?: React.MouseEvent<HTMLButtonElement>) => {
230
+ onValueChange('');
231
+ // Clearing puts the caret back where the user was typing, matching
232
+ // TextInput's built-in clear. Defer focus restoration past the button's
233
+ // unmount task so touch browsers don't jump page scroll on tap, while
234
+ // preserving synchronous focus for keyboard users.
235
+ if (!e || e.detail === 0) {
236
+ if (typeof ref === 'object' && ref?.current) {
237
+ ref.current.focus();
238
+ }
239
+ } else {
240
+ requestAnimationFrame(() => {
241
+ if (typeof ref === 'object' && ref?.current) {
242
+ ref.current.focus({preventScroll: true});
243
+ }
244
+ });
245
+ }
246
+ },
247
+ [onValueChange, ref],
248
+ );
236
249
 
237
250
  return (
238
251
  <div
@@ -13,6 +13,8 @@ review_triggers: [public-api, behavior, theming, accessibility]
13
13
  verified_by:
14
14
  [
15
15
  packages/core/src/FieldStatus/FieldStatus.test.tsx,
16
+ packages/core/src/FieldStatus/__tests__/StatusMessage.a11y.test.tsx,
17
+ packages/core/src/FieldStatus/__tests__/StatusMessage.a11y.chromium.spec.ts,
16
18
  scripts/check-knowledge.mjs,
17
19
  ]
18
20
  modules: []
@@ -20,7 +22,7 @@ families: []
20
22
  design_specs: []
21
23
  architecture: [architecture:component-theming-surface]
22
24
  contributing: []
23
- system_specs: []
25
+ system_specs: [spec:AST-009, spec:AST-020, spec:AST-021]
24
26
  ---
25
27
 
26
28
  # FieldStatus component contract
@@ -116,23 +118,28 @@ intentional decision.
116
118
 
117
119
  ### Performance and resources
118
120
 
119
- - **PR1 — Announcement work.** A message change schedules only the persistent
120
- live-region announcement; FieldStatus owns no global event listeners,
121
- observers, or measurements.
121
+ - **PR1 — Channel-update work.** A message change schedules only the persistent
122
+ live-region update; FieldStatus owns no global event listeners, observers, or
123
+ measurements.
122
124
 
123
125
  ## Accessibility contract
124
126
 
125
- - **AR1 — Announcement channel.** Errors MUST announce through the persistent
126
- assertive region; warnings and successes MUST announce through the persistent
127
- polite region, including on first mount and when message or type changes.
127
+ - **AR1 — Programmatic announcement channel.** Errors MUST update the persistent
128
+ assertive region; warnings and successes MUST update the persistent polite
129
+ region, including on first mount and when message or type changes, without
130
+ moving focus.
131
+ - **AR5 — Assistive-technology output.** Claims about whether, when, or how the
132
+ message is spoken or presented in braille MUST be verified through the real-AT
133
+ evidence process in `spec:AST-009`; DOM and accessibility-tree checks prove
134
+ only programmatic exposure and routing.
128
135
  - **AR2 — Visible description.** The rendered message box MUST remain available
129
136
  to assistive technology for `aria-describedby` association and MUST NOT become
130
137
  its own newly mounted live region.
131
138
  - **AR3 — Non-color cue.** Detached status MUST include a leading status glyph so
132
139
  status is not conveyed only by color or position.
133
140
  - **AR4 — Redundant glyph.** The detached glyph MUST remain hidden from assistive
134
- technology because the visible message already conveys and announces the
135
- status.
141
+ technology because the visible message already conveys the status in text;
142
+ announcement output remains owned by AR5.
136
143
 
137
144
  ## Design relationships
138
145
 
@@ -140,7 +147,7 @@ intentional decision.
140
147
  | ---------------- | ---------------------------------------------------------- | ----------------------------------------------- | -------------- | ------------------ |
141
148
  | Message box | Carries the visible validation treatment. | Current behavior and approved component intent. | Supporting | FR1, FR3 |
142
149
  | Detached icon | Adds a non-color status cue only in detached presentation. | Current behavior and approved component intent. | Supporting | FR2, FR4, AR3 |
143
- | Message text | Communicates the validation result in words. | Current behavior and approved component intent. | Prominent | FR1, AR1, AR2 |
150
+ | Message text | Communicates the validation result in words. | Current behavior and approved component intent. | Prominent | FR1, AR1, AR2, AR5 |
144
151
 
145
152
  Current behavior renders the detached icon before message text. The intended
146
153
  public theming ownership is the exact map below; it does not add a target or
@@ -167,13 +174,16 @@ change either presentation.
167
174
 
168
175
  ## Verification map
169
176
 
170
- | Contract | Verification | Representative states | Mutation or failure expectation | Audit section |
171
- | ------------------- | --------------------------------------------------------- | ------------------------------------------------- | --------------------------------------------------------------------------------------- | --------------------------------- |
172
- | FR1, FR2, AR3, AR4 | `FieldStatus.test.tsx` rendering and detached-icon suites | Attached and detached; all status types | Removing or reordering presentation parts fails DOM and accessibility assertions. | `audit:FieldStatus/anatomy` |
173
- | FR3, FR4 | `FieldStatus.test.tsx` theme target suites | Attached/detached; error/warning/success | Removing a target or status reflection fails class/data-attribute assertions. | `audit:FieldStatus/theming` |
174
- | 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` |
175
- | AR1, AR2 | `FieldStatus.test.tsx` announcement suites | Mount, message update, type update, empty message | Reintroducing a local live region or misrouting severity fails announcement assertions. | `audit:FieldStatus/accessibility` |
176
- | 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` |
177
+ | Contract | Verification | Representative states | Mutation or failure expectation | Audit section |
178
+ | ------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- |
179
+ | FR1, FR2, AR3, AR4 | `FieldStatus.test.tsx` rendering and detached-icon suites | Attached and detached; all status types | Removing or reordering presentation parts fails DOM and accessibility assertions. | `audit:FieldStatus/anatomy` |
180
+ | FR3, FR4 | `FieldStatus.test.tsx` theme target suites | Attached/detached; error/warning/success | Removing a target or status reflection fails class/data-attribute assertions. | `audit:FieldStatus/theming` |
181
+ | 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` |
182
+ | 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` |
183
+ | 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` |
184
+ | 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
+ | 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` |
186
+ | 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` |
177
187
 
178
188
  ## Decision log
179
189
 
@@ -53,7 +53,8 @@ describe('FieldStatus', () => {
53
53
  expect(el).not.toHaveAttribute('aria-live');
54
54
  });
55
55
 
56
- // Errors are urgent — they interrupt via the assertive channel.
56
+ // These remain local because they exercise FieldStatus's first-use hook
57
+ // routing. The shared binding starts from an already established channel.
57
58
  it('announces error messages assertively, including on first mount', async () => {
58
59
  render(<FieldStatus type="error" message="This field is required" />);
59
60
  await waitFor(() => {
@@ -62,7 +63,7 @@ describe('FieldStatus', () => {
62
63
  expect(politeRegion()).toHaveTextContent('');
63
64
  });
64
65
 
65
- it('announces warning messages politely', async () => {
66
+ it('announces warning messages politely on first mount', async () => {
66
67
  render(<FieldStatus type="warning" message="Check this value" />);
67
68
  await waitFor(() => {
68
69
  expect(politeRegion()).toHaveTextContent('Check this value');
@@ -70,14 +71,14 @@ describe('FieldStatus', () => {
70
71
  expect(assertiveRegion()).toHaveTextContent('');
71
72
  });
72
73
 
73
- it('announces success messages politely', async () => {
74
+ it('announces success messages politely on first mount', async () => {
74
75
  render(<FieldStatus type="success" message="Looks good" />);
75
76
  await waitFor(() => {
76
77
  expect(politeRegion()).toHaveTextContent('Looks good');
77
78
  });
78
79
  });
79
80
 
80
- it('announces message changes', async () => {
81
+ it('announces message changes through the component hook', async () => {
81
82
  const {rerender} = render(<FieldStatus type="error" message="First" />);
82
83
  await waitFor(() => {
83
84
  expect(assertiveRegion()).toHaveTextContent('First');
@@ -88,7 +89,8 @@ describe('FieldStatus', () => {
88
89
  });
89
90
  });
90
91
 
91
- // Severity changes re-route the announcement to the matching channel.
92
+ // The generic contract checks each fixed urgency. This local test protects
93
+ // the component-specific same-instance type reroute.
92
94
  it('re-routes to the polite channel when type changes from error', async () => {
93
95
  const {rerender} = render(<FieldStatus type="error" message="msg" />);
94
96
  await waitFor(() => {
@@ -0,0 +1,198 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file StatusMessage.a11y.chromium.spec.ts
5
+ * @input Uses the shared status-message contract, checked-in stories, Core state inventory, and exact known failures
6
+ * @output Real-browser and accessibility-tree evidence for every current Core status-message binding
7
+ * @position Browser migration lane; it makes no claim about assistive-technology speech or timing
8
+ */
9
+
10
+ import {
11
+ expect,
12
+ test,
13
+ type CDPSession,
14
+ type Locator,
15
+ type Page,
16
+ } from '@playwright/test';
17
+ import {
18
+ STATUS_MESSAGE_PATTERN,
19
+ blockingResults,
20
+ checkAccessibilitySpec,
21
+ formatFailures,
22
+ formatReport,
23
+ neverExercised,
24
+ summarize,
25
+ unmatchedKnownFailures,
26
+ type BindingResult,
27
+ } from '@astryxdesign/a11y-spec';
28
+ import {
29
+ createChromiumHarness,
30
+ holdMotionStill,
31
+ } from '@astryxdesign/a11y-spec/chromium';
32
+ import {
33
+ DEFAULT_STORYBOOK_DIR,
34
+ serveStorybook,
35
+ type StaticServer,
36
+ } from '@astryxdesign/a11y-spec/storybook';
37
+ import {CORE_STATUS_MESSAGE_KNOWN_FAILURES} from './StatusMessage.a11y.known-failures';
38
+ import {
39
+ CORE_STATUS_MESSAGE_BINDING_STATES,
40
+ type CoreStatusMessageBindingState,
41
+ } from './StatusMessage.a11y.states';
42
+
43
+ function transitionTestId(name: string): string {
44
+ return `status-transition-${name}`;
45
+ }
46
+
47
+ let storybook: StaticServer;
48
+
49
+ test.beforeAll(async () => {
50
+ storybook = await serveStorybook(
51
+ process.env.ASTRYX_STORYBOOK_DIR ?? DEFAULT_STORYBOOK_DIR,
52
+ );
53
+ });
54
+
55
+ test.afterAll(async () => {
56
+ await storybook?.close();
57
+ });
58
+
59
+ function storyUrl(storyId: string): string {
60
+ return `${storybook.origin}/iframe.html?id=${storyId}&viewMode=story`;
61
+ }
62
+
63
+ const BOUND_SUBJECT_ATTRIBUTE = 'data-a11y-binding-subject';
64
+
65
+ function subjectFor(page: Page): Locator {
66
+ return page.locator(`[${BOUND_SUBJECT_ATTRIBUTE}]`);
67
+ }
68
+
69
+ async function bindSubject(
70
+ page: Page,
71
+ state: CoreStatusMessageBindingState,
72
+ ): Promise<void> {
73
+ const facts = state.facts;
74
+ const role = facts.kind === 'progressbar' ? 'progressbar' : facts.role;
75
+ if (role == null) {
76
+ throw new Error(
77
+ `${state.id}: this component binding declares no public role`,
78
+ );
79
+ }
80
+ const candidates = page.getByRole(role, {includeHidden: true});
81
+ let target: Locator;
82
+ if (facts.kind === 'progressbar') {
83
+ target = page.getByRole(role, {includeHidden: true, name: facts.name});
84
+ } else if (facts.messageSource === 'accessible-name') {
85
+ target = page.getByRole(role, {
86
+ includeHidden: true,
87
+ name: facts.initialMessage,
88
+ });
89
+ } else {
90
+ const texts = await candidates.evaluateAll(elements =>
91
+ elements.map(element =>
92
+ (element.textContent ?? '').replace(/\s+/g, ' ').trim(),
93
+ ),
94
+ );
95
+ const indexes = texts.flatMap((text, index) =>
96
+ text === facts.initialMessage ? [index] : [],
97
+ );
98
+ if (indexes.length !== 1) {
99
+ throw new Error(
100
+ `${state.id}: expected one ${role} subject in initial state, found ${indexes.length}`,
101
+ );
102
+ }
103
+ target = candidates.nth(indexes[0]);
104
+ }
105
+ await expect(target).toHaveCount(1);
106
+ await target.evaluate((element, attribute) => {
107
+ element.setAttribute(attribute, '');
108
+ }, BOUND_SUBJECT_ATTRIBUTE);
109
+ }
110
+
111
+ async function mountState(
112
+ page: Page,
113
+ state: CoreStatusMessageBindingState,
114
+ ): Promise<void> {
115
+ await page.goto(storyUrl(state.storyId), {waitUntil: 'load'});
116
+ await holdMotionStill(page);
117
+ await page.locator('[data-a11y-ready="true"]').waitFor({state: 'attached'});
118
+ await bindSubject(page, state);
119
+ await subjectFor(page).waitFor({state: 'attached'});
120
+ await expect(subjectFor(page)).toHaveCount(1);
121
+ if ('focusSelector' in state) {
122
+ await expect(page.locator(state.focusSelector)).toHaveAttribute(
123
+ 'aria-describedby',
124
+ /\S+/,
125
+ );
126
+ }
127
+ }
128
+
129
+ async function transition(page: Page, name: string): Promise<void> {
130
+ await page
131
+ .getByTestId(transitionTestId(name))
132
+ .evaluate(element => (element as HTMLElement).click());
133
+ await page.evaluate(
134
+ async () =>
135
+ new Promise<void>(resolve =>
136
+ requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
137
+ ),
138
+ );
139
+ }
140
+
141
+ async function runState(
142
+ page: Page,
143
+ cdp: CDPSession,
144
+ state: CoreStatusMessageBindingState,
145
+ ): Promise<BindingResult> {
146
+ return checkAccessibilitySpec({
147
+ spec: STATUS_MESSAGE_PATTERN,
148
+ binding: state.binding,
149
+ state: state.id,
150
+ facts: state.facts,
151
+ knownFailures: CORE_STATUS_MESSAGE_KNOWN_FAILURES,
152
+ mount: async () => {
153
+ await mountState(page, state);
154
+ return createChromiumHarness({
155
+ page,
156
+ cdp,
157
+ subject: subjectFor(page),
158
+ related: {
159
+ 'focus-anchor': page.locator(
160
+ 'focusSelector' in state
161
+ ? state.focusSelector
162
+ : '[data-a11y-relation="focus-anchor"]',
163
+ ),
164
+ },
165
+ });
166
+ },
167
+ transition: async name => transition(page, name),
168
+ });
169
+ }
170
+
171
+ test('every expectation is exercised by at least one Core binding state', async ({
172
+ page,
173
+ }) => {
174
+ test.setTimeout(4 * 60 * 1000);
175
+ const cdp = await page.context().newCDPSession(page);
176
+ const results: BindingResult[] = [];
177
+ for (const state of CORE_STATUS_MESSAGE_BINDING_STATES) {
178
+ results.push(await runState(page, cdp, state));
179
+ }
180
+ expect(neverExercised(results)).toEqual([]);
181
+ expect(
182
+ unmatchedKnownFailures(CORE_STATUS_MESSAGE_KNOWN_FAILURES, results),
183
+ ).toEqual([]);
184
+ });
185
+
186
+ for (const state of CORE_STATUS_MESSAGE_BINDING_STATES) {
187
+ test(`${state.binding} [${state.id}] — ${state.summary}`, async ({page}) => {
188
+ test.setTimeout(2 * 60 * 1000);
189
+ const cdp = await page.context().newCDPSession(page);
190
+ const result = await runState(page, cdp, state);
191
+ const report = summarize(STATUS_MESSAGE_PATTERN, [result]);
192
+ // eslint-disable-next-line no-console -- the report is this run's artifact
193
+ console.log(formatReport(report));
194
+ expect(formatFailures(blockingResults([result]))).toBe('');
195
+ expect(report.unrunLayers).toEqual([]);
196
+ expect(report.counts.unexpectedPass).toBe(0);
197
+ });
198
+ }
@@ -0,0 +1,13 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file StatusMessage.a11y.known-failures.ts
5
+ * @input Uses the exact known-failure vocabulary from the shared runner
6
+ * @output Public-safe Core status-message debt records (currently none)
7
+ * @position Migration debt only; operational ownership is maintained outside this public repository
8
+ */
9
+
10
+ import type {KnownFailure} from '@astryxdesign/a11y-spec';
11
+
12
+ export const CORE_STATUS_MESSAGE_KNOWN_FAILURES: ReadonlyArray<KnownFailure> =
13
+ [];