@astryxdesign/core 0.4.0 → 0.4.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 (194) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/dist/AspectRatio/AspectRatio.d.ts.map +1 -1
  3. package/dist/AspectRatio/AspectRatio.js +1 -2
  4. package/dist/AspectRatio/index.d.ts.map +1 -1
  5. package/dist/AspectRatio/index.js +1 -2
  6. package/dist/Badge/Badge.d.ts.map +1 -1
  7. package/dist/Badge/Badge.js +1 -2
  8. package/dist/Badge/index.d.ts.map +1 -1
  9. package/dist/Badge/index.js +1 -2
  10. package/dist/Banner/Banner.d.ts +3 -0
  11. package/dist/Banner/Banner.d.ts.map +1 -1
  12. package/dist/Banner/Banner.js +17 -3
  13. package/dist/Blockquote/Blockquote.d.ts.map +1 -1
  14. package/dist/Blockquote/Blockquote.js +1 -2
  15. package/dist/Blockquote/index.d.ts.map +1 -1
  16. package/dist/Blockquote/index.js +1 -2
  17. package/dist/Button/Button.d.ts.map +1 -1
  18. package/dist/Button/Button.js +2 -2
  19. package/dist/Card/Card.d.ts.map +1 -1
  20. package/dist/Card/Card.js +1 -2
  21. package/dist/Card/index.d.ts.map +1 -1
  22. package/dist/Card/index.js +1 -2
  23. package/dist/Center/Center.d.ts.map +1 -1
  24. package/dist/Center/Center.js +1 -2
  25. package/dist/Center/index.d.ts.map +1 -1
  26. package/dist/Center/index.js +1 -2
  27. package/dist/Code/Code.d.ts.map +1 -1
  28. package/dist/Code/Code.js +1 -2
  29. package/dist/CommandPalette/CommandPalette.d.ts.map +1 -1
  30. package/dist/CommandPalette/CommandPalette.js +5 -0
  31. package/dist/ComplexSelector/ComplexSelector.d.ts.map +1 -1
  32. package/dist/ComplexSelector/ComplexSelector.js +7 -8
  33. package/dist/DateInput/DateInput.d.ts.map +1 -1
  34. package/dist/DateInput/DateInput.js +23 -10
  35. package/dist/DateRangeInput/DateRangeInput.d.ts.map +1 -1
  36. package/dist/DateRangeInput/DateRangeInput.js +44 -16
  37. package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
  38. package/dist/DateTimeInput/DateTimeInput.js +32 -13
  39. package/dist/DropdownMenu/DropdownMenuRadioGroup.d.ts +1 -1
  40. package/dist/DropdownMenu/DropdownMenuRadioGroup.d.ts.map +1 -1
  41. package/dist/DropdownMenu/DropdownMenuRadioGroup.js +10 -3
  42. package/dist/Field/PanelSearchInput.d.ts +81 -0
  43. package/dist/Field/PanelSearchInput.d.ts.map +1 -0
  44. package/dist/Field/PanelSearchInput.js +151 -0
  45. package/dist/FileInput/FileInput.d.ts.map +1 -1
  46. package/dist/FileInput/FileInput.js +21 -10
  47. package/dist/Grid/Grid.d.ts.map +1 -1
  48. package/dist/Grid/Grid.js +1 -2
  49. package/dist/Grid/index.d.ts.map +1 -1
  50. package/dist/Grid/index.js +1 -2
  51. package/dist/Icon/globalIconRegistry.d.ts +5 -4
  52. package/dist/Icon/globalIconRegistry.d.ts.map +1 -1
  53. package/dist/Icon/globalIconRegistry.js +5 -4
  54. package/dist/Indicator/index.d.ts +1 -1
  55. package/dist/Indicator/index.d.ts.map +1 -1
  56. package/dist/Indicator/types.d.ts +10 -0
  57. package/dist/Indicator/types.d.ts.map +1 -1
  58. package/dist/Lightbox/Lightbox.d.ts.map +1 -1
  59. package/dist/Lightbox/Lightbox.js +24 -50
  60. package/dist/MultiSelector/MultiSelector.d.ts +8 -1
  61. package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
  62. package/dist/MultiSelector/MultiSelector.js +134 -102
  63. package/dist/Popover/usePopover.d.ts +15 -0
  64. package/dist/Popover/usePopover.d.ts.map +1 -1
  65. package/dist/Popover/usePopover.js +14 -2
  66. package/dist/ProgressBar/ProgressBar.d.ts.map +1 -1
  67. package/dist/ProgressBar/ProgressBar.js +2 -3
  68. package/dist/Section/Section.d.ts.map +1 -1
  69. package/dist/Section/Section.js +1 -2
  70. package/dist/Section/index.d.ts.map +1 -1
  71. package/dist/Section/index.js +1 -2
  72. package/dist/Selector/Selector.d.ts +10 -0
  73. package/dist/Selector/Selector.d.ts.map +1 -1
  74. package/dist/Selector/Selector.js +135 -95
  75. package/dist/Skeleton/Skeleton.d.ts.map +1 -1
  76. package/dist/Skeleton/Skeleton.js +1 -2
  77. package/dist/Skeleton/index.d.ts.map +1 -1
  78. package/dist/Skeleton/index.js +1 -2
  79. package/dist/Slider/Slider.d.ts.map +1 -1
  80. package/dist/Slider/Slider.js +2 -6
  81. package/dist/Switch/Switch.d.ts.map +1 -1
  82. package/dist/Switch/Switch.js +3 -3
  83. package/dist/Table/TableRow.d.ts +1 -1
  84. package/dist/Table/TableRow.d.ts.map +1 -1
  85. package/dist/Table/TableRow.js +4 -2
  86. package/dist/TimeInput/TimeInput.d.ts.map +1 -1
  87. package/dist/TimeInput/TimeInput.js +7 -2
  88. package/dist/Timestamp/Timestamp.d.ts.map +1 -1
  89. package/dist/Timestamp/Timestamp.js +14 -3
  90. package/dist/Timestamp/formatInstant.d.ts +11 -1
  91. package/dist/Timestamp/formatInstant.d.ts.map +1 -1
  92. package/dist/Timestamp/formatInstant.js +3 -1
  93. package/dist/VisuallyHidden/VisuallyHidden.d.ts.map +1 -1
  94. package/dist/VisuallyHidden/VisuallyHidden.js +1 -2
  95. package/dist/VisuallyHidden/index.d.ts.map +1 -1
  96. package/dist/VisuallyHidden/index.js +1 -2
  97. package/dist/astryx.css +18 -17
  98. package/dist/astryx.umd.js +45 -45
  99. package/dist/astryx.umd.js.map +4 -4
  100. package/dist/hooks/useInputStatusIcon.d.ts.map +1 -1
  101. package/dist/hooks/useInputStatusIcon.js +17 -4
  102. package/dist/theme/defineTheme.d.ts +2 -2
  103. package/dist/theme/defineTheme.d.ts.map +1 -1
  104. package/dist/theme/defineTheme.js +2 -1
  105. package/dist/theme/index.d.ts +1 -1
  106. package/dist/theme/index.d.ts.map +1 -1
  107. package/dist/theme/index.js +1 -1
  108. package/dist/theme/tokens.stylex.d.ts +12 -0
  109. package/dist/theme/tokens.stylex.d.ts.map +1 -1
  110. package/dist/theme/tokens.stylex.js +23 -0
  111. package/dist/utils/focusOutline.stylex.d.ts +34 -19
  112. package/dist/utils/focusOutline.stylex.d.ts.map +1 -1
  113. package/dist/utils/focusOutline.stylex.js +46 -33
  114. package/dist/utils/index.d.ts +1 -0
  115. package/dist/utils/index.d.ts.map +1 -1
  116. package/dist/utils/index.js +5 -1
  117. package/dist/utils/interactionModality.d.ts +34 -0
  118. package/dist/utils/interactionModality.d.ts.map +1 -0
  119. package/dist/utils/interactionModality.js +70 -0
  120. package/locales/en.json +40 -0
  121. package/locales/pseudo.json +30 -0
  122. package/package.json +2 -2
  123. package/src/AppShell/AppShell.doc.mjs +3 -0
  124. package/src/AspectRatio/AspectRatio.tsx +0 -2
  125. package/src/AspectRatio/index.ts +0 -2
  126. package/src/Badge/Badge.tsx +0 -2
  127. package/src/Badge/index.ts +0 -2
  128. package/src/Banner/Banner.test.tsx +58 -1
  129. package/src/Banner/Banner.tsx +24 -5
  130. package/src/Blockquote/Blockquote.tsx +0 -2
  131. package/src/Blockquote/index.ts +0 -2
  132. package/src/Button/Button.doc.mjs +2 -2
  133. package/src/Button/Button.tsx +5 -4
  134. package/src/Card/Card.tsx +0 -2
  135. package/src/Card/index.ts +0 -2
  136. package/src/Center/Center.tsx +0 -2
  137. package/src/Center/index.ts +0 -2
  138. package/src/CheckboxInput/CheckboxInput.test.tsx +8 -3
  139. package/src/Code/Code.tsx +0 -2
  140. package/src/CommandPalette/CommandPalette.test.tsx +54 -0
  141. package/src/CommandPalette/CommandPalette.tsx +5 -0
  142. package/src/ComplexSelector/ComplexSelector.test.tsx +15 -6
  143. package/src/ComplexSelector/ComplexSelector.tsx +6 -10
  144. package/src/DateInput/DateInput.test.tsx +19 -0
  145. package/src/DateInput/DateInput.tsx +3 -6
  146. package/src/DateRangeInput/DateRangeInput.tsx +3 -9
  147. package/src/DateTimeInput/DateTimeInput.test.tsx +79 -1
  148. package/src/DateTimeInput/DateTimeInput.tsx +23 -9
  149. package/src/DropdownMenu/DropdownMenuRadioGroup.tsx +2 -1
  150. package/src/Field/PanelSearchInput.tsx +271 -0
  151. package/src/FileInput/FileInput.tsx +21 -8
  152. package/src/Grid/Grid.tsx +0 -2
  153. package/src/Grid/index.ts +0 -2
  154. package/src/Icon/globalIconRegistry.tsx +5 -4
  155. package/src/Indicator/Indicator.doc.mjs +1 -1
  156. package/src/Indicator/index.ts +1 -0
  157. package/src/Indicator/types.ts +11 -0
  158. package/src/Lightbox/Lightbox.tsx +2 -11
  159. package/src/MultiSelector/MultiSelector.doc.mjs +9 -0
  160. package/src/MultiSelector/MultiSelector.test.tsx +135 -5
  161. package/src/MultiSelector/MultiSelector.tsx +170 -127
  162. package/src/Popover/Popover.doc.mjs +2 -0
  163. package/src/Popover/usePopover.tsx +37 -4
  164. package/src/ProgressBar/ProgressBar.tsx +2 -8
  165. package/src/Section/Section.tsx +0 -2
  166. package/src/Section/index.ts +0 -2
  167. package/src/Selector/Selector.doc.mjs +10 -0
  168. package/src/Selector/Selector.test.tsx +386 -2
  169. package/src/Selector/Selector.tsx +182 -122
  170. package/src/Skeleton/Skeleton.tsx +0 -2
  171. package/src/Skeleton/index.ts +0 -2
  172. package/src/Slider/Slider.tsx +2 -11
  173. package/src/Switch/Switch.tsx +10 -2
  174. package/src/Table/Table.test.tsx +31 -0
  175. package/src/Table/TableRow.tsx +6 -0
  176. package/src/TimeInput/TimeInput.test.tsx +54 -0
  177. package/src/TimeInput/TimeInput.tsx +17 -2
  178. package/src/Timestamp/Timestamp.test.tsx +91 -14
  179. package/src/Timestamp/Timestamp.tsx +14 -2
  180. package/src/Timestamp/formatInstant.ts +16 -1
  181. package/src/TopNav/TopNavMegaMenu.doc.mjs +5 -0
  182. package/src/TreeList/TreeList.test.tsx +3 -2
  183. package/src/VisuallyHidden/VisuallyHidden.tsx +0 -2
  184. package/src/VisuallyHidden/index.ts +0 -2
  185. package/src/hooks/useInputStatusIcon.tsx +8 -8
  186. package/src/serverSafeComponents.test.ts +665 -0
  187. package/src/theme/defineTheme.ts +3 -0
  188. package/src/theme/generateThemeRules.test.ts +17 -0
  189. package/src/theme/index.ts +2 -0
  190. package/src/theme/tokens.stylex.ts +18 -0
  191. package/src/theme/tokens.test.ts +37 -0
  192. package/src/utils/focusOutline.stylex.ts +37 -23
  193. package/src/utils/index.ts +4 -0
  194. package/src/utils/interactionModality.ts +74 -0
