@astryxdesign/core 0.4.6 → 0.4.7-canary.29be96d

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 (197) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/BottomSheet/BottomSheet.d.ts +1 -0
  3. package/dist/BottomSheet/BottomSheet.d.ts.map +1 -1
  4. package/dist/BottomSheet/BottomSheet.js +54 -15
  5. package/dist/BottomSheet/BottomSheetEdgeTint.d.ts +6 -0
  6. package/dist/BottomSheet/BottomSheetEdgeTint.d.ts.map +1 -0
  7. package/dist/BottomSheet/BottomSheetEdgeTint.js +62 -0
  8. package/dist/BottomSheet/BottomSheetPanel.d.ts.map +1 -1
  9. package/dist/BottomSheet/BottomSheetPanel.js +1 -0
  10. package/dist/BottomSheet/BottomSheetSwitcher.d.ts +1 -0
  11. package/dist/BottomSheet/BottomSheetSwitcher.d.ts.map +1 -1
  12. package/dist/BottomSheet/BottomSheetSwitcher.js +10 -4
  13. package/dist/BottomSheet/useSheetGestures.d.ts.map +1 -1
  14. package/dist/BottomSheet/useSheetGestures.js +23 -5
  15. package/dist/Breadcrumbs/BreadcrumbItem.d.ts.map +1 -1
  16. package/dist/Breadcrumbs/BreadcrumbItem.js +5 -5
  17. package/dist/Calendar/Calendar.d.ts +3 -1
  18. package/dist/Calendar/Calendar.d.ts.map +1 -1
  19. package/dist/Calendar/Calendar.js +13 -14
  20. package/dist/Calendar/getInitialFocusDate.d.ts +46 -0
  21. package/dist/Calendar/getInitialFocusDate.d.ts.map +1 -0
  22. package/dist/Calendar/getInitialFocusDate.js +62 -0
  23. package/dist/Chat/ChatMessageList.d.ts +22 -3
  24. package/dist/Chat/ChatMessageList.d.ts.map +1 -1
  25. package/dist/Chat/ChatMessageList.js +6 -3
  26. package/dist/DateInput/TouchDateField.d.ts.map +1 -1
  27. package/dist/DateInput/TouchDateField.js +34 -1
  28. package/dist/Dialog/Dialog.d.ts +7 -1
  29. package/dist/Dialog/Dialog.d.ts.map +1 -1
  30. package/dist/Dialog/Dialog.js +48 -22
  31. package/dist/Heading/Heading.d.ts.map +1 -1
  32. package/dist/Heading/Heading.js +6 -2
  33. package/dist/Kbd/Kbd.d.ts.map +1 -1
  34. package/dist/Kbd/Kbd.js +10 -3
  35. package/dist/Markdown/index.d.ts +1 -1
  36. package/dist/Markdown/index.d.ts.map +1 -1
  37. package/dist/Markdown/parser.d.ts +38 -1
  38. package/dist/Markdown/parser.d.ts.map +1 -1
  39. package/dist/Markdown/parser.js +149 -23
  40. package/dist/MultiSelector/MultiSelector.d.ts +15 -1
  41. package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
  42. package/dist/MultiSelector/MultiSelector.js +17 -6
  43. package/dist/MultiSelector/index.d.ts +1 -1
  44. package/dist/MultiSelector/index.d.ts.map +1 -1
  45. package/dist/Selector/Selector.d.ts.map +1 -1
  46. package/dist/Selector/Selector.js +5 -0
  47. package/dist/Stepper/Step.d.ts +140 -0
  48. package/dist/Stepper/Step.d.ts.map +1 -0
  49. package/dist/Stepper/Step.js +1045 -0
  50. package/dist/Stepper/StepStatus.d.ts +24 -0
  51. package/dist/Stepper/StepStatus.d.ts.map +1 -0
  52. package/dist/Stepper/StepStatus.js +1 -0
  53. package/dist/Stepper/Stepper.d.ts +93 -0
  54. package/dist/Stepper/Stepper.d.ts.map +1 -0
  55. package/dist/Stepper/Stepper.js +184 -0
  56. package/dist/Stepper/StepperContext.d.ts +41 -0
  57. package/dist/Stepper/StepperContext.d.ts.map +1 -0
  58. package/dist/Stepper/StepperContext.js +34 -0
  59. package/dist/Stepper/index.d.ts +8 -0
  60. package/dist/Stepper/index.d.ts.map +1 -0
  61. package/dist/Stepper/index.js +7 -0
  62. package/dist/Stepper/stepper.stylex.d.ts +15 -0
  63. package/dist/Stepper/stepper.stylex.d.ts.map +1 -0
  64. package/dist/Stepper/stepper.stylex.js +20 -0
  65. package/dist/TabList/Tab.d.ts.map +1 -1
  66. package/dist/TabList/Tab.js +5 -1
  67. package/dist/Table/BaseTable.d.ts.map +1 -1
  68. package/dist/Table/BaseTable.js +4 -1
  69. package/dist/Table/plugins/groupedRows/useTableGroupedRows.d.ts.map +1 -1
  70. package/dist/Table/plugins/groupedRows/useTableGroupedRows.js +20 -8
  71. package/dist/Table/plugins/rowStatus/useTableRowStatus.d.ts.map +1 -1
  72. package/dist/Table/plugins/rowStatus/useTableRowStatus.js +10 -3
  73. package/dist/Table/plugins/selection/useTableSelection.d.ts +16 -0
  74. package/dist/Table/plugins/selection/useTableSelection.d.ts.map +1 -1
  75. package/dist/Table/plugins/selection/useTableSelection.js +19 -5
  76. package/dist/Table/types.d.ts +22 -4
  77. package/dist/Table/types.d.ts.map +1 -1
  78. package/dist/Table/useBaseTablePlugins.d.ts.map +1 -1
  79. package/dist/Table/useBaseTablePlugins.js +5 -0
  80. package/dist/Text/Text.d.ts.map +1 -1
  81. package/dist/Text/Text.js +6 -2
  82. package/dist/astryx.css +41 -1
  83. package/dist/hooks/index.d.ts +1 -0
  84. package/dist/hooks/index.d.ts.map +1 -1
  85. package/dist/hooks/index.js +1 -0
  86. package/dist/hooks/useHotkeys.d.ts.map +1 -1
  87. package/dist/hooks/useHotkeys.js +10 -3
  88. package/dist/hooks/useListFocus.d.ts +5 -2
  89. package/dist/hooks/useListFocus.d.ts.map +1 -1
  90. package/dist/hooks/useListFocus.js +12 -6
  91. package/dist/hooks/useMergedRefs.d.ts +18 -0
  92. package/dist/hooks/useMergedRefs.d.ts.map +1 -0
  93. package/dist/hooks/useMergedRefs.js +25 -0
  94. package/dist/index.d.ts +1 -0
  95. package/dist/index.d.ts.map +1 -1
  96. package/dist/index.js +1 -0
  97. package/dist/utils/timeParser.d.ts +1 -1
  98. package/dist/utils/timeParser.d.ts.map +1 -1
  99. package/dist/utils/timeParser.js +26 -12
  100. package/locales/en.json +14 -6
  101. package/locales/pseudo.json +6 -0
  102. package/package.json +8 -3
  103. package/src/Avatar/Avatar.doc.mjs +2 -1
  104. package/src/BottomSheet/BottomSheet.test.tsx +123 -0
  105. package/src/BottomSheet/BottomSheet.tsx +36 -4
  106. package/src/BottomSheet/BottomSheetEdgeTint.test.tsx +225 -0
  107. package/src/BottomSheet/BottomSheetEdgeTint.tsx +82 -0
  108. package/src/BottomSheet/BottomSheetPanel.test.tsx +66 -0
  109. package/src/BottomSheet/BottomSheetPanel.tsx +19 -0
  110. package/src/BottomSheet/BottomSheetSwitcher.tsx +13 -0
  111. package/src/BottomSheet/useSheetGestures.test.ts +27 -0
  112. package/src/BottomSheet/useSheetGestures.ts +25 -5
  113. package/src/Breadcrumbs/BreadcrumbItem.tsx +2 -1
  114. package/src/Button/Button.doc.mjs +22 -0
  115. package/src/Calendar/Calendar.doc.mjs +4 -3
  116. package/src/Calendar/Calendar.test.tsx +52 -0
  117. package/src/Calendar/Calendar.tsx +18 -15
  118. package/src/Calendar/getInitialFocusDate.test.ts +94 -0
  119. package/src/Calendar/getInitialFocusDate.ts +93 -0
  120. package/src/Chat/ChatMessageList.doc.mjs +9 -1
  121. package/src/Chat/ChatMessageList.test.tsx +46 -0
  122. package/src/Chat/ChatMessageList.tsx +28 -4
  123. package/src/CodeBlock/CodeBlock.doc.mjs +6 -0
  124. package/src/ContextMenu/ContextMenu.doc.mjs +5 -0
  125. package/src/DateInput/DateInputTouch.test.tsx +36 -0
  126. package/src/DateInput/TouchDateField.tsx +35 -1
  127. package/src/Dialog/Dialog.doc.mjs +8 -3
  128. package/src/Dialog/Dialog.test.tsx +71 -0
  129. package/src/Dialog/Dialog.tsx +72 -20
  130. package/src/Field/Field.doc.mjs +11 -0
  131. package/src/Heading/Heading.test.tsx +23 -1
  132. package/src/Heading/Heading.tsx +6 -2
  133. package/src/Kbd/Kbd.test.tsx +17 -0
  134. package/src/Kbd/Kbd.tsx +10 -3
  135. package/src/Link/Link.doc.mjs +11 -0
  136. package/src/Markdown/index.ts +1 -0
  137. package/src/Markdown/parser.perf.test.ts +71 -1
  138. package/src/Markdown/parser.test.ts +145 -2
  139. package/src/Markdown/parser.ts +208 -22
  140. package/src/MoreMenu/MoreMenu.doc.mjs +7 -1
  141. package/src/MultiSelector/MultiSelector.doc.mjs +10 -0
  142. package/src/MultiSelector/MultiSelector.test.tsx +105 -0
  143. package/src/MultiSelector/MultiSelector.tsx +50 -6
  144. package/src/MultiSelector/index.ts +1 -0
  145. package/src/NumberInput/NumberInput.doc.mjs +5 -0
  146. package/src/Popover/Popover.test.tsx +27 -1
  147. package/src/PowerSearch/PowerSearch.doc.mjs +10 -0
  148. package/src/Selector/Selector.doc.mjs +11 -0
  149. package/src/Selector/Selector.test.tsx +21 -0
  150. package/src/Selector/Selector.tsx +11 -1
  151. package/src/Stepper/Step.doc.mjs +98 -0
  152. package/src/Stepper/Step.tsx +1638 -0
  153. package/src/Stepper/StepStatus.ts +26 -0
  154. package/src/Stepper/Stepper.doc.mjs +370 -0
  155. package/src/Stepper/Stepper.test.tsx +1024 -0
  156. package/src/Stepper/Stepper.tsx +258 -0
  157. package/src/Stepper/StepperContext.ts +71 -0
  158. package/src/Stepper/index.ts +18 -0
  159. package/src/Stepper/stepper.stylex.ts +19 -0
  160. package/src/TabList/Tab.tsx +5 -1
  161. package/src/TabList/TabList.test.tsx +21 -4
  162. package/src/Table/BaseTable.tsx +6 -3
  163. package/src/Table/Table.test.tsx +35 -0
  164. package/src/Table/plugins/groupedRows/useTableGroupedRows-perf.test.tsx +112 -0
  165. package/src/Table/plugins/groupedRows/useTableGroupedRows.test.tsx +100 -0
  166. package/src/Table/plugins/groupedRows/useTableGroupedRows.tsx +17 -8
  167. package/src/Table/plugins/rowStatus/useTableRowStatus.test.tsx +13 -7
  168. package/src/Table/plugins/rowStatus/useTableRowStatus.tsx +12 -3
  169. package/src/Table/plugins/selection/useTableSelection.test.tsx +76 -0
  170. package/src/Table/plugins/selection/useTableSelection.tsx +40 -7
  171. package/src/Table/types.ts +22 -4
  172. package/src/Table/useBaseTablePlugins.ts +5 -0
  173. package/src/Table/useTableGroupedRows.doc.mjs +5 -4
  174. package/src/Table/useTableRowStatus.doc.mjs +1 -1
  175. package/src/Table/useTableSelection.doc.mjs +31 -0
  176. package/src/Text/Text.test.tsx +23 -1
  177. package/src/Text/Text.tsx +6 -2
  178. package/src/TextInput/TextInput.doc.mjs +10 -0
  179. package/src/Toast/Toast.doc.mjs +6 -0
  180. package/src/Tokenizer/Tokenizer.doc.mjs +10 -0
  181. package/src/Toolbar/Toolbar.doc.mjs +5 -0
  182. package/src/__tests__/apiContractDrift.test.tsx +80 -0
  183. package/src/__tests__/fieldContract.test.tsx +24 -0
  184. package/src/__tests__/structuralComponentContract.test.tsx +39 -0
  185. package/src/hooks/index.ts +2 -0
  186. package/src/hooks/useHotkeys.test.ts +18 -0
  187. package/src/hooks/useHotkeys.ts +10 -3
  188. package/src/hooks/useListFocus.doc.mjs +2 -2
  189. package/src/hooks/useListFocus.test.tsx +65 -3
  190. package/src/hooks/useListFocus.ts +15 -7
  191. package/src/hooks/useMergedRefs.doc.mjs +79 -0
  192. package/src/hooks/useMergedRefs.test.tsx +62 -0
  193. package/src/hooks/useMergedRefs.ts +36 -0
  194. package/src/index.ts +1 -0
  195. package/src/theme/MediaTheme.doc.mjs +5 -5
  196. package/src/utils/timeParser.test.ts +6 -1
  197. package/src/utils/timeParser.ts +35 -13
