@astryxdesign/core 0.4.0 → 0.4.1-canary.f4868b2

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 (236) 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/NavItem/navItemStyles.stylex.d.ts +17 -5
  64. package/dist/NavItem/navItemStyles.stylex.d.ts.map +1 -1
  65. package/dist/NavItem/navItemStyles.stylex.js +11 -5
  66. package/dist/Popover/usePopover.d.ts +15 -0
  67. package/dist/Popover/usePopover.d.ts.map +1 -1
  68. package/dist/Popover/usePopover.js +14 -2
  69. package/dist/ProgressBar/ProgressBar.d.ts.map +1 -1
  70. package/dist/ProgressBar/ProgressBar.js +2 -3
  71. package/dist/Section/Section.d.ts.map +1 -1
  72. package/dist/Section/Section.js +1 -2
  73. package/dist/Section/index.d.ts.map +1 -1
  74. package/dist/Section/index.js +1 -2
  75. package/dist/Selector/Selector.d.ts +10 -0
  76. package/dist/Selector/Selector.d.ts.map +1 -1
  77. package/dist/Selector/Selector.js +135 -95
  78. package/dist/SideNav/SideNav.d.ts +7 -9
  79. package/dist/SideNav/SideNav.d.ts.map +1 -1
  80. package/dist/SideNav/SideNav.js +32 -5
  81. package/dist/SideNav/SideNavCollapseButton.d.ts +25 -9
  82. package/dist/SideNav/SideNavCollapseButton.d.ts.map +1 -1
  83. package/dist/SideNav/SideNavCollapseButton.js +39 -15
  84. package/dist/SideNav/SideNavCollapseContext.d.ts +21 -0
  85. package/dist/SideNav/SideNavCollapseContext.d.ts.map +1 -1
  86. package/dist/SideNav/SideNavCollapseContext.js +16 -2
  87. package/dist/SideNav/SideNavHeading.d.ts.map +1 -1
  88. package/dist/SideNav/SideNavHeading.js +70 -24
  89. package/dist/SideNav/SideNavItem.d.ts +7 -2
  90. package/dist/SideNav/SideNavItem.d.ts.map +1 -1
  91. package/dist/SideNav/SideNavItem.js +114 -75
  92. package/dist/SideNav/SideNavSection.d.ts.map +1 -1
  93. package/dist/SideNav/SideNavSection.js +7 -16
  94. package/dist/SideNav/index.d.ts +1 -1
  95. package/dist/SideNav/index.d.ts.map +1 -1
  96. package/dist/Skeleton/Skeleton.d.ts.map +1 -1
  97. package/dist/Skeleton/Skeleton.js +1 -2
  98. package/dist/Skeleton/index.d.ts.map +1 -1
  99. package/dist/Skeleton/index.js +1 -2
  100. package/dist/Slider/Slider.d.ts.map +1 -1
  101. package/dist/Slider/Slider.js +2 -6
  102. package/dist/Switch/Switch.d.ts.map +1 -1
  103. package/dist/Switch/Switch.js +3 -3
  104. package/dist/Table/TableRow.d.ts +1 -1
  105. package/dist/Table/TableRow.d.ts.map +1 -1
  106. package/dist/Table/TableRow.js +4 -2
  107. package/dist/TimeInput/TimeInput.d.ts.map +1 -1
  108. package/dist/TimeInput/TimeInput.js +7 -2
  109. package/dist/Timestamp/Timestamp.d.ts.map +1 -1
  110. package/dist/Timestamp/Timestamp.js +14 -3
  111. package/dist/Timestamp/formatInstant.d.ts +11 -1
  112. package/dist/Timestamp/formatInstant.d.ts.map +1 -1
  113. package/dist/Timestamp/formatInstant.js +3 -1
  114. package/dist/TopNav/TopNavMegaMenu.js +1 -1
  115. package/dist/TopNav/TopNavMegaMenuItem.js +1 -1
  116. package/dist/TopNav/TopNavMenu.d.ts.map +1 -1
  117. package/dist/TopNav/TopNavMenu.js +2 -2
  118. package/dist/VisuallyHidden/VisuallyHidden.d.ts.map +1 -1
  119. package/dist/VisuallyHidden/VisuallyHidden.js +1 -2
  120. package/dist/VisuallyHidden/index.d.ts.map +1 -1
  121. package/dist/VisuallyHidden/index.js +1 -2
  122. package/dist/astryx.css +20 -17
  123. package/dist/astryx.umd.js +48 -48
  124. package/dist/astryx.umd.js.map +4 -4
  125. package/dist/hooks/useInputStatusIcon.d.ts.map +1 -1
  126. package/dist/hooks/useInputStatusIcon.js +17 -4
  127. package/dist/theme/defineTheme.d.ts +2 -2
  128. package/dist/theme/defineTheme.d.ts.map +1 -1
  129. package/dist/theme/defineTheme.js +2 -1
  130. package/dist/theme/index.d.ts +1 -1
  131. package/dist/theme/index.d.ts.map +1 -1
  132. package/dist/theme/index.js +1 -1
  133. package/dist/theme/tokens.stylex.d.ts +12 -0
  134. package/dist/theme/tokens.stylex.d.ts.map +1 -1
  135. package/dist/theme/tokens.stylex.js +23 -0
  136. package/dist/utils/focusOutline.stylex.d.ts +34 -19
  137. package/dist/utils/focusOutline.stylex.d.ts.map +1 -1
  138. package/dist/utils/focusOutline.stylex.js +46 -33
  139. package/dist/utils/index.d.ts +1 -0
  140. package/dist/utils/index.d.ts.map +1 -1
  141. package/dist/utils/index.js +5 -1
  142. package/dist/utils/interactionModality.d.ts +34 -0
  143. package/dist/utils/interactionModality.d.ts.map +1 -0
  144. package/dist/utils/interactionModality.js +70 -0
  145. package/locales/en.json +44 -0
  146. package/locales/pseudo.json +33 -0
  147. package/package.json +3 -3
  148. package/src/AppShell/AppShell.doc.mjs +3 -0
  149. package/src/AspectRatio/AspectRatio.tsx +0 -2
  150. package/src/AspectRatio/index.ts +0 -2
  151. package/src/Badge/Badge.tsx +0 -2
  152. package/src/Badge/index.ts +0 -2
  153. package/src/Banner/Banner.test.tsx +58 -1
  154. package/src/Banner/Banner.tsx +24 -5
  155. package/src/Blockquote/Blockquote.tsx +0 -2
  156. package/src/Blockquote/index.ts +0 -2
  157. package/src/Button/Button.doc.mjs +2 -2
  158. package/src/Button/Button.tsx +5 -4
  159. package/src/Card/Card.tsx +0 -2
  160. package/src/Card/index.ts +0 -2
  161. package/src/Center/Center.tsx +0 -2
  162. package/src/Center/index.ts +0 -2
  163. package/src/CheckboxInput/CheckboxInput.test.tsx +8 -3
  164. package/src/Code/Code.tsx +0 -2
  165. package/src/CommandPalette/CommandPalette.test.tsx +54 -0
  166. package/src/CommandPalette/CommandPalette.tsx +5 -0
  167. package/src/ComplexSelector/ComplexSelector.test.tsx +15 -6
  168. package/src/ComplexSelector/ComplexSelector.tsx +6 -10
  169. package/src/DateInput/DateInput.test.tsx +19 -0
  170. package/src/DateInput/DateInput.tsx +3 -6
  171. package/src/DateRangeInput/DateRangeInput.tsx +3 -9
  172. package/src/DateTimeInput/DateTimeInput.test.tsx +79 -1
  173. package/src/DateTimeInput/DateTimeInput.tsx +23 -9
  174. package/src/DropdownMenu/DropdownMenuRadioGroup.tsx +2 -1
  175. package/src/Field/PanelSearchInput.tsx +271 -0
  176. package/src/FileInput/FileInput.tsx +21 -8
  177. package/src/Grid/Grid.tsx +0 -2
  178. package/src/Grid/index.ts +0 -2
  179. package/src/Icon/globalIconRegistry.tsx +5 -4
  180. package/src/Indicator/Indicator.doc.mjs +1 -1
  181. package/src/Indicator/index.ts +1 -0
  182. package/src/Indicator/types.ts +11 -0
  183. package/src/Lightbox/Lightbox.tsx +2 -11
  184. package/src/MultiSelector/MultiSelector.doc.mjs +9 -0
  185. package/src/MultiSelector/MultiSelector.test.tsx +135 -5
  186. package/src/MultiSelector/MultiSelector.tsx +170 -127
  187. package/src/NavItem/navItemStyles.stylex.ts +31 -5
  188. package/src/Popover/Popover.doc.mjs +2 -0
  189. package/src/Popover/usePopover.tsx +37 -4
  190. package/src/ProgressBar/ProgressBar.tsx +2 -8
  191. package/src/Section/Section.tsx +0 -2
  192. package/src/Section/index.ts +0 -2
  193. package/src/Selector/Selector.doc.mjs +10 -0
  194. package/src/Selector/Selector.test.tsx +386 -2
  195. package/src/Selector/Selector.tsx +182 -122
  196. package/src/SideNav/SideNav.doc.mjs +13 -4
  197. package/src/SideNav/SideNav.test.tsx +771 -2
  198. package/src/SideNav/SideNav.tsx +37 -16
  199. package/src/SideNav/SideNavCollapseButton.doc.mjs +28 -6
  200. package/src/SideNav/SideNavCollapseButton.tsx +67 -15
  201. package/src/SideNav/SideNavCollapseContext.ts +29 -9
  202. package/src/SideNav/SideNavHeading.tsx +28 -10
  203. package/src/SideNav/SideNavItem.doc.mjs +13 -0
  204. package/src/SideNav/SideNavItem.tsx +92 -84
  205. package/src/SideNav/SideNavSection.tsx +6 -20
  206. package/src/SideNav/index.ts +2 -0
  207. package/src/Skeleton/Skeleton.tsx +0 -2
  208. package/src/Skeleton/index.ts +0 -2
  209. package/src/Slider/Slider.tsx +2 -11
  210. package/src/Switch/Switch.tsx +10 -2
  211. package/src/Table/Table.test.tsx +31 -0
  212. package/src/Table/TableRow.tsx +6 -0
  213. package/src/TimeInput/TimeInput.test.tsx +54 -0
  214. package/src/TimeInput/TimeInput.tsx +17 -2
  215. package/src/Timestamp/Timestamp.test.tsx +91 -14
  216. package/src/Timestamp/Timestamp.tsx +14 -2
  217. package/src/Timestamp/formatInstant.ts +16 -1
  218. package/src/TopNav/TopNavMegaMenu.doc.mjs +5 -0
  219. package/src/TopNav/TopNavMegaMenu.test.tsx +43 -0
  220. package/src/TopNav/TopNavMegaMenu.tsx +4 -1
  221. package/src/TopNav/TopNavMegaMenuItem.tsx +1 -1
  222. package/src/TopNav/TopNavMenu.test.tsx +41 -0
  223. package/src/TopNav/TopNavMenu.tsx +8 -2
  224. package/src/TreeList/TreeList.test.tsx +3 -2
  225. package/src/VisuallyHidden/VisuallyHidden.tsx +0 -2
  226. package/src/VisuallyHidden/index.ts +0 -2
  227. package/src/hooks/useInputStatusIcon.tsx +8 -8
  228. package/src/serverSafeComponents.test.ts +665 -0
  229. package/src/theme/defineTheme.ts +3 -0
  230. package/src/theme/generateThemeRules.test.ts +17 -0
  231. package/src/theme/index.ts +2 -0
  232. package/src/theme/tokens.stylex.ts +18 -0
  233. package/src/theme/tokens.test.ts +37 -0
  234. package/src/utils/focusOutline.stylex.ts +37 -23
  235. package/src/utils/index.ts +4 -0
  236. package/src/utils/interactionModality.ts +74 -0