@@ -42,8 +42,9 @@ import {
42
42
  } from '../Field';
43
43
  import {Divider} from '../Divider';
44
44
  import {Spinner} from '../Spinner';
45
- import {TextInput} from '../TextInput';
45
+ import {PanelSearchInput} from '../Field/PanelSearchInput';
46
46
  import {CheckboxInput} from '../CheckboxInput';
47
+ import type {IndicatorPosition} from '../Indicator';
47
48
  import {Badge} from '../Badge';
48
49
  import {
49
50
  colorVars,
@@ -76,6 +77,7 @@ import type {BaseProps} from '../BaseProps';
76
77
  import type {SizeValue} from '../utils/types';
77
78
  import {useSize} from '../SizeContext/SizeContext';
78
79
  import {themeProps} from '../utils/themeProps';
80
+ import {focusOutlineStyles} from '../utils/focusOutline.stylex';
79
81
  import {stableClassName} from '../naming';
80
82
  import {groupStyles} from '../InputGroup/groupStyles';
81
83
  import {useInputGroup} from '../InputGroup/InputGroupContext';
@@ -200,14 +202,6 @@ const styles = stylex.create({
200
202
  ':focus-within': 'none',
201
203
  },
202
204
  fontWeight: fontWeightVars['--font-weight-medium'],
203
- outline: {
204
- default: 'none',
205
- ':has(:focus-visible)': `2px solid ${colorVars['--color-accent']}`,
206
- },
207
- outlineOffset: {
208
- default: '0',
209
- ':has(:focus-visible)': '3px',
210
- },
211
205
  transitionProperty:
212
206
  'background-image, background-color, color, opacity, transform',
213
207
  transform: {
@@ -236,11 +230,6 @@ const styles = stylex.create({
236
230
  color: 'inherit',
237
231
  cursor: 'pointer',
238
232
  borderRadius: radiusVars['--radius-element'],
239
- outline: {
240
- default: 'none',
241
- ':focus-visible': `${borderVars['--border-width']} solid ${colorVars['--color-accent']}`,
242
- },
243
- outlineOffset: 1,
244
233
  },
245
234
 
246
235
  // Dropdown container
@@ -256,16 +245,6 @@ const styles = stylex.create({
256
245
  minWidth: 'anchor-size(width)',
257
246
  },
258
247
 
259
- // Search field. The inner TextInput owns the border, focus ring, magnifier
260
- // (startIcon), and clear button (hasClear); this wrapper only supplies the
261
- // dropdown's inline/block padding around it.
262
- searchWrapper: {
263
- display: 'flex',
264
- alignItems: 'center',
265
- paddingInline: spacingVars['--spacing-2'],
266
- paddingBlock: spacingVars['--spacing-1'],
267
- },
268
-
269
248
  // Select-all wrapper
270
249
  selectAllWrapper: {
271
250
  display: 'flex',
@@ -274,9 +253,19 @@ const styles = stylex.create({
274
253
  cursor: 'pointer',
275
254
  },
276
255
 
277
- // Section divider with label
278
- sectionDivider: {
279
- marginBlock: spacingVars['--spacing-1'],
256
+ // Section heading. Plain secondary text, no rules — the same treatment
257
+ // DropdownMenu and CommandPaletteGroup already use for a group heading in a
258
+ // panel list. A labeled Divider (line–text–line) reads as a separator, and
259
+ // next to the search row's own divider it stacked two rules a few pixels
260
+ // apart.
261
+ sectionHeading: {
262
+ paddingBlock: spacingVars['--spacing-1'],
263
+ paddingInline: spacingVars['--spacing-2'],
264
+ fontFamily: typographyVars['--font-family-body'],
265
+ fontSize: typeScaleVars['--text-supporting-size'],
266
+ lineHeight: typeScaleVars['--text-supporting-leading'],
267
+ color: colorVars['--color-text-secondary'],
268
+ userSelect: 'none',
280
269
  },
281
270
 
282
271
  // Divider
@@ -320,6 +309,14 @@ const styles = stylex.create({
320
309
  display: 'flex',
321
310
  flexShrink: 0,
322
311
  },
312
+ // Pushed to the row's far edge rather than sitting against the label, which
313
+ // is what an end-positioned control means here. The row is not
314
+ // `space-between` (a truncating label plus a trailing control is what wants
315
+ // the auto margin), and `renderOption` content is not wrapped in a growing
316
+ // span, so the margin has to live on the checkbox itself.
317
+ checkboxDecorativeEnd: {
318
+ marginInlineStart: 'auto',
319
+ },
323
320
 
324
321
  // Label text for items (rendered outside checkbox for correct click
325
322
  // behavior). Typography is inherited from the row; this only handles
@@ -607,6 +604,13 @@ export interface MultiSelectorProps<
607
604
  */
608
605
  renderOption?: (option: MultiSelectorOptionData) => ReactNode;
609
606
 
607
+ /**
608
+ * Which edge of the option row carries the checkbox.
609
+ *
610
+ * @default 'start'
611
+ */
612
+ indicatorPosition?: IndicatorPosition;
613
+
610
614
  /**
611
615
  * Whether the dropdown starts open on mount.
612
616
  * Useful for showcases and previews.
@@ -692,6 +696,7 @@ export function MultiSelector<T extends MultiSelectorOptionType>({
692
696
  triggerDisplay = 'count',
693
697
  maxBadges = 3,
694
698
  renderOption,
699
+ indicatorPosition = 'start',
695
700
  isDefaultOpen = false,
696
701
  'data-testid': testId,
697
702
  htmlName,
@@ -724,7 +729,7 @@ export function MultiSelector<T extends MultiSelectorOptionType>({
724
729
  const inputGroup = useInputGroup();
725
730
 
726
731
  const [searchQuery, setSearchQuery] = useState('');
727
- // A typed query shows TextInput's built-in clear (✕) button, which becomes
732
+ // A typed query shows the search row's clear (✕) button, which becomes
728
733
  // the next tab stop after the search input.
729
734
  const hasQuery = searchQuery.length > 0;
730
735
 
@@ -869,6 +874,9 @@ export function MultiSelector<T extends MultiSelectorOptionType>({
869
874
  // The popup's own role="listbox" is the exposed semantics; the trigger
870
875
  // keeps DOM focus, so wrapping it in a modal dialog would misrepresent it.
871
876
  role: 'none',
877
+ // The theme target belongs on the SURFACE that paints the popup, which
878
+ // `usePopover` owns — not on the scrolling list inside it.
879
+ surfaceTarget: 'multi-selector-popup',
872
880
  });
873
881
 
874
882
  // Open dropdown on mount when isDefaultOpen is true
@@ -1131,11 +1139,38 @@ export function MultiSelector<T extends MultiSelectorOptionType>({
1131
1139
  return null;
1132
1140
  }
1133
1141
  return (
1134
- <div
1135
- {...stylex.props(styles.searchWrapper)}
1136
- onKeyDown={e => {
1137
- // The clear (✕) button lives inside the TextInput, after the input in
1138
- // DOM order. When it is focused and the user tabs forward there is
1142
+ <PanelSearchInput
1143
+ ref={searchRef}
1144
+ id={searchId}
1145
+ // The search row is the panel's header: a magnifier, a borderless
1146
+ // input, and the shared clear (✕) button. It deliberately does NOT
1147
+ // render a bordered TextInput — the popup is already a bordered
1148
+ // surface, and a field inside it drew a second box within that box.
1149
+ label={t('@astryx.multiSelector.searchOptions')}
1150
+ // Same accessible name the TextInput's built-in clear produced
1151
+ // ("Clear Search options"), so the affordance keeps its name while its
1152
+ // chrome changes.
1153
+ clearLabel={t('@astryx.textInput.clearLabel', {
1154
+ label: t('@astryx.multiSelector.searchOptions'),
1155
+ })}
1156
+ {...themeProps('multi-selector-search')}
1157
+ // When hasSearch is set, focus moves into this input on open, so it —
1158
+ // not the trigger — must be the combobox reporting the highlighted
1159
+ // option via aria-activedescendant (comboboxes-4).
1160
+ role="combobox"
1161
+ aria-expanded={popover.isOpen}
1162
+ aria-controls={listboxId}
1163
+ aria-autocomplete="list"
1164
+ aria-activedescendant={
1165
+ popover.isOpen && highlightedIndex >= 0
1166
+ ? getItemId(highlightedIndex)
1167
+ : undefined
1168
+ }
1169
+ value={searchQuery}
1170
+ onValueChange={handleSearchChange}
1171
+ onContainerKeyDown={e => {
1172
+ // The clear (✕) button lives inside the row, after the input in DOM
1173
+ // order. When it is focused and the user tabs forward there is
1139
1174
  // nothing else in the popup, so dismiss it (Shift+Tab returns to the
1140
1175
  // input natively). Key events originating on the input are handled on
1141
1176
  // the input below; ignore them here so we don't double-dismiss.
@@ -1145,64 +1180,32 @@ export function MultiSelector<T extends MultiSelectorOptionType>({
1145
1180
  if (e.key === 'Tab' && !e.shiftKey) {
1146
1181
  onKeyDown(e);
1147
1182
  }
1148
- }}>
1149
- <TextInput
1150
- ref={searchRef}
1151
- id={searchId}
1152
- // The search field IS a TextInput: the leading magnifier is its
1153
- // `startIcon` and the trailing clear (✕) is its built-in `hasClear`
1154
- // (which resets the value and refocuses the input). We add no bespoke
1155
- // affordance chrome — the field just looks and behaves like every
1156
- // other Astryx input.
1157
- label={t('@astryx.multiSelector.searchOptions')}
1158
- isLabelHidden
1159
- startIcon="search"
1160
- hasClear
1161
- size="sm"
1162
- // Fill the dropdown's width (minus the wrapper's inline padding) so
1163
- // the field is flush end-to-end rather than sized to its content.
1164
- width="100%"
1165
- // When hasSearch is set, focus moves into this input on open, so it —
1166
- // not the trigger — must be the combobox reporting the highlighted
1167
- // option via aria-activedescendant (comboboxes-4). role + aria-* pass
1168
- // through to the underlying <input> via BaseProps.
1169
- role="combobox"
1170
- aria-expanded={popover.isOpen}
1171
- aria-controls={listboxId}
1172
- aria-autocomplete="list"
1173
- aria-activedescendant={
1174
- popover.isOpen && highlightedIndex >= 0
1175
- ? getItemId(highlightedIndex)
1176
- : undefined
1183
+ }}
1184
+ onKeyDown={e => {
1185
+ // Arrow keys navigate options; Enter toggles; Escape closes.
1186
+ // Space and Home/End are left to the input (type a space / move
1187
+ // the caret) per the APG editable combobox; PageUp/PageDown are
1188
+ // the sanctioned substitute for jumping to the first/last option.
1189
+ if (
1190
+ e.key === 'ArrowDown' ||
1191
+ e.key === 'ArrowUp' ||
1192
+ e.key === 'PageUp' ||
1193
+ e.key === 'PageDown' ||
1194
+ e.key === 'Enter' ||
1195
+ e.key === 'Escape'
1196
+ ) {
1197
+ onKeyDown(e);
1198
+ return;
1177
1199
  }
1178
- value={searchQuery}
1179
- onChange={handleSearchChange}
1180
- onKeyDown={e => {
1181
- // Arrow keys navigate options; Enter toggles; Escape closes.
1182
- // Space and Home/End are left to the input (type a space / move
1183
- // the caret) per the APG editable combobox; PageUp/PageDown are
1184
- // the sanctioned substitute for jumping to the first/last option.
1185
- if (
1186
- e.key === 'ArrowDown' ||
1187
- e.key === 'ArrowUp' ||
1188
- e.key === 'PageUp' ||
1189
- e.key === 'PageDown' ||
1190
- e.key === 'Enter' ||
1191
- e.key === 'Escape'
1192
- ) {
1193
- onKeyDown(e);
1194
- return;
1195
- }
1196
- // Tab: when a query is showing the clear (✕) button, forward-tab
1197
- // moves focus to it (keeping the popup open) so the affordance is
1198
- // keyboard-reachable. Every other Tab dismisses the popup as usual.
1199
- if (e.key === 'Tab' && (e.shiftKey || !hasQuery)) {
1200
- onKeyDown(e);
1201
- }
1202
- }}
1203
- placeholder={searchPlaceholder}
1204
- />
1205
- </div>
1200
+ // Tab: when a query is showing the clear (✕) button, forward-tab
1201
+ // moves focus to it (keeping the popup open) so the affordance is
1202
+ // keyboard-reachable. Every other Tab dismisses the popup as usual.
1203
+ if (e.key === 'Tab' && (e.shiftKey || !hasQuery)) {
1204
+ onKeyDown(e);
1205
+ }
1206
+ }}
1207
+ placeholder={searchPlaceholder}
1208
+ />
1206
1209
  );
1207
1210
  }, [
1208
1211
  hasSearch,
@@ -1234,6 +1237,24 @@ export function MultiSelector<T extends MultiSelectorOptionType>({
1234
1237
  const isPartiallySelected =
1235
1238
  isSelectAll && selectAllState === 'indeterminate';
1236
1239
 
1240
+ const checkbox = (
1241
+ <div
1242
+ inert
1243
+ {...stylex.props(
1244
+ styles.checkboxDecorative,
1245
+ indicatorPosition === 'end' && styles.checkboxDecorativeEnd,
1246
+ )}>
1247
+ <CheckboxInput
1248
+ label=""
1249
+ isLabelHidden
1250
+ value={checkboxValue}
1251
+ onChange={() => {}}
1252
+ isDisabled={item.disabled}
1253
+ size={size === 'lg' ? 'md' : size}
1254
+ />
1255
+ </div>
1256
+ );
1257
+
1237
1258
  return (
1238
1259
  <div
1239
1260
  key={item.value}
@@ -1273,16 +1294,7 @@ export function MultiSelector<T extends MultiSelectorOptionType>({
1273
1294
  item.disabled && styles.itemDisabled,
1274
1295
  ),
1275
1296
  )}>
1276
- <div inert {...stylex.props(styles.checkboxDecorative)}>
1277
- <CheckboxInput
1278
- label=""
1279
- isLabelHidden
1280
- value={checkboxValue}
1281
- onChange={() => {}}
1282
- isDisabled={item.disabled}
1283
- size={size === 'lg' ? 'md' : size}
1284
- />
1285
- </div>
1297
+ {indicatorPosition === 'start' && checkbox}
1286
1298
  {renderOption && !isSelectAll ? (
1287
1299
  renderOption(item)
1288
1300
  ) : (
@@ -1290,11 +1302,13 @@ export function MultiSelector<T extends MultiSelectorOptionType>({
1290
1302
  {item.label ?? item.value}
1291
1303
  </span>
1292
1304
  )}
1305
+ {indicatorPosition === 'end' && checkbox}
1293
1306
  </div>
1294
1307
  );
1295
1308
  },
1296
1309
  [
1297
1310
  renderOption,
1311
+ indicatorPosition,
1298
1312
  highlightedIndex,
1299
1313
  optimisticValue,
1300
1314
  allEnabledSelected,
@@ -1320,12 +1334,11 @@ export function MultiSelector<T extends MultiSelectorOptionType>({
1320
1334
  ? sortedItems.length - 1
1321
1335
  : sortedItems.length;
1322
1336
 
1323
- // Show select-all only when there are real items to select
1337
+ // Show select-all only when there are real items to select. It reads as
1338
+ // the first row of the list, not a section of its own — no divider under
1339
+ // it (the checkbox column already lines it up with the options below).
1324
1340
  if (hasSelectAll && realItemCount > 0) {
1325
1341
  elements.push(renderItem(sortedItems[0], 0));
1326
- elements.push(
1327
- <Divider key="select-all-divider" xstyle={styles.divider} />,
1328
- );
1329
1342
  cursor = 1;
1330
1343
  } else if (hasSelectAll) {
1331
1344
  // Skip the select-all sentinel when there are no real items
@@ -1394,17 +1407,23 @@ export function MultiSelector<T extends MultiSelectorOptionType>({
1394
1407
  sectionItems.push(renderItem(sortedItems[cursor], cursor));
1395
1408
  cursor++;
1396
1409
  }
1397
- if (option.title) {
1398
- elements.push(
1399
- <Divider
1400
- key={`section-divider-${i}`}
1401
- label={option.title}
1402
- xstyle={styles.sectionDivider}
1403
- />,
1404
- );
1405
- }
1410
+ // The heading lives INSIDE the group and is aria-hidden: the group
1411
+ // already carries the title as its accessible name, so exposing the
1412
+ // text again would announce it twice. This also keeps role="listbox"'s
1413
+ // children to option/group only — the old labeled Divider sat in the
1414
+ // listbox as a stray role="separator".
1406
1415
  elements.push(
1407
1416
  <div key={`section-${i}`} role="group" aria-label={option.title}>
1417
+ {option.title && (
1418
+ <div
1419
+ aria-hidden="true"
1420
+ {...mergeProps(
1421
+ themeProps('multi-selector-section-heading'),
1422
+ stylex.props(styles.sectionHeading),
1423
+ )}>
1424
+ {option.title}
1425
+ </div>
1426
+ )}
1408
1427
  {sectionItems}
1409
1428
  </div>,
1410
1429
  );
@@ -1453,6 +1472,7 @@ export function MultiSelector<T extends MultiSelectorOptionType>({
1453
1472
  styles.triggerContainer,
1454
1473
  sizeStyles[size],
1455
1474
  variant === 'ghost' && styles.triggerGhost,
1475
+ variant === 'ghost' && focusOutlineStyles.focusWithin,
1456
1476
  isDisabled && inputWrapperStyles.disabled,
1457
1477
  variant === 'ghost' && isDisabled && styles.triggerGhostDisabled,
1458
1478
  optimisticValue.length === 0 && styles.triggerPlaceholder,
@@ -1541,7 +1561,10 @@ export function MultiSelector<T extends MultiSelectorOptionType>({
1541
1561
  aria-label={t(STATUS_BUTTON_LABEL_KEY[status.type])}
1542
1562
  aria-describedby={statusTooltip.describedBy}
1543
1563
  onClick={e => e.stopPropagation()}
1544
- {...stylex.props(styles.statusButton)}>
1564
+ {...stylex.props(
1565
+ focusOutlineStyles.focusVisible,
1566
+ styles.statusButton,
1567
+ )}>
1545
1568
  <Icon
1546
1569
  icon={STATUS_ICON_MAP[status.type]}
1547
1570
  size="sm"
@@ -1583,20 +1606,40 @@ export function MultiSelector<T extends MultiSelectorOptionType>({
1583
1606
  </div>
1584
1607
 
1585
1608
  {popover.render(
1586
- <div
1587
- {...mergeProps(
1588
- themeProps('multi-selector-popup'),
1589
- stylex.props(styles.dropdown),
1590
- )}>
1591
- {renderSearch()}
1592
- <div
1593
- id={listboxId}
1594
- role="listbox"
1595
- aria-multiselectable="true"
1596
- aria-labelledby={triggerId}>
1597
- {renderOptions()}
1609
+ hasSearch ? (
1610
+ // With a search row the panel splits: the header stays put while the
1611
+ // options scroll under it, so the field does not slide out of reach
1612
+ // in a long list. Without one the panel is a single scroll container,
1613
+ // exactly as before.
1614
+ <div>
1615
+ {renderSearch()}
1616
+ {/*
1617
+ Separates the header from the options and spans the panel: the
1618
+ search row and the option list each hold their own inline
1619
+ padding, the line does not, so it reads as the panel's own edge.
1620
+ */}
1621
+ <Divider />
1622
+ <div {...stylex.props(styles.dropdown)}>
1623
+ <div
1624
+ id={listboxId}
1625
+ role="listbox"
1626
+ aria-multiselectable="true"
1627
+ aria-labelledby={triggerId}>
1628
+ {renderOptions()}
1629
+ </div>
1630
+ </div>
1598
1631
  </div>
1599
- </div>,
1632
+ ) : (
1633
+ <div {...stylex.props(styles.dropdown)}>
1634
+ <div
1635
+ id={listboxId}
1636
+ role="listbox"
1637
+ aria-multiselectable="true"
1638
+ aria-labelledby={triggerId}>
1639
+ {renderOptions()}
1640
+ </div>
1641
+ </div>
1642
+ ),
1600
1643
  {
1601
1644
  placement: 'below',
1602
1645
  alignment: 'start',
@@ -133,6 +133,7 @@ export const docs = {
133
133
  theming: {
134
134
  targets: [
135
135
  {className: 'astryx-popover'},
136
+ {className: 'astryx-popover-surface'},
136
137
  ],
137
138
  vars: [
138
139
  {name: '--_popover-radius', description: 'Border radius of the popover', default: 'var(--radius-element)', private: true},
@@ -277,6 +278,7 @@ export const docsZh = {
277
278
  theming: {
278
279
  targets: [
279
280
  {className: 'astryx-popover'},
281
+ {className: 'astryx-popover-surface'},
280
282
  ],
281
283
  vars: [
282
284
  {name: '--_popover-radius', description: 'Border radius of the popover', default: 'var(--radius-element)', private: true},
@@ -30,6 +30,9 @@ import {Button} from '../Button';
30
30
  import {rtlStyles} from '../utils';
31
31
  import {useTranslator} from '../i18n';
32
32
  import {useDevWarning} from '../hooks/useDevWarning';
33
+ import {mergeProps} from '../utils/mergeProps';
34
+ import {themeProps} from '../utils/themeProps';
35
+ import {stableClassName} from '../naming';
33
36
 
34
37
  const styles = stylex.create({
35
38
  // Default popover surface — background, radius, shadow.
@@ -187,6 +190,22 @@ export interface UsePopoverOptions {
187
190
  * @default true
188
191
  */
189
192
  hasSurface?: boolean;
193
+
194
+ /**
195
+ * Theme-target name stamped on the popup SURFACE — the element that paints
196
+ * the background, radius and elevation — without the `astryx-` prefix
197
+ * (e.g. `'complex-selector-popup'`).
198
+ *
199
+ * The surface is created here, not by the calling component, so a component
200
+ * that wants its popup themeable cannot reach it on its own: a target it
201
+ * renders itself lands on its content INSIDE the surface, where a background
202
+ * or radius rule paints the wrong box. Name the surface through this option
203
+ * and document the class in the component's `theming.targets`.
204
+ *
205
+ * The shared `astryx-popover-surface` class is always present alongside it,
206
+ * so a theme can style every popup surface at once.
207
+ */
208
+ surfaceTarget?: string;
190
209
  }
191
210
 
192
211
  /**
@@ -312,6 +331,7 @@ export function usePopover(options: UsePopoverOptions = {}): UsePopoverReturn {
312
331
  hasEscapeDismiss = true,
313
332
  hasAutoFocus = true,
314
333
  hasSurface = true,
334
+ surfaceTarget,
315
335
  hasCloseButton = true,
316
336
  closeButtonLabel: closeButtonLabelFromProps,
317
337
  dialogLabel,
@@ -406,16 +426,28 @@ export function usePopover(options: UsePopoverOptions = {}): UsePopoverReturn {
406
426
  // Wrapped render function that includes surface styles and optional hidden close button
407
427
  const render = useCallback(
408
428
  (children: ReactNode, props?: ContextRenderProps): ReactNode => {
429
+ // `mergeProps` is positional — a third OBJECT argument is read as
430
+ // `style`, not as more props — so the surface's classes are composed
431
+ // into one props object before merging with the StyleX result.
432
+ const surfaceProps = themeProps('popover-surface');
433
+ const surfaceClassName =
434
+ surfaceTarget != null
435
+ ? `${surfaceProps.className} ${stableClassName(surfaceTarget)}`
436
+ : surfaceProps.className;
437
+
409
438
  return layer.render(
410
439
  <div
411
440
  ref={contentRef}
412
441
  role={role === 'dialog' ? 'dialog' : undefined}
413
442
  aria-modal={role === 'dialog' && isModal ? true : undefined}
414
443
  aria-label={role === 'dialog' ? dialogLabel : undefined}
415
- {...stylex.props(
416
- styles.contentWrapper,
417
- hasSurface && styles.surface,
418
- xstyle,
444
+ {...mergeProps(
445
+ {...surfaceProps, className: surfaceClassName},
446
+ stylex.props(
447
+ styles.contentWrapper,
448
+ hasSurface && styles.surface,
449
+ xstyle,
450
+ ),
419
451
  )}>
420
452
  {children}
421
453
  {hasCloseButton && (
@@ -439,6 +471,7 @@ export function usePopover(options: UsePopoverOptions = {}): UsePopoverReturn {
439
471
  layer,
440
472
  hasCloseButton,
441
473
  hasSurface,
474
+ surfaceTarget,
442
475
  closeButtonLabel,
443
476
  contentRef,
444
477
  dialogLabel,
@@ -30,6 +30,7 @@ import {
30
30
  typeScaleVars,
31
31
  } from '../theme/tokens.stylex';
32
32
  import {mergeProps} from '../utils';
33
+ import {focusOutlineStyles} from '../utils/focusOutline.stylex';
33
34
  import type {BaseProps} from '../BaseProps';
34
35
  import {themeProps} from '../utils/themeProps';
35
36
  import {VisuallyHidden} from '../VisuallyHidden';
@@ -282,14 +283,6 @@ const styles = stylex.create({
282
283
  top: '50%',
283
284
  width: 'var(--_progressbar-mark-width, 2px)',
284
285
  height: 'var(--_progressbar-mark-height, 8px)',
285
- outline: {
286
- default: 'none',
287
- ':focus-visible': `2px solid ${colorVars['--color-accent']}`,
288
- },
289
- outlineOffset: {
290
- default: '0',
291
- ':focus-visible': '2px',
292
- },
293
286
  transform: {
294
287
  default: 'translate(-50%, -50%)',
295
288
  ':is([dir="rtl"] *)': 'translate(50%, -50%)',
@@ -581,6 +574,7 @@ export function ProgressBar({
581
574
  placement: mark.isOnFill ? 'fill' : 'track',
582
575
  }),
583
576
  stylex.props(
577
+ focusOutlineStyles.focusVisible,
584
578
  styles.mark,
585
579
  mark.isOnFill
586
580
  ? markOnFillStyles[fillVariant]
@@ -1,7 +1,5 @@
1
1
  // Copyright (c) Meta Platforms, Inc. and affiliates.
2
2
 
3
- 'use client';
4
-
5
3
  /**
6
4
  * @file Section.tsx
7
5
  * @input Uses container utility, StyleX
@@ -1,7 +1,5 @@
1
1
  // Copyright (c) Meta Platforms, Inc. and affiliates.
2
2
 
3
- 'use client';
4
-
5
3
  /**
6
4
  * @file index.ts
7
5
  * @input Imports Section component
@@ -27,10 +27,13 @@ export const docs = {
27
27
  states: ['disabled'],
28
28
  },
29
29
  {className: 'astryx-selector-option'},
30
+ {className: 'astryx-selector-search'},
31
+ {className: 'astryx-selector-section-heading'},
30
32
  {className: 'astryx-selector-empty-state'},
31
33
  {className: 'astryx-selector-clear-icon', deprecatedFor: 'input-clear-icon'},
32
34
  {className: 'astryx-selector-indicator-icon', states: ['state']},
33
35
  {className: 'astryx-selector-check'},
36
+ {className: 'astryx-selector-popup'},
34
37
  ],
35
38
  },
36
39
  description: 'Dropdown selector for choosing from a list of options.',
@@ -156,6 +159,13 @@ export const docs = {
156
159
  description:
157
160
  'Custom render function for each selectable option in the dropdown. Use this instead of JSX children; dividers and sections are rendered by the selector.',
158
161
  },
162
+ {
163
+ name: 'indicatorPosition',
164
+ type: "'start' | 'end'",
165
+ description:
166
+ 'Which edge of the option row carries the selected mark. start reserves a mark column ahead of every label so they stay aligned, the way a native menu does; end is the house convention shared with Typeahead and CommandPalette.',
167
+ default: "'end'",
168
+ },
159
169
  {
160
170
  name: 'width',
161
171
  type: 'SizeValue',