@@ -128,6 +128,90 @@ describe('MultiSelector', () => {
128
128
  expect(screen.getByText('2 selected')).toBeInTheDocument();
129
129
  });
130
130
 
131
+ it('formats the count display with formatValue', () => {
132
+ render(
133
+ <MultiSelector
134
+ label="Spaces"
135
+ options={defaultOptions}
136
+ value={['Apple', 'Banana']}
137
+ onChange={() => {}}
138
+ formatValue={items => `${items.length} spaces selected`}
139
+ />,
140
+ );
141
+ expect(screen.getByText('2 spaces selected')).toBeInTheDocument();
142
+ });
143
+
144
+ it('passes selected values and resolved labels to formatValue', () => {
145
+ const formatValue = vi.fn(
146
+ (items: {value: string; label: string}[]) =>
147
+ `${items[0].label} and ${items.length - 1} more`,
148
+ );
149
+ render(
150
+ <MultiSelector
151
+ label="Fruit"
152
+ options={[
153
+ {value: 'a', label: 'Apple'},
154
+ {value: 'b', label: 'Banana'},
155
+ ]}
156
+ value={['a', 'b']}
157
+ onChange={() => {}}
158
+ formatValue={formatValue}
159
+ />,
160
+ );
161
+ expect(formatValue).toHaveBeenCalledWith([
162
+ {value: 'a', label: 'Apple'},
163
+ {value: 'b', label: 'Banana'},
164
+ ]);
165
+ expect(screen.getByText('Apple and 1 more')).toBeInTheDocument();
166
+ });
167
+
168
+ it('formats the labels display with formatValue', () => {
169
+ render(
170
+ <MultiSelector
171
+ label="Fruit"
172
+ options={defaultOptions}
173
+ value={['Apple', 'Banana', 'Orange']}
174
+ onChange={() => {}}
175
+ triggerDisplay="labels"
176
+ formatValue={items => items.map(item => item.label).join(' & ')}
177
+ />,
178
+ );
179
+ expect(screen.getByText('Apple & Banana & Orange')).toBeInTheDocument();
180
+ });
181
+
182
+ it('ignores formatValue in badges display', () => {
183
+ render(
184
+ <MultiSelector
185
+ label="Fruit"
186
+ options={defaultOptions}
187
+ value={['Apple', 'Banana']}
188
+ onChange={() => {}}
189
+ triggerDisplay="badges"
190
+ formatValue={() => 'formatted'}
191
+ />,
192
+ );
193
+ expect(screen.queryByText('formatted')).not.toBeInTheDocument();
194
+ const trigger = within(screen.getByRole('combobox'));
195
+ expect(trigger.getByText('Apple')).toBeInTheDocument();
196
+ expect(trigger.getByText('Banana')).toBeInTheDocument();
197
+ });
198
+
199
+ it('shows the placeholder rather than calling formatValue when empty', () => {
200
+ const formatValue = vi.fn(() => 'formatted');
201
+ render(
202
+ <MultiSelector
203
+ label="Fruit"
204
+ options={defaultOptions}
205
+ value={[]}
206
+ onChange={() => {}}
207
+ placeholder="Pick fruits..."
208
+ formatValue={formatValue}
209
+ />,
210
+ );
211
+ expect(screen.getByText('Pick fruits...')).toBeInTheDocument();
212
+ expect(formatValue).not.toHaveBeenCalled();
213
+ });
214
+
131
215
  it('shows labels display', () => {
132
216
  render(
133
217
  <MultiSelector
@@ -1981,6 +2065,27 @@ describe('MultiSelector list structure', () => {
1981
2065
  Node.DOCUMENT_POSITION_FOLLOWING,
1982
2066
  ).toBeTruthy();
1983
2067
  });
2068
+
2069
+ it('hides a standalone divider from the accessibility tree (#4994)', async () => {
2070
+ const user = userEvent.setup();
2071
+ render(
2072
+ <MultiSelector
2073
+ label="Fruit"
2074
+ options={['Apple', {type: 'divider'}, 'Banana']}
2075
+ value={[]}
2076
+ onChange={() => {}}
2077
+ />,
2078
+ );
2079
+ await user.click(screen.getByRole('combobox'));
2080
+
2081
+ // role="listbox" only permits option/group children. The divider still
2082
+ // renders role="separator" (unchanged visual/DOM), but must be excluded
2083
+ // from the accessibility tree so it never reaches the listbox's exposed
2084
+ // children (axe aria-required-children).
2085
+ const divider = document.querySelector('[role="separator"]');
2086
+ expect(divider).toBeTruthy();
2087
+ expect(divider).toHaveAttribute('aria-hidden', 'true');
2088
+ });
1984
2089
  });