@@ -33,6 +33,7 @@ import {usePopover} from '../Popover/usePopover';
33
33
  import {useTooltip} from '../Tooltip';
34
34
  import {Icon, renderIconSlot, type IconType} from '../Icon';
35
35
  import {useIndicator} from '../Indicator';
36
+ import type {IndicatorPosition} from '../Indicator';
36
37
  import type {IconName} from '../Icon';
37
38
  import {
38
39
  Field,
@@ -46,7 +47,7 @@ import {Divider} from '../Divider';
46
47
  import {layerAnimations} from '../Layer/layerAnimations.stylex';
47
48
  import type {LayerPlacement} from '../Layer/useLayer';
48
49
  import {Spinner} from '../Spinner';
49
- import {TextInput} from '../TextInput';
50
+ import {PanelSearchInput} from '../Field/PanelSearchInput';
50
51
  import {useAnnounce} from '../hooks/useAnnounce';
51
52
  import {
52
53
  colorVars,
@@ -76,6 +77,7 @@ import {useSize} from '../SizeContext/SizeContext';
76
77
  import type {BaseProps} from '../BaseProps';
77
78
  import type {SizeValue} from '../utils/types';
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';
@@ -179,14 +181,6 @@ const styles = stylex.create({
179
181
  ':focus-within': 'none',
180
182
  },
181
183
  fontWeight: fontWeightVars['--font-weight-medium'],
182
- outline: {
183
- default: 'none',
184
- ':has(:focus-visible)': `2px solid ${colorVars['--color-accent']}`,
185
- },
186
- outlineOffset: {
187
- default: '0',
188
- ':has(:focus-visible)': '3px',
189
- },
190
184
  transitionProperty:
191
185
  'background-image, background-color, color, opacity, transform',
192
186
  transform: {
@@ -215,11 +209,6 @@ const styles = stylex.create({
215
209
  color: 'inherit',
216
210
  cursor: 'pointer',
217
211
  borderRadius: radiusVars['--radius-element'],
218
- outline: {
219
- default: 'none',
220
- ':focus-visible': `${borderVars['--border-width']} solid ${colorVars['--color-accent']}`,
221
- },
222
- outlineOffset: 1,
223
212
  },
224
213
 
225
214
  // Dropdown container
@@ -237,6 +226,11 @@ const styles = stylex.create({
237
226
  // pixel in the menu; the borderless ghost variant needs no correction.
238
227
  paddingInline: `calc(${spacingVars['--spacing-1']} + ${borderVars['--border-width']})`,
239
228
  },
229
+ // Same correction for the search row's gutter, so the search field and the
230
+ // option rows share one left edge.
231
+ searchRowInput: {
232
+ paddingInline: `calc(${spacingVars['--spacing-1']} + ${borderVars['--border-width']})`,
233
+ },
240
234
  dropdownHidden: {
241
235
  opacity: 0,
242
236
  transition: 'none',
@@ -246,15 +240,6 @@ const styles = stylex.create({
246
240
  popover: {
247
241
  minWidth: 'anchor-size(width)',
248
242
  },
249
- // Search field. The inner TextInput owns the border, focus ring, magnifier
250
- // (startIcon), and clear button (hasClear); this wrapper only supplies the
251
- // dropdown's inline/block padding around it.
252
- searchWrapper: {
253
- display: 'flex',
254
- alignItems: 'center',
255
- paddingInline: spacingVars['--spacing-2'],
256
- paddingBlock: spacingVars['--spacing-1'],
257
- },
258
243
 
259
244
  // Empty state
260
245
  emptyState: {
@@ -265,9 +250,19 @@ const styles = stylex.create({
265
250
  fontSize: typeScaleVars['--text-label-size'],
266
251
  },
267
252
 
268
- // Section divider with label
269
- sectionDivider: {
270
- marginBlock: spacingVars['--spacing-1'],
253
+ // Section heading. Plain secondary text, no rules — the same treatment
254
+ // DropdownMenu and CommandPaletteGroup already use for a group heading in a
255
+ // panel list. A labeled Divider (line–text–line) reads as a separator, and
256
+ // next to the search row's own divider it stacked two rules a few pixels
257
+ // apart.
258
+ sectionHeading: {
259
+ paddingBlock: spacingVars['--spacing-1'],
260
+ paddingInline: spacingVars['--spacing-2'],
261
+ fontFamily: typographyVars['--font-family-body'],
262
+ fontSize: typeScaleVars['--text-supporting-size'],
263
+ lineHeight: typeScaleVars['--text-supporting-leading'],
264
+ color: colorVars['--color-text-secondary'],
265
+ userSelect: 'none',
271
266
  },
272
267
 
273
268
  // Divider
@@ -301,6 +296,19 @@ const styles = stylex.create({
301
296
  flex: 1,
302
297
  minWidth: 0,
303
298
  },
299
+ // The mark's column, reserved on every row and at either position, so a row
300
+ // occupies the same geometry whether or not it is the chosen one — the
301
+ // default check draws nothing when unchecked, and without the column a list
302
+ // would indent (or truncate) its chosen row differently from the rest.
303
+ // `minWidth` rather than `width`: a theme can replace `check` with a larger
304
+ // indicator (a radio is 20px at `sm`), and the column has to grow with it.
305
+ itemMarkColumn: {
306
+ display: 'inline-flex',
307
+ alignItems: 'center',
308
+ justifyContent: 'center',
309
+ flexShrink: 0,
310
+ minWidth: '1rem',
311
+ },
304
312
  itemCheckmark: {
305
313
  flexShrink: 0,
306
314
  width: 16,
@@ -518,6 +526,16 @@ interface SelectorPropsBase<
518
526
  */
519
527
  renderOption?: (option: SelectorOptionData) => ReactNode;
520
528
 
529
+ /**
530
+ * Which edge of the option row carries the selected mark. `start` reserves a
531
+ * mark column ahead of every label so they stay aligned, the way a native
532
+ * menu does; `end` is the house convention shared with Typeahead and
533
+ * CommandPalette.
534
+ *
535
+ * @default 'end'
536
+ */
537
+ indicatorPosition?: IndicatorPosition;
538
+
521
539
  /**
522
540
  * Whether to show a search input for filtering options.
523
541
  * @default false
@@ -668,6 +686,7 @@ export function Selector<T extends SelectorOptionType>(
668
686
  startIcon,
669
687
  htmlName,
670
688
  renderOption,
689
+ indicatorPosition = 'end',
671
690
  hasSearch = false,
672
691
  searchPlaceholder: searchPlaceholderFromProps,
673
692
  placement,
@@ -706,7 +725,7 @@ export function Selector<T extends SelectorOptionType>(
706
725
  const inputGroup = useInputGroup();
707
726
 
708
727
  const [searchQuery, setSearchQuery] = useState('');
709
- // A typed query shows TextInput's built-in clear (✕) button, which becomes
728
+ // A typed query shows the search row's clear (✕) button, which becomes
710
729
  // the next tab stop after the search input.
711
730
  const hasQuery = searchQuery.length > 0;
712
731
 
@@ -797,6 +816,9 @@ export function Selector<T extends SelectorOptionType>(
797
816
  // The popup's own role="listbox" is the exposed semantics; the trigger
798
817
  // keeps DOM focus, so wrapping it in a modal dialog would misrepresent it.
799
818
  role: 'none',
819
+ // The theme target belongs on the SURFACE that paints the popup, which
820
+ // `usePopover` owns — not on the scrolling list inside it.
821
+ surfaceTarget: 'selector-popup',
800
822
  });
801
823
 
802
824
  // Open dropdown on mount when isDefaultOpen is true
@@ -988,11 +1010,40 @@ export function Selector<T extends SelectorOptionType>(
988
1010
  return null;
989
1011
  }
990
1012
  return (
991
- <div
992
- {...stylex.props(styles.searchWrapper)}
993
- onKeyDown={e => {
994
- // The clear (✕) button lives inside the TextInput, after the input in
995
- // DOM order. When it is focused and the user tabs forward there is
1013
+ <PanelSearchInput
1014
+ ref={searchRef}
1015
+ id={searchId}
1016
+ // The search row is the panel's header: a magnifier, a borderless
1017
+ // input, and the shared clear (✕) button. It deliberately does NOT
1018
+ // render a bordered TextInput — the popup is already a bordered
1019
+ // surface, and a field inside it drew a second box within that box.
1020
+ label={t('@astryx.selector.searchOptions')}
1021
+ // Same accessible name the TextInput's built-in clear produced
1022
+ // ("Clear Search options"), so the affordance keeps its name while its
1023
+ // chrome changes.
1024
+ clearLabel={t('@astryx.textInput.clearLabel', {
1025
+ label: t('@astryx.selector.searchOptions'),
1026
+ })}
1027
+ {...themeProps('selector-search')}
1028
+ xstyle={variant !== 'ghost' && styles.searchRowInput}
1029
+ // When hasSearch is set, focus moves into this input on open, so it —
1030
+ // not the trigger — must be the combobox that reports the highlighted
1031
+ // option via aria-activedescendant (comboboxes-4). A bare searchbox
1032
+ // left the highlight silent to screen readers.
1033
+ role="combobox"
1034
+ aria-expanded={popover.isOpen}
1035
+ aria-controls={listboxId}
1036
+ aria-autocomplete="list"
1037
+ aria-activedescendant={
1038
+ popover.isOpen && highlightedIndex >= 0
1039
+ ? getItemId(highlightedIndex)
1040
+ : undefined
1041
+ }
1042
+ value={searchQuery}
1043
+ onValueChange={handleSearchChange}
1044
+ onContainerKeyDown={e => {
1045
+ // The clear (✕) button lives inside the row, after the input in DOM
1046
+ // order. When it is focused and the user tabs forward there is
996
1047
  // nothing else in the popup, so dismiss it (Shift+Tab returns to the
997
1048
  // input natively). Key events originating on the input are handled on
998
1049
  // the input below; ignore them here so we don't double-dismiss.
@@ -1002,65 +1053,32 @@ export function Selector<T extends SelectorOptionType>(
1002
1053
  if (e.key === 'Tab' && !e.shiftKey) {
1003
1054
  onKeyDown(e);
1004
1055
  }
1005
- }}>
1006
- <TextInput
1007
- ref={searchRef}
1008
- id={searchId}
1009
- // The search field IS a TextInput: the leading magnifier is its
1010
- // `startIcon` and the trailing clear (✕) is its built-in `hasClear`
1011
- // (which resets the value and refocuses the input). We add no bespoke
1012
- // affordance chrome — the field just looks and behaves like every
1013
- // other Astryx input.
1014
- label={t('@astryx.selector.searchOptions')}
1015
- isLabelHidden
1016
- startIcon="search"
1017
- hasClear
1018
- size="sm"
1019
- // Fill the dropdown's width (minus the wrapper's inline padding) so
1020
- // the field is flush end-to-end rather than sized to its content.
1021
- width="100%"
1022
- // When hasSearch is set, focus moves into this input on open, so it —
1023
- // not the trigger — must be the combobox that reports the highlighted
1024
- // option via aria-activedescendant (comboboxes-4). A bare searchbox
1025
- // left the highlight silent to screen readers. role + aria-* pass
1026
- // through to the underlying <input> via BaseProps.
1027
- role="combobox"
1028
- aria-expanded={popover.isOpen}
1029
- aria-controls={listboxId}
1030
- aria-autocomplete="list"
1031
- aria-activedescendant={
1032
- popover.isOpen && highlightedIndex >= 0
1033
- ? getItemId(highlightedIndex)
1034
- : undefined
1056
+ }}
1057
+ onKeyDown={e => {
1058
+ // Arrow keys navigate options; Enter selects; Escape closes.
1059
+ // Home/End are left to the input for caret movement (APG editable
1060
+ // combobox); PageUp/PageDown are the sanctioned substitute for
1061
+ // jumping to the first/last option.
1062
+ if (
1063
+ e.key === 'ArrowDown' ||
1064
+ e.key === 'ArrowUp' ||
1065
+ e.key === 'PageUp' ||
1066
+ e.key === 'PageDown' ||
1067
+ e.key === 'Enter' ||
1068
+ e.key === 'Escape'
1069
+ ) {
1070
+ onKeyDown(e);
1071
+ return;
1035
1072
  }
1036
- value={searchQuery}
1037
- onChange={handleSearchChange}
1038
- onKeyDown={e => {
1039
- // Arrow keys navigate options; Enter selects; Escape closes.
1040
- // Home/End are left to the input for caret movement (APG editable
1041
- // combobox); PageUp/PageDown are the sanctioned substitute for
1042
- // jumping to the first/last option.
1043
- if (
1044
- e.key === 'ArrowDown' ||
1045
- e.key === 'ArrowUp' ||
1046
- e.key === 'PageUp' ||
1047
- e.key === 'PageDown' ||
1048
- e.key === 'Enter' ||
1049
- e.key === 'Escape'
1050
- ) {
1051
- onKeyDown(e);
1052
- return;
1053
- }
1054
- // Tab: when a query is showing the clear (✕) button, forward-tab
1055
- // moves focus to it (keeping the popup open) so the affordance is
1056
- // keyboard-reachable. Every other Tab dismisses the popup as usual.
1057
- if (e.key === 'Tab' && (e.shiftKey || !hasQuery)) {
1058
- onKeyDown(e);
1059
- }
1060
- }}
1061
- placeholder={searchPlaceholder}
1062
- />
1063
- </div>
1073
+ // Tab: when a query is showing the clear (✕) button, forward-tab
1074
+ // moves focus to it (keeping the popup open) so the affordance is
1075
+ // keyboard-reachable. Every other Tab dismisses the popup as usual.
1076
+ if (e.key === 'Tab' && (e.shiftKey || !hasQuery)) {
1077
+ onKeyDown(e);
1078
+ }
1079
+ }}
1080
+ placeholder={searchPlaceholder}
1081
+ />
1064
1082
  );
1065
1083
  }, [
1066
1084
  hasSearch,
@@ -1074,6 +1092,7 @@ export function Selector<T extends SelectorOptionType>(
1074
1092
  popover.isOpen,
1075
1093
  highlightedIndex,
1076
1094
  getItemId,
1095
+ variant,
1077
1096
  t,
1078
1097
  ]);
1079
1098
 
@@ -1088,6 +1107,45 @@ export function Selector<T extends SelectorOptionType>(
1088
1107
  const isHighlighted = flatIndex === highlightedIndex;
1089
1108
  const isSelected = item.value === normalizedValue;
1090
1109
 
1110
+ /*
1111
+ * Rendered UNCONDITIONALLY, with the state passed down: the default
1112
+ * check draws nothing when unchecked, but a theme that replaces the
1113
+ * `check` indicator with a radio needs the unselected state to draw
1114
+ * its empty circle. `{isSelected && …}` would make that impossible.
1115
+ *
1116
+ * `selector-check` stays the stable target for the mark's position
1117
+ * in the row; the indicator owns what the mark looks like.
1118
+ */
1119
+ const mark = (
1120
+ <span {...stylex.props(styles.itemMarkColumn)}>
1121
+ <SelectionMark
1122
+ state={isSelected ? 'checked' : 'unchecked'}
1123
+ size="sm"
1124
+ isDisabled={item.disabled ?? false}
1125
+ {...themeProps('selector-check')}
1126
+ />
1127
+ </span>
1128
+ );
1129
+
1130
+ const optionContent = (
1131
+ <span {...stylex.props(styles.itemContent)}>
1132
+ {renderOption ? renderOption(item) : <DefaultOption option={item} />}
1133
+ </span>
1134
+ );
1135
+
1136
+ const content =
1137
+ indicatorPosition === 'start' ? (
1138
+ <>
1139
+ {mark}
1140
+ {optionContent}
1141
+ </>
1142
+ ) : (
1143
+ <>
1144
+ {optionContent}
1145
+ {mark}
1146
+ </>
1147
+ );
1148
+
1091
1149
  return (
1092
1150
  <div
1093
1151
  key={item.value}
@@ -1104,33 +1162,13 @@ export function Selector<T extends SelectorOptionType>(
1104
1162
  isSelected && styles.itemSelected,
1105
1163
  item.disabled && styles.itemDisabled,
1106
1164
  )}>
1107
- <span {...stylex.props(styles.itemContent)}>
1108
- {renderOption ? (
1109
- renderOption(item)
1110
- ) : (
1111
- <DefaultOption option={item} />
1112
- )}
1113
- </span>
1114
- {/*
1115
- * Rendered UNCONDITIONALLY, with the state passed down: the default
1116
- * check draws nothing when unchecked, but a theme that replaces the
1117
- * `check` indicator with a radio needs the unselected state to draw
1118
- * its empty circle. `{isSelected && …}` would make that impossible.
1119
- *
1120
- * `selector-check` stays the stable target for the mark's position
1121
- * in the row; the indicator owns what the mark looks like.
1122
- */}
1123
- <SelectionMark
1124
- state={isSelected ? 'checked' : 'unchecked'}
1125
- size="sm"
1126
- isDisabled={item.disabled ?? false}
1127
- {...themeProps('selector-check')}
1128
- />
1165
+ {content}
1129
1166
  </div>
1130
1167
  );
1131
1168
  },
1132
1169
  [
1133
1170
  renderOption,
1171
+ indicatorPosition,
1134
1172
  highlightedIndex,
1135
1173
  size,
1136
1174
  normalizedValue,
@@ -1192,17 +1230,23 @@ export function Selector<T extends SelectorOptionType>(
1192
1230
  if (sectionItems.length === 0) {
1193
1231
  continue;
1194
1232
  }
1195
- if (option.title) {
1196
- elements.push(
1197
- <Divider
1198
- key={`section-divider-${i}`}
1199
- label={option.title}
1200
- xstyle={styles.sectionDivider}
1201
- />,
1202
- );
1203
- }
1233
+ // The heading lives INSIDE the group and is aria-hidden: the group
1234
+ // already carries the title as its accessible name, so exposing the
1235
+ // text again would announce it twice. This also keeps role="listbox"'s
1236
+ // children to option/group only — the old labeled Divider sat in the
1237
+ // listbox as a stray role="separator".
1204
1238
  elements.push(
1205
1239
  <div key={`section-${i}`} role="group" aria-label={option.title}>
1240
+ {option.title && (
1241
+ <div
1242
+ aria-hidden="true"
1243
+ {...mergeProps(
1244
+ themeProps('selector-section-heading'),
1245
+ stylex.props(styles.sectionHeading),
1246
+ )}>
1247
+ {option.title}
1248
+ </div>
1249
+ )}
1206
1250
  {sectionItems}
1207
1251
  </div>,
1208
1252
  );
@@ -1251,6 +1295,7 @@ export function Selector<T extends SelectorOptionType>(
1251
1295
  styles.triggerContainer,
1252
1296
  sizeStyles[size],
1253
1297
  variant === 'ghost' && styles.triggerGhost,
1298
+ variant === 'ghost' && focusOutlineStyles.focusWithin,
1254
1299
  isDisabled && inputWrapperStyles.disabled,
1255
1300
  variant === 'ghost' && isDisabled && styles.triggerGhostDisabled,
1256
1301
  !selectedItem && styles.triggerPlaceholder,
@@ -1338,7 +1383,10 @@ export function Selector<T extends SelectorOptionType>(
1338
1383
  aria-label={t(STATUS_BUTTON_LABEL_KEY[status.type])}
1339
1384
  aria-describedby={statusTooltip.describedBy}
1340
1385
  onClick={e => e.stopPropagation()}
1341
- {...stylex.props(styles.statusButton)}>
1386
+ {...stylex.props(
1387
+ focusOutlineStyles.focusVisible,
1388
+ styles.statusButton,
1389
+ )}>
1342
1390
  <Icon
1343
1391
  icon={STATUS_ICON_MAP[status.type]}
1344
1392
  size="sm"
@@ -1383,6 +1431,12 @@ export function Selector<T extends SelectorOptionType>(
1383
1431
  hasSearch ? (
1384
1432
  <div>
1385
1433
  {renderSearch()}
1434
+ {/*
1435
+ Separates the header from the options and spans the panel: the
1436
+ search row and the listbox each hold their own inline padding,
1437
+ the line does not, so it reads as the panel's own edge.
1438
+ */}
1439
+ <Divider />
1386
1440
  <div
1387
1441
  ref={listboxRef}
1388
1442
  id={listboxId}
@@ -1412,6 +1466,12 @@ export function Selector<T extends SelectorOptionType>(
1412
1466
  {
1413
1467
  placement: popoverPlacement,
1414
1468
  alignment: 'start',
1469
+ // The system's standard menu clearance, except in overlay mode:
1470
+ // there the measured negative margin owns the block geometry and
1471
+ // the menu is meant to sit on the trigger, not clear it.
1472
+ offset: shouldOverlaySelectedItem
1473
+ ? undefined
1474
+ : spacingVars['--spacing-1'],
1415
1475
  xstyle: [styles.popover, layerAnimations[popoverPlacement]],
1416
1476
  style: popoverOffsetStyle,
1417
1477
  },
@@ -21,7 +21,7 @@ export const docs = {
21
21
  targets: [
22
22
  {className: 'astryx-side-nav', visualProps: ['mode']},
23
23
  {className: 'astryx-side-nav-heading'},
24
- {className: 'astryx-side-nav-item', visualProps: ['size'], states: ['selected']},
24
+ {className: 'astryx-side-nav-item', visualProps: ['size'], states: ['selected', 'disabled']},
25
25
  {className: 'astryx-side-nav-section'},
26
26
  ],
27
27
  },
@@ -85,7 +85,7 @@ export const docs = {
85
85
  {
86
86
  name: 'footerIcons',
87
87
  type: 'ReactNode',
88
- description: 'Footer icon bar.',
88
+ description: "Footer icon bar. The row cascades a 'sm' size to the interactive children it contains, so its icons and the built-in collapse button come out one height; pass an explicit size on a child to opt out.",
89
89
  slotElements: [
90
90
  {
91
91
  __element: 'Icon',
@@ -99,7 +99,7 @@ export const docs = {
99
99
  {
100
100
  name: 'collapsible',
101
101
  type: 'boolean | { defaultIsCollapsed?: boolean; isCollapsed?: boolean; onCollapsedChange?: (isCollapsed: boolean) => void; hasButton?: boolean; buttonLabel?: string }',
102
- description: 'Enables collapse behavior. true for uncontrolled with default toggle button, or an object for controlled mode and advanced config (defaultIsCollapsed, isCollapsed + onCollapsedChange, hasButton, buttonLabel).',
102
+ description: 'Enables collapse behavior. true for uncontrolled with default toggle button, or an object for controlled mode and advanced config (defaultIsCollapsed, isCollapsed + onCollapsedChange, hasButton, buttonLabel). A controlled config can also be passed to a SideNavCollapseButton rendered outside this SideNav, so both share one state.',
103
103
  default: 'false',
104
104
  },
105
105
  {
@@ -111,7 +111,7 @@ export const docs = {
111
111
  {
112
112
  name: 'handleRef',
113
113
  type: 'Ref<SideNavImperativeCollapseHandle>',
114
- description: 'Imperative collapse handle for SideNavCollapseButton instances rendered outside this SideNav. Separate from `ref`, which continues to expose the root HTMLElement.',
114
+ description: 'Deprecated. Imperative collapse handle for SideNavCollapseButton instances rendered outside this SideNav; hand both the same controlled collapsible config instead. Separate from `ref`, which continues to expose the root HTMLElement.',
115
115
  },
116
116
  {
117
117
  name: 'xstyle',
@@ -131,6 +131,9 @@ export const docs = {
131
131
  bestPractices: [
132
132
  {guidance: true, description: 'Use sections to group related navigation items and help users scan for their destination.'},
133
133
  {guidance: true, description: 'Pair outline and filled icon variants so the selected state is visually distinct.'},
134
+ {guidance: true, description: 'Mark the current page with isSelected — it sets aria-current="page", so the current destination is announced rather than carried by color alone.'},
135
+ {guidance: true, description: 'SideNav renders a navigation landmark, and a collapsible item follows the WAI-ARIA APG Disclosure pattern (https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/): the toggle carries aria-expanded and aria-controls, and the group it owns is inert while collapsed. Keep item labels short — they are the accessible name in both expanded and icon-only modes.'},
136
+ {guidance: true, description: 'While the nav is collapsed, an item with children shows them in a submenu flyout. On a device that can hover, pointing at the item opens it after a short delay and moving away closes it; a flyout opened by clicking stays open until it is dismissed. On touch, it opens on tap. Do not put an action in there that has no other route to it.'},
134
137
  {guidance: false, description: 'Include a SideNavHeading when a TopNav is already providing app identity; this duplicates branding.'},
135
138
  {guidance: false, description: 'Use for filtering content; use tabs or filter buttons instead.'},
136
139
  ],
@@ -150,6 +153,9 @@ export const docsZh = {
150
153
  bestPractices: [
151
154
  {guidance: true, description: 'Use sections to group related navigation items and help users scan for their destination.'},
152
155
  {guidance: true, description: 'Pair outline and filled icon variants so the selected state is visually distinct.'},
156
+ {guidance: true, description: 'Mark the current page with isSelected — it sets aria-current="page", so the current destination is announced rather than carried by color alone.'},
157
+ {guidance: true, description: 'SideNav renders a navigation landmark, and a collapsible item follows the WAI-ARIA APG Disclosure pattern (https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/): the toggle carries aria-expanded and aria-controls, and the group it owns is inert while collapsed. Keep item labels short — they are the accessible name in both expanded and icon-only modes.'},
158
+ {guidance: true, description: 'While the nav is collapsed, an item with children shows them in a submenu flyout. On a device that can hover, pointing at the item opens it after a short delay and moving away closes it; a flyout opened by clicking stays open until it is dismissed. On touch, it opens on tap. Do not put an action in there that has no other route to it.'},
153
159
  {guidance: false, description: 'Include a SideNavHeading when a TopNav is already providing app identity; this duplicates branding.'},
154
160
  {guidance: false, description: 'Use for filtering content; use tabs or filter buttons instead.'},
155
161
  ],
@@ -169,6 +175,9 @@ export const docsDense = {
169
175
  bestPractices: [
170
176
  {guidance: true, description: 'Use sections to group related navigation items and help users scan for their destination.'},
171
177
  {guidance: true, description: 'Pair outline and filled icon variants so the selected state is visually distinct.'},
178
+ {guidance: true, description: 'Mark the current page with isSelected — it sets aria-current="page", so the current destination is announced rather than carried by color alone.'},
179
+ {guidance: true, description: 'SideNav renders a navigation landmark, and a collapsible item follows the WAI-ARIA APG Disclosure pattern (https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/): the toggle carries aria-expanded and aria-controls, and the group it owns is inert while collapsed. Keep item labels short — they are the accessible name in both expanded and icon-only modes.'},
180
+ {guidance: true, description: 'While the nav is collapsed, an item with children shows them in a submenu flyout. On a device that can hover, pointing at the item opens it after a short delay and moving away closes it; a flyout opened by clicking stays open until it is dismissed. On touch, it opens on tap. Do not put an action in there that has no other route to it.'},
172
181
  {guidance: false, description: 'Include a SideNavHeading when a TopNav is already providing app identity; this duplicates branding.'},
173
182
  {guidance: false, description: 'Use for filtering content; use tabs or filter buttons instead.'},
174
183
  ],