@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
@@ -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
  },
@@ -1,7 +1,5 @@
1
1
  // Copyright (c) Meta Platforms, Inc. and affiliates.
2
2
 
3
- 'use client';
4
-
5
3
  /**
6
4
  * @file Skeleton.tsx
7
5
  * @input Uses React, StyleX keyframes and tokens
@@ -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 from Skeleton component files
@@ -41,6 +41,7 @@ import {useTooltip} from '../Tooltip';
41
41
  import {VisuallyHidden} from '../VisuallyHidden';
42
42
  import type {InputStatus} from '../Field/types';
43
43
  import {mergeProps, mergeRefs, rtlStyles} from '../utils';
44
+ import {focusOutlineStyles} from '../utils/focusOutline.stylex';
44
45
  import {isRtlElement} from '../hooks/isRtlElement';
45
46
  import type {BaseProps} from '../BaseProps';
46
47
  import type {SizeValue} from '../utils/types';
@@ -253,16 +254,6 @@ const styles = stylex.create({
253
254
  },
254
255
  },
255
256
  },
256
- thumbFocusVisible: {
257
- outline: {
258
- default: 'none',
259
- ':focus-visible': `2px solid ${colorVars['--color-accent']}`,
260
- },
261
- outlineOffset: {
262
- default: '0',
263
- ':focus-visible': '2px',
264
- },
265
- },
266
257
  thumbDisabled: {
267
258
  backgroundColor: colorVars['--color-background-muted'],
268
259
  cursor: 'not-allowed',
@@ -792,7 +783,7 @@ export function Slider({ref, ...props}: SliderProps) {
792
783
  ? styles.thumbHorizontal
793
784
  : rtlStyles.centerInline('50%'),
794
785
  !isDisabled && styles.thumbHover,
795
- !isDisabled && styles.thumbFocusVisible,
786
+ !isDisabled && focusOutlineStyles.focusVisible,
796
787
  isDisabled && styles.thumbDisabled,
797
788
  ),
798
789
  undefined,
@@ -33,6 +33,7 @@ import {
33
33
  easeVars,
34
34
  typographyVars,
35
35
  typeScaleVars,
36
+ focusVars,
36
37
  } from '../theme/tokens.stylex';
37
38
  import {FieldLabel} from '../Field/FieldLabel';
38
39
  import {FieldStatus} from '../FieldStatus/FieldStatus';
@@ -189,15 +190,22 @@ const styles = stylex.create({
189
190
  '@media (forced-colors: active)': 'CanvasText',
190
191
  },
191
192
  },
193
+ // The one ring in the system not drawn by focusOutlineStyles. The focusable
194
+ // input is a sibling of the track, so the condition has to reach the shared
195
+ // scope marker — and a marker cannot be shared across components without
196
+ // leaking focus state from an outer one, so it cannot live in the utility.
197
+ // StyleX also cannot inline a constant imported from another module, so the
198
+ // values are read from the tokens the utility reads.
192
199
  trackFocus: {
193
200
  outline: {
194
201
  default: 'none',
195
202
  [stylex.when.ancestor(':has(:focus-visible)', switchScope)]:
196
- `2px solid ${colorVars['--color-accent']}`,
203
+ `${focusVars['--focus-outline-width']} ${focusVars['--focus-outline-style']} ${focusVars['--focus-outline-color']}`,
197
204
  },
198
205
  outlineOffset: {
199
206
  default: null,
200
- [stylex.when.ancestor(':has(:focus-visible)', switchScope)]: '2px',
207
+ [stylex.when.ancestor(':has(:focus-visible)', switchScope)]:
208
+ focusVars['--focus-outline-offset'],
201
209
  },
202
210
  },
203
211
  // State-dependent colors with ancestor hover behavior
@@ -496,6 +496,37 @@ describe('BaseTable', () => {
496
496
  });
497
497
  });
498
498
 
499
+ describe('TableRow styling props', () => {
500
+ it('applies className and style to the row inside a Table', () => {
501
+ render(
502
+ <Table>
503
+ <tbody>
504
+ <TableRow className="custom-row" style={{opacity: 0.9}}>
505
+ <TableCell>Cell</TableCell>
506
+ </TableRow>
507
+ </tbody>
508
+ </Table>,
509
+ );
510
+ const row = screen.getByRole('row');
511
+ expect(row.className).toContain('custom-row');
512
+ expect(row.className).toContain('astryx-table-row');
513
+ expect(row.style.opacity).toBe('0.9');
514
+ });
515
+
516
+ it('applies className to a standalone row (no table context)', () => {
517
+ render(
518
+ <table>
519
+ <tbody>
520
+ <TableRow className="custom-row">
521
+ <td>Cell</td>
522
+ </TableRow>
523
+ </tbody>
524
+ </table>,
525
+ );
526
+ expect(screen.getByRole('row').className).toContain('custom-row');
527
+ });
528
+ });
529
+
499
530
  describe('plugin pipeline', () => {
500
531
  it('applies transformTable plugin', () => {
501
532
  const plugin: TablePlugin<User> = {
@@ -120,6 +120,8 @@ export function TableRow({
120
120
  xstyle,
121
121
  ref,
122
122
  isHeaderRow = false,
123
+ className: incomingClassName,
124
+ style: incomingStyle,
123
125
  ...props
124
126
  }: TableRowProps) {
125
127
  const ctx = use(TableContext);
@@ -132,6 +134,8 @@ export function TableRow({
132
134
  {...mergeProps(
133
135
  themeProps('table-row'),
134
136
  stylex.props(tableRowMarker, xstyle),
137
+ incomingClassName,
138
+ incomingStyle,
135
139
  )}>
136
140
  {children}
137
141
  </tr>
@@ -169,6 +173,8 @@ export function TableRow({
169
173
  {...mergeProps(
170
174
  themeProps('table-row'),
171
175
  stylex.props(tableRowMarker, ...rowStyles),
176
+ incomingClassName,
177
+ incomingStyle,
172
178
  )}>
173
179
  {children}
174
180
  </tr>