1985
2090
 
1986
2091
  describe('MultiSelector search affordances', () => {
@@ -424,6 +424,11 @@ export type MultiSelectorStatusType = 'warning' | 'error' | 'success';
424
424
 
425
425
  export type {MultiSelectorStatus};
426
426
 
427
+ export interface MultiSelectorSelectedItem {
428
+ value: string;
429
+ label: string;
430
+ }
431
+
427
432
  export interface MultiSelectorProps<
428
433
  T extends MultiSelectorOptionType = MultiSelectorOptionType,
429
434
  > extends Omit<BaseProps, 'onChange' | 'defaultValue'> {
@@ -608,6 +613,17 @@ export interface MultiSelectorProps<
608
613
  */
609
614
  triggerDisplay?: 'count' | 'labels' | 'badges';
610
615
 
616
+ /**
617
+ * Formats the trigger text when triggerDisplay is 'count' or 'labels'.
618
+ * Receives the selected items (value plus resolved label, in selection
619
+ * order) and returns the full trigger text; the count is `items.length`.
620
+ * Not called when nothing is selected — the placeholder shows instead — and
621
+ * not called for triggerDisplay 'badges', which renders Badge elements
622
+ * rather than text.
623
+ * @default items => `${items.length} selected` for 'count', "A, B, C, +N" for 'labels'
624
+ */
625
+ formatValue?: (items: MultiSelectorSelectedItem[]) => string;
626
+
611
627
  /**
612
628
  * Maximum number of badges to show before showing "+N".
613
629
  * Only used when triggerDisplay is 'badges'.
@@ -711,6 +727,7 @@ export function MultiSelector<T extends MultiSelectorOptionType>({
711
727
  hasSearch = false,
712
728
  searchPlaceholder: searchPlaceholderFromProps,
713
729
  triggerDisplay = 'count',
730
+ formatValue,
714
731
  maxBadges = 3,
715
732
  renderOption,
716
733
  indicatorPosition = 'start',
@@ -1107,13 +1124,18 @@ export function MultiSelector<T extends MultiSelectorOptionType>({
1107
1124
  }, [popover.isOpen, highlightedIndex, getItemId]);
1108
1125
 
1109
1126
  // Build trigger display content
1110
- const selectedLabels = useMemo(() => {
1127
+ const selectedItems = useMemo(() => {
1111
1128
  return optimisticValue.map(v => {
1112
1129
  const item = selectableItems.find(i => i.value === v);
1113
- return item?.label ?? v;
1130
+ return {value: v, label: item?.label ?? v};
1114
1131
  });
1115
1132
  }, [optimisticValue, selectableItems]);
1116
1133
 
1134
+ const selectedLabels = useMemo(
1135
+ () => selectedItems.map(item => item.label),
1136
+ [selectedItems],
1137
+ );
1138
+
1117
1139
  const renderTriggerContent = useCallback(() => {
1118
1140
  if (optimisticValue.length === 0) {
1119
1141
  return <span {...stylex.props(styles.triggerText)}>{placeholder}</span>;
@@ -1123,7 +1145,8 @@ export function MultiSelector<T extends MultiSelectorOptionType>({
1123
1145
  case 'count':
1124
1146
  return (
1125
1147
  <span {...stylex.props(styles.triggerText)}>
1126
- {optimisticValue.length} selected
1148
+ {formatValue?.(selectedItems) ??
1149
+ `${optimisticValue.length} selected`}
1127
1150
  </span>
1128
1151
  );
1129
1152
 
@@ -1134,7 +1157,11 @@ export function MultiSelector<T extends MultiSelectorOptionType>({
1134
1157
  remaining > 0
1135
1158
  ? `${displayed.join(', ')}, +${remaining}`
1136
1159
  : displayed.join(', ');
1137
- return <span {...stylex.props(styles.triggerText)}>{text}</span>;
1160
+ return (
1161
+ <span {...stylex.props(styles.triggerText)}>
1162
+ {formatValue?.(selectedItems) ?? text}
1163
+ </span>
1164
+ );
1138
1165
  }
1139
1166
 
1140
1167
  case 'badges': {
@@ -1154,7 +1181,15 @@ export function MultiSelector<T extends MultiSelectorOptionType>({
1154
1181
  );
1155
1182
  }
1156
1183
  }
1157
- }, [optimisticValue, triggerDisplay, selectedLabels, placeholder, maxBadges]);
1184
+ }, [
1185
+ optimisticValue,
1186
+ triggerDisplay,
1187
+ selectedItems,
1188
+ selectedLabels,
1189
+ placeholder,
1190
+ formatValue,
1191
+ maxBadges,
1192
+ ]);
1158
1193
 
1159
1194
  // Render search input
1160
1195
  const renderSearch = useCallback(() => {
@@ -1420,8 +1455,17 @@ export function MultiSelector<T extends MultiSelectorOptionType>({
1420
1455
  // A standalone divider between groups would orphan itself once its
1421
1456
  // neighbors are filtered out, so skip it while searching.
1422
1457
  if (!isSearching) {
1458
+ // role="listbox" only permits option/group children; the divider
1459
+ // carries no information the options don't, so it's hidden from
1460
+ // the accessibility tree entirely rather than exposing
1461
+ // role="separator" as a disallowed listbox child (axe
1462
+ // aria-required-children).
1423
1463
  elements.push(
1424
- <Divider key={`divider-${i}`} xstyle={styles.divider} />,
1464
+ <Divider
1465
+ key={`divider-${i}`}
1466
+ aria-hidden="true"
1467
+ xstyle={styles.divider}
1468
+ />,
1425
1469
  );
1426
1470
  }
1427
1471
  } else if (isSection(option)) {
@@ -13,6 +13,7 @@ export {
13
13
  type MultiSelectorProps,
14
14
  type MultiSelectorSize,
15
15
  type MultiSelectorStatusType,
16
+ type MultiSelectorSelectedItem,
16
17
  } from './MultiSelector';
17
18
  export type {
18
19
  MultiSelectorOptionType,
@@ -61,6 +61,11 @@ export const docs = {
61
61
  description:
62
62
  'Whether the field is optional (mutually exclusive with isRequired).',
63
63
  },
64
+ {
65
+ name: 'onKeyDown',
66
+ type: '(e: KeyboardEvent<HTMLInputElement>) => void',
67
+ description: 'Callback fired on keydown events on the input.',
68
+ },
64
69
  {
65
70
  name: 'isRequired',
66
71
  type: 'boolean',
@@ -2,7 +2,8 @@
2
2
 
3
3
  /**
4
4
  * @file Popover.test.tsx
5
- * @input Uses vitest, @testing-library/react, Popover component
5
+ * @input Uses vitest, @testing-library/react, Popover, Dialog,
6
+ * SegmentedControl
6
7
  * @output Unit tests for Popover component behavior
7
8
  * @position Testing; validates Popover.tsx implementation
8
9
  *
@@ -14,6 +15,7 @@ import {render, screen, fireEvent} from '@testing-library/react';
14
15
  import React, {useRef} from 'react';
15
16
  import {Popover} from './Popover';
16
17
  import {Dialog} from '../Dialog';
18
+ import {SegmentedControl, SegmentedControlItem} from '../SegmentedControl';
17
19
 
18
20
  // Store original matches to restore later
19
21
  const originalMatches = HTMLElement.prototype.matches;
@@ -306,6 +308,30 @@ describe('Popover', () => {
306
308
  expect(trigger).toHaveAttribute('aria-expanded', 'false');
307
309
  });
308
310
 
311
+ it('dismisses on Escape pressed inside a roving-focus list', () => {
312
+ render(
313
+ <Popover
314
+ content={
315
+ <SegmentedControl value="grid" onChange={() => {}} label="View">
316
+ <SegmentedControlItem value="grid" label="Grid" />
317
+ <SegmentedControlItem value="list" label="List" />
318
+ </SegmentedControl>
319
+ }
320
+ label="Test">
321
+ <button type="button">Open</button>
322
+ </Popover>,
323
+ );
324
+ const trigger = screen.getByRole('button', {name: 'Open'});
325
+ fireEvent.click(trigger);
326
+ expect(trigger).toHaveAttribute('aria-expanded', 'true');
327
+
328
+ // From the segment, so the list's own key handler runs first. jsdom has
329
+ // no popover display, so the open content still reads as hidden.
330
+ const segment = screen.getByRole('radio', {name: 'Grid', hidden: true});
331
+ fireEvent.keyDown(segment, {key: 'Escape'});
332
+ expect(trigger).toHaveAttribute('aria-expanded', 'false');
333
+ });
334
+
309
335
  it('stays open on Escape when hasEscapeDismiss is false', () => {
310
336
  render(
311
337
  <Popover
@@ -142,6 +142,16 @@ export const docs = {
142
142
  description: 'Size of the search input and tokens.',
143
143
  default: "'md'",
144
144
  },
145
+ {
146
+ name: 'onFocus',
147
+ type: '(e: FocusEvent<HTMLInputElement>) => void',
148
+ description: 'Fires when focus enters the search input.',
149
+ },
150
+ {
151
+ name: 'onBlur',
152
+ type: '(e: FocusEvent<HTMLInputElement>) => void',
153
+ description: 'Fires when focus leaves the search input.',
154
+ },
145
155
  {
146
156
  name: 'xstyle',
147
157
  type: 'StyleXStyles',
@@ -186,6 +186,17 @@ export const docs = {
186
186
  description:
187
187
  'Width of the field (number = pixels, string used as-is, e.g. "100%"). Sizes the whole field (label, control, and status) so they stay aligned.',
188
188
  },
189
+ {
190
+ name: 'startIcon',
191
+ type: 'IconType | ReactNode',
192
+ description: 'Icon displayed at the start of the selector trigger.',
193
+ },
194
+ {
195
+ name: 'isLoading',
196
+ type: 'boolean',
197
+ description: 'Shows a loading spinner in the trigger.',
198
+ default: 'false',
199
+ },
189
200
  {
190
201
  name: 'xstyle',
191
202
  type: 'StyleXStyles',
@@ -2484,6 +2484,27 @@ describe('Selector section headings', () => {
2484
2484
  // visible heading must not announce it a second time.
2485
2485
  expect(heading).toHaveAttribute('aria-hidden', 'true');
2486
2486
  });
2487
+
2488
+ it('hides a standalone divider from the accessibility tree (#4994)', async () => {
2489
+ const user = userEvent.setup();
2490
+ render(
2491
+ <Selector
2492
+ label="Fruit"
2493
+ options={['Apple', {type: 'divider'}, 'Banana']}
2494
+ value={undefined}
2495
+ onChange={() => {}}
2496
+ />,
2497
+ );
2498
+ await user.click(screen.getByRole('combobox'));
2499
+
2500
+ // role="listbox" only permits option/group children. The divider still
2501
+ // renders role="separator" (unchanged visual/DOM), but must be excluded
2502
+ // from the accessibility tree so it never reaches the listbox's exposed
2503
+ // children (axe aria-required-children).
2504
+ const divider = document.querySelector('[role="separator"]');
2505
+ expect(divider).toBeTruthy();
2506
+ expect(divider).toHaveAttribute('aria-hidden', 'true');
2507
+ });
2487
2508
  });
2488
2509
 
2489
2510
  describe('Selector search focus ring', () => {
@@ -1344,7 +1344,17 @@ export function Selector<T extends SelectorOptionType>(
1344
1344
  if (isSearching) {
1345
1345
  continue;
1346
1346
  }
1347
- elements.push(<Divider key={`divider-${i}`} xstyle={styles.divider} />);
1347
+ // role="listbox" only permits option/group children; the divider
1348
+ // carries no information the options don't, so it's hidden from the
1349
+ // accessibility tree entirely rather than exposing role="separator"
1350
+ // as a disallowed listbox child (axe aria-required-children).
1351
+ elements.push(
1352
+ <Divider
1353
+ key={`divider-${i}`}
1354
+ aria-hidden="true"
1355
+ xstyle={styles.divider}
1356
+ />,
1357
+ );
1348
1358
  } else if (isSection(option)) {
1349
1359
  const sectionItems: ReactNode[] = [];
1350
1360
  for (const opt of option.options) {
@@ -0,0 +1,98 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /** @type {import('@astryxdesign/cli/authoring').ComponentDoc} */
4
+
5
+ export const docs = {
6
+ name: 'Step',
7
+ subComponentOf: 'Stepper',
8
+ displayName: 'Step',
9
+ group: 'Stepper',
10
+ category: 'Navigation',
11
+ isHiddenFromOverview: true,
12
+ description:
13
+ 'Individual step within a Stepper. Renders a progress-bar segment, an indicator, and a label with optional description. Progress (completed/active/not-started) is derived from the parent Stepper\u2019s activeStep and this step\u2019s step index.',
14
+ props: [
15
+ {
16
+ name: 'step',
17
+ type: 'number',
18
+ description:
19
+ 'Zero-based index of this step. Used to derive progress (completed/active/not-started) relative to the parent activeStep.',
20
+ required: true,
21
+ },
22
+ {
23
+ name: 'label',
24
+ type: 'string',
25
+ description: 'Step label text.',
26
+ required: true,
27
+ },
28
+ {
29
+ name: 'description',
30
+ type: 'string',
31
+ description:
32
+ 'Optional description shown below the label for additional context.',
33
+ },
34
+ {
35
+ name: 'children',
36
+ type: 'ReactNode',
37
+ description:
38
+ 'Content rendered below the label and description. Useful in vertical steppers for form fields or detailed step content.',
39
+ },
40
+ {
41
+ name: 'status',
42
+ type: "'accent' | 'success' | 'warning' | 'error'",
43
+ description:
44
+ 'Semantic color for the step. Controls color only and maps to the global Astryx semantic tokens. Leave unset for the progress-derived default coloring.',
45
+ },
46
+ {
47
+ name: 'indicator',
48
+ type: "'auto' | 'number' | 'none' | ReactNode",
49
+ description:
50
+ "What to show as the step indicator. 'auto' shows a number until completed then a check, 'number' always shows a numbered badge, 'none' hides it, or pass any ReactNode (e.g. an Icon) for a fully custom indicator.",
51
+ slotElements: [{__element: 'Icon', props: {icon: 'check', size: 'sm'}}],
52
+ default: "'auto'",
53
+ },
54
+ {
55
+ name: 'isDisabled',
56
+ type: 'boolean',
57
+ description:
58
+ 'Disables interaction and dims the step indicator and label.',
59
+ default: 'false',
60
+ },
61
+ {
62
+ name: 'isOptional',
63
+ type: 'boolean',
64
+ description:
65
+ 'Marks the step as optional, appending an "Optional" affordance after the label.',
66
+ default: 'false',
67
+ },
68
+ {
69
+ name: 'endContent',
70
+ type: 'ReactNode',
71
+ description: 'Trailing content rendered at the end of the label row.',
72
+ },
73
+ {
74
+ name: 'density',
75
+ type: "'compact' | 'balanced' | 'spacious'",
76
+ description:
77
+ 'Controls vertical padding of the step. Falls back to the stepper-level density when unset.',
78
+ },
79
+ ],
80
+ // A Step reads its progress from the parent Stepper's context and throws
81
+ // without one, so the preview needs a real Stepper around it. Without this
82
+ // block Step inherits Stepper's playground, which renders it bare and hands
83
+ // it the parent's `activeStep`. The wrapper is vertical because that is the
84
+ // orientation where one step shows its whole anatomy — indicator, label, and
85
+ // description stacked — and `step` matches the wrapper's `activeStep` so the
86
+ // preview opens on the current step rather than an inert upcoming one.
87
+ playground: {
88
+ wrapper: {
89
+ component: 'Stepper',
90
+ props: {activeStep: 1, orientation: 'vertical'},
91
+ },
92
+ defaults: {
93
+ step: 1,
94
+ label: 'Billing address',
95
+ description: 'Used for invoices and tax',
96
+ },
97
+ },
98
+ };