@astryxdesign/core 0.3.0 → 0.4.0-canary.3112e99

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 (594) hide show
  1. package/CHANGELOG.md +156 -0
  2. package/dist/AlertDialog/AlertDialog.d.ts +10 -2
  3. package/dist/AlertDialog/AlertDialog.d.ts.map +1 -1
  4. package/dist/AlertDialog/AlertDialog.js +22 -3
  5. package/dist/AppShell/AppShell.d.ts +2 -2
  6. package/dist/AppShell/AppShell.d.ts.map +1 -1
  7. package/dist/AppShell/AppShell.js +40 -9
  8. package/dist/AspectRatio/AspectRatio.d.ts +13 -0
  9. package/dist/AspectRatio/AspectRatio.d.ts.map +1 -1
  10. package/dist/AspectRatio/AspectRatio.js +9 -0
  11. package/dist/Avatar/Avatar.d.ts.map +1 -1
  12. package/dist/Avatar/Avatar.js +33 -20
  13. package/dist/AvatarGroup/AvatarGroupOverflow.d.ts.map +1 -1
  14. package/dist/AvatarGroup/AvatarGroupOverflow.js +2 -3
  15. package/dist/Banner/Banner.d.ts +3 -0
  16. package/dist/Banner/Banner.d.ts.map +1 -1
  17. package/dist/Banner/Banner.js +32 -17
  18. package/dist/Breadcrumbs/BreadcrumbItem.d.ts.map +1 -1
  19. package/dist/Breadcrumbs/BreadcrumbItem.js +21 -8
  20. package/dist/Breadcrumbs/index.d.ts +2 -1
  21. package/dist/Breadcrumbs/index.d.ts.map +1 -1
  22. package/dist/Breadcrumbs/index.js +1 -0
  23. package/dist/Button/Button.d.ts.map +1 -1
  24. package/dist/Button/Button.js +12 -16
  25. package/dist/Calendar/Calendar.d.ts.map +1 -1
  26. package/dist/Calendar/Calendar.js +2 -1
  27. package/dist/Calendar/styles.d.ts +0 -2
  28. package/dist/Calendar/styles.d.ts.map +1 -1
  29. package/dist/Calendar/styles.js +0 -2
  30. package/dist/Carousel/Carousel.d.ts.map +1 -1
  31. package/dist/Carousel/Carousel.js +8 -12
  32. package/dist/Chat/ChatDictationButton.d.ts.map +1 -1
  33. package/dist/Chat/ChatDictationButton.js +2 -1
  34. package/dist/Chat/ChatLayoutScrollButton.d.ts.map +1 -1
  35. package/dist/Chat/ChatLayoutScrollButton.js +3 -1
  36. package/dist/Chat/ChatSendButton.d.ts.map +1 -1
  37. package/dist/Chat/ChatSendButton.js +4 -3
  38. package/dist/Chat/ChatToolCalls.d.ts.map +1 -1
  39. package/dist/Chat/ChatToolCalls.js +17 -17
  40. package/dist/Chat/useTriggerMenu.d.ts.map +1 -1
  41. package/dist/Chat/useTriggerMenu.js +2 -6
  42. package/dist/CheckboxInput/CheckboxInput.d.ts.map +1 -1
  43. package/dist/CheckboxInput/CheckboxInput.js +36 -125
  44. package/dist/ClickableCard/ClickableCard.d.ts.map +1 -1
  45. package/dist/ClickableCard/ClickableCard.js +3 -11
  46. package/dist/CodeBlock/CodeBlock.d.ts.map +1 -1
  47. package/dist/CodeBlock/CodeBlock.js +84 -67
  48. package/dist/Collapsible/Collapsible.d.ts.map +1 -1
  49. package/dist/Collapsible/Collapsible.js +35 -15
  50. package/dist/CommandPalette/CommandPaletteItem.d.ts +1 -1
  51. package/dist/CommandPalette/CommandPaletteItem.d.ts.map +1 -1
  52. package/dist/CommandPalette/CommandPaletteItem.js +6 -3
  53. package/dist/CommandPalette/CommandPaletteList.js +1 -1
  54. package/dist/ComplexSelector/ComplexSelector.d.ts +1 -1
  55. package/dist/ComplexSelector/ComplexSelector.d.ts.map +1 -1
  56. package/dist/ComplexSelector/ComplexSelector.js +32 -31
  57. package/dist/ContextMenu/ContextMenu.d.ts +2 -2
  58. package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
  59. package/dist/ContextMenu/ContextMenu.js +1 -1
  60. package/dist/ContextMenu/index.d.ts +2 -1
  61. package/dist/ContextMenu/index.d.ts.map +1 -1
  62. package/dist/ContextMenu/index.js +1 -0
  63. package/dist/DateInput/DateInput.d.ts +9 -2
  64. package/dist/DateInput/DateInput.d.ts.map +1 -1
  65. package/dist/DateInput/DateInput.js +47 -33
  66. package/dist/DateRangeInput/DateRangeInput.d.ts +9 -2
  67. package/dist/DateRangeInput/DateRangeInput.d.ts.map +1 -1
  68. package/dist/DateRangeInput/DateRangeInput.js +55 -32
  69. package/dist/DateTimeInput/DateTimeInput.d.ts +9 -1
  70. package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
  71. package/dist/DateTimeInput/DateTimeInput.js +54 -30
  72. package/dist/Dialog/Dialog.d.ts.map +1 -1
  73. package/dist/Dialog/Dialog.js +4 -4
  74. package/dist/DropdownMenu/DropdownMenu.d.ts +33 -8
  75. package/dist/DropdownMenu/DropdownMenu.d.ts.map +1 -1
  76. package/dist/DropdownMenu/DropdownMenu.js +62 -20
  77. package/dist/DropdownMenu/DropdownMenuCheckboxItem.d.ts +8 -11
  78. package/dist/DropdownMenu/DropdownMenuCheckboxItem.d.ts.map +1 -1
  79. package/dist/DropdownMenu/DropdownMenuCheckboxItem.js +32 -31
  80. package/dist/DropdownMenu/DropdownMenuDivider.d.ts +25 -0
  81. package/dist/DropdownMenu/DropdownMenuDivider.d.ts.map +1 -0
  82. package/dist/DropdownMenu/DropdownMenuDivider.js +66 -0
  83. package/dist/DropdownMenu/DropdownMenuItem.d.ts +15 -2
  84. package/dist/DropdownMenu/DropdownMenuItem.d.ts.map +1 -1
  85. package/dist/DropdownMenu/DropdownMenuItem.js +20 -8
  86. package/dist/DropdownMenu/DropdownMenuRadioGroup.d.ts +1 -1
  87. package/dist/DropdownMenu/DropdownMenuRadioGroup.d.ts.map +1 -1
  88. package/dist/DropdownMenu/DropdownMenuRadioGroup.js +10 -3
  89. package/dist/DropdownMenu/DropdownMenuRadioItem.d.ts +5 -5
  90. package/dist/DropdownMenu/DropdownMenuRadioItem.d.ts.map +1 -1
  91. package/dist/DropdownMenu/DropdownMenuRadioItem.js +24 -67
  92. package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts.map +1 -1
  93. package/dist/DropdownMenu/DropdownMenuSubMenu.js +2 -8
  94. package/dist/DropdownMenu/index.d.ts +2 -1
  95. package/dist/DropdownMenu/index.d.ts.map +1 -1
  96. package/dist/DropdownMenu/index.js +4 -0
  97. package/dist/DropdownMenu/renderDropdownItems.d.ts.map +1 -1
  98. package/dist/DropdownMenu/renderDropdownItems.js +35 -27
  99. package/dist/EmptyState/EmptyState.d.ts.map +1 -1
  100. package/dist/EmptyState/EmptyState.js +9 -5
  101. package/dist/Field/InputClearButton.d.ts +16 -3
  102. package/dist/Field/InputClearButton.d.ts.map +1 -1
  103. package/dist/Field/InputClearButton.js +15 -4
  104. package/dist/FileInput/FileInput.d.ts.map +1 -1
  105. package/dist/FileInput/FileInput.js +21 -10
  106. package/dist/HoverCard/useHoverCard.d.ts.map +1 -1
  107. package/dist/HoverCard/useHoverCard.js +3 -19
  108. package/dist/Indicator/CheckIndicator.d.ts +25 -0
  109. package/dist/Indicator/CheckIndicator.d.ts.map +1 -0
  110. package/dist/Indicator/CheckIndicator.js +155 -0
  111. package/dist/Indicator/CheckboxIndicator.d.ts +22 -0
  112. package/dist/Indicator/CheckboxIndicator.d.ts.map +1 -0
  113. package/dist/Indicator/CheckboxIndicator.js +173 -0
  114. package/dist/Indicator/RadioIndicator.d.ts +23 -0
  115. package/dist/Indicator/RadioIndicator.d.ts.map +1 -0
  116. package/dist/Indicator/RadioIndicator.js +137 -0
  117. package/dist/Indicator/index.d.ts +24 -0
  118. package/dist/Indicator/index.d.ts.map +1 -0
  119. package/dist/Indicator/index.js +26 -0
  120. package/dist/Indicator/indicator.markers.stylex.d.ts +16 -0
  121. package/dist/Indicator/indicator.markers.stylex.d.ts.map +1 -0
  122. package/dist/Indicator/indicator.markers.stylex.js +21 -0
  123. package/dist/Indicator/indicatorRegistry.d.ts +71 -0
  124. package/dist/Indicator/indicatorRegistry.d.ts.map +1 -0
  125. package/dist/Indicator/indicatorRegistry.js +93 -0
  126. package/dist/Indicator/types.d.ts +177 -0
  127. package/dist/Indicator/types.d.ts.map +1 -0
  128. package/dist/Indicator/types.js +1 -0
  129. package/dist/Indicator/useIndicator.d.ts +18 -0
  130. package/dist/Indicator/useIndicator.d.ts.map +1 -0
  131. package/dist/Indicator/useIndicator.js +30 -0
  132. package/dist/Item/Item.d.ts.map +1 -1
  133. package/dist/Item/Item.js +4 -8
  134. package/dist/Layer/useLayer.d.ts +14 -0
  135. package/dist/Layer/useLayer.d.ts.map +1 -1
  136. package/dist/Layer/useLayer.js +25 -2
  137. package/dist/Lightbox/Lightbox.d.ts.map +1 -1
  138. package/dist/Lightbox/Lightbox.js +80 -103
  139. package/dist/Link/Link.d.ts.map +1 -1
  140. package/dist/Link/Link.js +4 -5
  141. package/dist/MobileNav/MobileNav.js +8 -8
  142. package/dist/MoreMenu/MoreMenu.d.ts +14 -1
  143. package/dist/MoreMenu/MoreMenu.d.ts.map +1 -1
  144. package/dist/MoreMenu/MoreMenu.js +4 -0
  145. package/dist/MultiSelector/MultiSelector.d.ts +8 -1
  146. package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
  147. package/dist/MultiSelector/MultiSelector.js +119 -95
  148. package/dist/NumberInput/NumberInput.d.ts +27 -1
  149. package/dist/NumberInput/NumberInput.d.ts.map +1 -1
  150. package/dist/NumberInput/NumberInput.js +230 -42
  151. package/dist/Outline/Outline.d.ts.map +1 -1
  152. package/dist/Outline/Outline.js +2 -3
  153. package/dist/Pagination/Pagination.d.ts.map +1 -1
  154. package/dist/Pagination/Pagination.js +61 -63
  155. package/dist/Popover/Popover.d.ts.map +1 -1
  156. package/dist/Popover/Popover.js +6 -8
  157. package/dist/Popover/usePopover.d.ts +15 -0
  158. package/dist/Popover/usePopover.d.ts.map +1 -1
  159. package/dist/Popover/usePopover.js +14 -2
  160. package/dist/PowerSearch/PowerSearch.d.ts.map +1 -1
  161. package/dist/PowerSearch/PowerSearch.js +1 -1
  162. package/dist/PowerSearch/PowerSearchEditPopover.d.ts.map +1 -1
  163. package/dist/PowerSearch/PowerSearchEditPopover.js +7 -0
  164. package/dist/ProgressBar/ProgressBar.d.ts +11 -4
  165. package/dist/ProgressBar/ProgressBar.d.ts.map +1 -1
  166. package/dist/ProgressBar/ProgressBar.js +95 -6
  167. package/dist/RadioList/RadioListItem.d.ts.map +1 -1
  168. package/dist/RadioList/RadioListItem.js +29 -84
  169. package/dist/Resizable/ResizeHandle.d.ts.map +1 -1
  170. package/dist/Resizable/ResizeHandle.js +2 -3
  171. package/dist/SegmentedControl/SegmentedControlItem.d.ts.map +1 -1
  172. package/dist/SegmentedControl/SegmentedControlItem.js +2 -3
  173. package/dist/SelectableCard/SelectableCard.d.ts.map +1 -1
  174. package/dist/SelectableCard/SelectableCard.js +3 -11
  175. package/dist/Selector/Selector.d.ts +12 -1
  176. package/dist/Selector/Selector.d.ts.map +1 -1
  177. package/dist/Selector/Selector.js +222 -111
  178. package/dist/Selector/hooks.d.ts +18 -6
  179. package/dist/Selector/hooks.d.ts.map +1 -1
  180. package/dist/Selector/hooks.js +57 -40
  181. package/dist/SideNav/SideNav.d.ts.map +1 -1
  182. package/dist/SideNav/SideNav.js +1 -1
  183. package/dist/SideNav/SideNavCollapseButton.d.ts +1 -1
  184. package/dist/SideNav/SideNavCollapseButton.d.ts.map +1 -1
  185. package/dist/SideNav/SideNavCollapseButton.js +29 -14
  186. package/dist/SideNav/SideNavHeading.d.ts.map +1 -1
  187. package/dist/SideNav/SideNavHeading.js +56 -20
  188. package/dist/SideNav/SideNavItem.d.ts.map +1 -1
  189. package/dist/SideNav/SideNavItem.js +28 -22
  190. package/dist/Slider/Slider.d.ts.map +1 -1
  191. package/dist/Slider/Slider.js +2 -6
  192. package/dist/Stack/stack.stylex.d.ts +3 -3
  193. package/dist/Switch/Switch.d.ts.map +1 -1
  194. package/dist/Switch/Switch.js +14 -13
  195. package/dist/TabList/Tab.d.ts.map +1 -1
  196. package/dist/TabList/Tab.js +2 -3
  197. package/dist/TabList/TabMenu.d.ts.map +1 -1
  198. package/dist/TabList/TabMenu.js +41 -19
  199. package/dist/Table/BaseTable.d.ts.map +1 -1
  200. package/dist/Table/BaseTable.js +8 -0
  201. package/dist/Table/TableRow.d.ts +1 -1
  202. package/dist/Table/TableRow.d.ts.map +1 -1
  203. package/dist/Table/TableRow.js +4 -2
  204. package/dist/Table/index.d.ts +1 -1
  205. package/dist/Table/index.d.ts.map +1 -1
  206. package/dist/Table/index.js +1 -1
  207. package/dist/Table/plugins/groupedRows/useTableGroupedRows.d.ts +1 -1
  208. package/dist/Table/plugins/groupedRows/useTableGroupedRows.d.ts.map +1 -1
  209. package/dist/Table/plugins/groupedRows/useTableGroupedRows.js +22 -18
  210. package/dist/Table/plugins/rowExpansion/index.d.ts +1 -1
  211. package/dist/Table/plugins/rowExpansion/index.d.ts.map +1 -1
  212. package/dist/Table/plugins/rowExpansion/index.js +1 -1
  213. package/dist/Table/plugins/rowExpansion/useTableRowExpansion.d.ts +41 -76
  214. package/dist/Table/plugins/rowExpansion/useTableRowExpansion.d.ts.map +1 -1
  215. package/dist/Table/plugins/rowExpansion/useTableRowExpansion.js +94 -345
  216. package/dist/Table/plugins/sortable/useTableSortable.d.ts.map +1 -1
  217. package/dist/Table/plugins/sortable/useTableSortable.js +23 -4
  218. package/dist/Table/plugins/tree/useTableTreeData.d.ts.map +1 -1
  219. package/dist/Table/plugins/tree/useTableTreeData.js +29 -33
  220. package/dist/Table/tableContextMenu.d.ts.map +1 -1
  221. package/dist/Table/tableContextMenu.js +2 -1
  222. package/dist/Table/types.d.ts +11 -0
  223. package/dist/Table/types.d.ts.map +1 -1
  224. package/dist/TextArea/TextArea.d.ts +10 -1
  225. package/dist/TextArea/TextArea.d.ts.map +1 -1
  226. package/dist/TextArea/TextArea.js +16 -6
  227. package/dist/TextInput/TextInput.d.ts +10 -1
  228. package/dist/TextInput/TextInput.d.ts.map +1 -1
  229. package/dist/TextInput/TextInput.js +13 -19
  230. package/dist/Thumbnail/Thumbnail.d.ts.map +1 -1
  231. package/dist/Thumbnail/Thumbnail.js +32 -26
  232. package/dist/TimeInput/TimeInput.d.ts.map +1 -1
  233. package/dist/TimeInput/TimeInput.js +14 -17
  234. package/dist/Timestamp/Timestamp.d.ts.map +1 -1
  235. package/dist/Timestamp/Timestamp.js +14 -3
  236. package/dist/Timestamp/TimestampHoverCard.d.ts.map +1 -1
  237. package/dist/Timestamp/TimestampHoverCard.js +16 -34
  238. package/dist/Timestamp/formatInstant.d.ts +11 -1
  239. package/dist/Timestamp/formatInstant.d.ts.map +1 -1
  240. package/dist/Timestamp/formatInstant.js +3 -1
  241. package/dist/Token/Token.d.ts.map +1 -1
  242. package/dist/Token/Token.js +20 -11
  243. package/dist/Tokenizer/Tokenizer.d.ts.map +1 -1
  244. package/dist/Tokenizer/Tokenizer.js +4 -2
  245. package/dist/Tooltip/useTooltip.d.ts.map +1 -1
  246. package/dist/Tooltip/useTooltip.js +3 -19
  247. package/dist/TopNav/TopNav.d.ts.map +1 -1
  248. package/dist/TopNav/TopNav.js +6 -5
  249. package/dist/TopNav/TopNavHeading.d.ts.map +1 -1
  250. package/dist/TopNav/TopNavHeading.js +54 -16
  251. package/dist/TopNav/TopNavItem.d.ts.map +1 -1
  252. package/dist/TopNav/TopNavItem.js +3 -9
  253. package/dist/TopNav/TopNavMegaMenu.d.ts +7 -0
  254. package/dist/TopNav/TopNavMegaMenu.d.ts.map +1 -1
  255. package/dist/TopNav/TopNavMegaMenu.js +146 -60
  256. package/dist/TopNav/TopNavMegaMenuItem.d.ts.map +1 -1
  257. package/dist/TopNav/TopNavMegaMenuItem.js +22 -3
  258. package/dist/TopNav/TopNavMenu.d.ts.map +1 -1
  259. package/dist/TopNav/TopNavMenu.js +83 -38
  260. package/dist/TreeList/TreeList.d.ts.map +1 -1
  261. package/dist/TreeList/TreeList.js +13 -0
  262. package/dist/TreeList/TreeListBranches.d.ts +1 -1
  263. package/dist/TreeList/TreeListBranches.d.ts.map +1 -1
  264. package/dist/TreeList/TreeListBranches.js +14 -4
  265. package/dist/TreeList/TreeListItem.d.ts +11 -1
  266. package/dist/TreeList/TreeListItem.d.ts.map +1 -1
  267. package/dist/TreeList/TreeListItem.js +57 -30
  268. package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
  269. package/dist/Typeahead/BaseTypeahead.js +16 -8
  270. package/dist/astryx.css +84 -96
  271. package/dist/astryx.umd.js +56 -56
  272. package/dist/astryx.umd.js.map +4 -4
  273. package/dist/hooks/containerReveal.stylex.d.ts +61 -0
  274. package/dist/hooks/containerReveal.stylex.d.ts.map +1 -0
  275. package/dist/hooks/containerReveal.stylex.js +63 -0
  276. package/dist/hooks/index.d.ts +3 -0
  277. package/dist/hooks/index.d.ts.map +1 -1
  278. package/dist/hooks/index.js +2 -0
  279. package/dist/hooks/useClipboard.d.ts +59 -0
  280. package/dist/hooks/useClipboard.d.ts.map +1 -0
  281. package/dist/hooks/useClipboard.js +106 -0
  282. package/dist/hooks/useContainerReveal.d.ts +14 -8
  283. package/dist/hooks/useContainerReveal.d.ts.map +1 -1
  284. package/dist/hooks/useContainerReveal.js +28 -69
  285. package/dist/hooks/useIndicatorFocusRing.d.ts +28 -0
  286. package/dist/hooks/useIndicatorFocusRing.d.ts.map +1 -0
  287. package/dist/hooks/useIndicatorFocusRing.js +96 -0
  288. package/dist/hooks/useInputStatusIcon.d.ts.map +1 -1
  289. package/dist/hooks/useInputStatusIcon.js +17 -4
  290. package/dist/hooks/useLongPress.d.ts.map +1 -1
  291. package/dist/hooks/useLongPress.js +12 -2
  292. package/dist/hooks/useScrollLock.d.ts.map +1 -1
  293. package/dist/hooks/useScrollLock.js +33 -18
  294. package/dist/hooks/useStreamingText.d.ts +18 -3
  295. package/dist/hooks/useStreamingText.d.ts.map +1 -1
  296. package/dist/hooks/useStreamingText.js +47 -7
  297. package/dist/hooks/useTreeFocus.d.ts.map +1 -1
  298. package/dist/hooks/useTreeFocus.js +9 -3
  299. package/dist/hooks/useTypeahead.d.ts +3 -2
  300. package/dist/hooks/useTypeahead.d.ts.map +1 -1
  301. package/dist/hooks/useTypeahead.js +19 -7
  302. package/dist/index.d.ts +1 -0
  303. package/dist/index.d.ts.map +1 -1
  304. package/dist/index.js +1 -0
  305. package/dist/theme/defineTheme.d.ts +18 -2
  306. package/dist/theme/defineTheme.d.ts.map +1 -1
  307. package/dist/theme/defineTheme.js +10 -1
  308. package/dist/theme/derivedVarRegistry.d.ts +8 -0
  309. package/dist/theme/derivedVarRegistry.d.ts.map +1 -1
  310. package/dist/theme/derivedVarRegistry.js +14 -13
  311. package/dist/theme/expandColorScale.d.ts +3 -1
  312. package/dist/theme/expandColorScale.d.ts.map +1 -1
  313. package/dist/theme/expandColorScale.js +17 -7
  314. package/dist/theme/expandTypeScale.d.ts.map +1 -1
  315. package/dist/theme/expandTypeScale.js +17 -0
  316. package/dist/theme/generateThemeRules.d.ts.map +1 -1
  317. package/dist/theme/generateThemeRules.js +11 -0
  318. package/dist/theme/index.d.ts +2 -1
  319. package/dist/theme/index.d.ts.map +1 -1
  320. package/dist/theme/index.js +1 -1
  321. package/dist/theme/tokens.stylex.d.ts +12 -0
  322. package/dist/theme/tokens.stylex.d.ts.map +1 -1
  323. package/dist/theme/tokens.stylex.js +23 -0
  324. package/dist/utils/focusOutline.stylex.d.ts +122 -0
  325. package/dist/utils/focusOutline.stylex.d.ts.map +1 -0
  326. package/dist/utils/focusOutline.stylex.js +143 -0
  327. package/dist/utils/focusReturn.d.ts +24 -0
  328. package/dist/utils/focusReturn.d.ts.map +1 -0
  329. package/dist/utils/focusReturn.js +29 -0
  330. package/dist/utils/index.d.ts +2 -0
  331. package/dist/utils/index.d.ts.map +1 -1
  332. package/dist/utils/index.js +6 -1
  333. package/dist/utils/mergeRefs.d.ts.map +1 -1
  334. package/dist/utils/mergeRefs.js +7 -5
  335. package/dist/utils/rtlStyles.d.ts +2 -2
  336. package/dist/utils/rtlStyles.js +2 -2
  337. package/dist/utils/themeProps.d.ts +20 -1
  338. package/dist/utils/themeProps.d.ts.map +1 -1
  339. package/dist/utils/themeProps.js +8 -2
  340. package/locales/en.json +52 -0
  341. package/locales/pseudo.json +39 -0
  342. package/package.json +8 -3
  343. package/src/AlertDialog/AlertDialog.doc.mjs +16 -3
  344. package/src/AlertDialog/AlertDialog.test.tsx +208 -1
  345. package/src/AlertDialog/AlertDialog.tsx +21 -3
  346. package/src/AppShell/AppShell.doc.mjs +11 -0
  347. package/src/AppShell/AppShell.test.tsx +139 -0
  348. package/src/AppShell/AppShell.tsx +32 -22
  349. package/src/AspectRatio/AspectRatio.doc.mjs +27 -3
  350. package/src/AspectRatio/AspectRatio.test.tsx +30 -0
  351. package/src/AspectRatio/AspectRatio.tsx +13 -0
  352. package/src/Avatar/Avatar.doc.mjs +2 -13
  353. package/src/Avatar/Avatar.test.tsx +33 -8
  354. package/src/Avatar/Avatar.tsx +52 -50
  355. package/src/AvatarGroup/AvatarGroupOverflow.tsx +2 -9
  356. package/src/Banner/Banner.test.tsx +58 -1
  357. package/src/Banner/Banner.tsx +33 -11
  358. package/src/Breadcrumbs/BreadcrumbItem.tsx +16 -6
  359. package/src/Breadcrumbs/index.ts +5 -1
  360. package/src/Button/Button.doc.mjs +2 -2
  361. package/src/Button/Button.tsx +22 -41
  362. package/src/Calendar/Calendar.tsx +2 -1
  363. package/src/Calendar/styles.ts +0 -8
  364. package/src/Carousel/Carousel.doc.mjs +3 -0
  365. package/src/Carousel/Carousel.tsx +10 -6
  366. package/src/Chat/Chat.doc.mjs +2 -0
  367. package/src/Chat/ChatDictationButton.tsx +7 -1
  368. package/src/Chat/ChatLayoutScrollButton.test.tsx +22 -0
  369. package/src/Chat/ChatLayoutScrollButton.tsx +8 -1
  370. package/src/Chat/ChatSendButton.tsx +2 -3
  371. package/src/Chat/ChatToolCalls.test.tsx +35 -0
  372. package/src/Chat/ChatToolCalls.tsx +36 -16
  373. package/src/Chat/useTriggerMenu.tsx +2 -5
  374. package/src/CheckboxInput/CheckboxInput.doc.mjs +4 -2
  375. package/src/CheckboxInput/CheckboxInput.test.tsx +120 -0
  376. package/src/CheckboxInput/CheckboxInput.tsx +45 -202
  377. package/src/ClickableCard/ClickableCard.tsx +6 -11
  378. package/src/CodeBlock/CodeBlock.doc.mjs +3 -0
  379. package/src/CodeBlock/CodeBlock.test.tsx +91 -0
  380. package/src/CodeBlock/CodeBlock.tsx +57 -80
  381. package/src/Collapsible/Collapsible.tsx +22 -18
  382. package/src/CommandPalette/CommandPalette.test.tsx +29 -0
  383. package/src/CommandPalette/CommandPaletteItem.tsx +7 -4
  384. package/src/CommandPalette/CommandPaletteList.tsx +2 -2
  385. package/src/ComplexSelector/ComplexSelector.doc.mjs +42 -0
  386. package/src/ComplexSelector/ComplexSelector.test.tsx +73 -0
  387. package/src/ComplexSelector/ComplexSelector.tsx +41 -28
  388. package/src/ContextMenu/ContextMenu.doc.mjs +1 -1
  389. package/src/ContextMenu/ContextMenu.test.tsx +41 -0
  390. package/src/ContextMenu/ContextMenu.tsx +3 -3
  391. package/src/ContextMenu/index.ts +5 -1
  392. package/src/DateInput/DateInput.doc.mjs +18 -3
  393. package/src/DateInput/DateInput.test.tsx +112 -11
  394. package/src/DateInput/DateInput.tsx +44 -25
  395. package/src/DateRangeInput/DateRangeInput.doc.mjs +10 -2
  396. package/src/DateRangeInput/DateRangeInput.test.tsx +110 -14
  397. package/src/DateRangeInput/DateRangeInput.tsx +27 -21
  398. package/src/DateTimeInput/DateTimeInput.doc.mjs +16 -2
  399. package/src/DateTimeInput/DateTimeInput.test.tsx +154 -1
  400. package/src/DateTimeInput/DateTimeInput.tsx +58 -17
  401. package/src/Dialog/Dialog.tsx +2 -9
  402. package/src/DropdownMenu/DropdownMenu.doc.mjs +5 -9
  403. package/src/DropdownMenu/DropdownMenu.test.tsx +534 -4
  404. package/src/DropdownMenu/DropdownMenu.tsx +113 -52
  405. package/src/DropdownMenu/DropdownMenuCheckboxItem.tsx +27 -30
  406. package/src/DropdownMenu/DropdownMenuDivider.doc.mjs +34 -0
  407. package/src/DropdownMenu/DropdownMenuDivider.tsx +81 -0
  408. package/src/DropdownMenu/DropdownMenuItem.doc.mjs +39 -4
  409. package/src/DropdownMenu/DropdownMenuItem.tsx +48 -10
  410. package/src/DropdownMenu/DropdownMenuRadioGroup.tsx +2 -1
  411. package/src/DropdownMenu/DropdownMenuRadioItem.tsx +30 -76
  412. package/src/DropdownMenu/DropdownMenuSelectable.test.tsx +25 -19
  413. package/src/DropdownMenu/DropdownMenuSubMenu.test.tsx +44 -14
  414. package/src/DropdownMenu/DropdownMenuSubMenu.tsx +1 -5
  415. package/src/DropdownMenu/index.ts +8 -1
  416. package/src/DropdownMenu/renderDropdownItems.tsx +33 -34
  417. package/src/EmptyState/EmptyState.doc.mjs +4 -0
  418. package/src/EmptyState/EmptyState.test.tsx +62 -0
  419. package/src/EmptyState/EmptyState.tsx +16 -6
  420. package/src/Field/Field.doc.mjs +1 -0
  421. package/src/Field/InputClearButton.test.tsx +117 -0
  422. package/src/Field/InputClearButton.tsx +30 -3
  423. package/src/FileInput/FileInput.tsx +21 -8
  424. package/src/HoverCard/useHoverCard.tsx +2 -23
  425. package/src/Icon/Icon.doc.mjs +2 -1
  426. package/src/Indicator/CheckIndicator.tsx +174 -0
  427. package/src/Indicator/CheckboxIndicator.tsx +257 -0
  428. package/src/Indicator/Indicator.doc.mjs +344 -0
  429. package/src/Indicator/Indicator.test.tsx +393 -0
  430. package/src/Indicator/RadioIndicator.tsx +206 -0
  431. package/src/Indicator/index.ts +46 -0
  432. package/src/Indicator/indicator.markers.stylex.ts +19 -0
  433. package/src/Indicator/indicatorRegistry.test.tsx +106 -0
  434. package/src/Indicator/indicatorRegistry.ts +120 -0
  435. package/src/Indicator/types.ts +197 -0
  436. package/src/Indicator/useIndicator.ts +39 -0
  437. package/src/Item/Item.doc.mjs +1 -1
  438. package/src/Item/Item.tsx +7 -14
  439. package/src/Layer/useLayer.doc.mjs +2 -2
  440. package/src/Layer/useLayer.test.tsx +85 -0
  441. package/src/Layer/useLayer.tsx +39 -1
  442. package/src/Lightbox/Lightbox.doc.mjs +1 -1
  443. package/src/Lightbox/Lightbox.tsx +45 -49
  444. package/src/Link/Link.tsx +4 -11
  445. package/src/MobileNav/MobileNav.tsx +3 -3
  446. package/src/MoreMenu/MoreMenu.doc.mjs +31 -0
  447. package/src/MoreMenu/MoreMenu.test.tsx +68 -1
  448. package/src/MoreMenu/MoreMenu.tsx +19 -0
  449. package/src/MultiSelector/MultiSelector.doc.mjs +16 -1
  450. package/src/MultiSelector/MultiSelector.test.tsx +259 -16
  451. package/src/MultiSelector/MultiSelector.tsx +144 -121
  452. package/src/NumberInput/NumberInput.doc.mjs +265 -50
  453. package/src/NumberInput/NumberInput.test.tsx +726 -21
  454. package/src/NumberInput/NumberInput.tsx +353 -59
  455. package/src/Outline/Outline.tsx +2 -5
  456. package/src/Pagination/Pagination.doc.mjs +1 -1
  457. package/src/Pagination/Pagination.test.tsx +22 -7
  458. package/src/Pagination/Pagination.tsx +38 -35
  459. package/src/Popover/Popover.doc.mjs +2 -0
  460. package/src/Popover/Popover.tsx +6 -7
  461. package/src/Popover/usePopover.tsx +37 -4
  462. package/src/PowerSearch/PowerSearch.tsx +1 -1
  463. package/src/PowerSearch/PowerSearchEditPopover.test.tsx +42 -0
  464. package/src/PowerSearch/PowerSearchEditPopover.tsx +7 -0
  465. package/src/ProgressBar/ProgressBar.doc.mjs +27 -5
  466. package/src/ProgressBar/ProgressBar.test.tsx +224 -1
  467. package/src/ProgressBar/ProgressBar.tsx +126 -30
  468. package/src/RadioList/RadioList.doc.mjs +4 -2
  469. package/src/RadioList/RadioListItem.tsx +31 -130
  470. package/src/Resizable/ResizeHandle.tsx +2 -9
  471. package/src/SegmentedControl/SegmentedControlItem.tsx +2 -9
  472. package/src/SelectableCard/SelectableCard.tsx +4 -9
  473. package/src/Selector/Selector.doc.mjs +15 -4
  474. package/src/Selector/Selector.test.tsx +1234 -98
  475. package/src/Selector/Selector.tsx +254 -126
  476. package/src/Selector/hooks.ts +77 -50
  477. package/src/SideNav/SideNav.test.tsx +46 -0
  478. package/src/SideNav/SideNav.tsx +1 -0
  479. package/src/SideNav/SideNavCollapseButton.tsx +15 -10
  480. package/src/SideNav/SideNavHeading.tsx +48 -10
  481. package/src/SideNav/SideNavItem.tsx +20 -12
  482. package/src/Slider/Slider.tsx +2 -11
  483. package/src/Stack/Stack.doc.mjs +110 -0
  484. package/src/StatusDot/StatusDot.doc.mjs +12 -3
  485. package/src/Switch/Switch.test.tsx +32 -0
  486. package/src/Switch/Switch.tsx +18 -10
  487. package/src/TabList/Tab.tsx +2 -9
  488. package/src/TabList/TabMenu.tsx +14 -21
  489. package/src/Table/BaseTable.tsx +11 -0
  490. package/src/Table/Table.test.tsx +31 -0
  491. package/src/Table/TableRow.tsx +6 -0
  492. package/src/Table/index.ts +1 -4
  493. package/src/Table/plugins/groupedRows/useTableGroupedRows.tsx +30 -14
  494. package/src/Table/plugins/rowExpansion/index.ts +1 -4
  495. package/src/Table/plugins/rowExpansion/useTableRowExpansion.test.tsx +125 -175
  496. package/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx +115 -458
  497. package/src/Table/plugins/sortable/useTableSortable.tsx +2 -6
  498. package/src/Table/plugins/tree/useTableTreeData.tsx +43 -24
  499. package/src/Table/tableContextMenu.test.tsx +27 -0
  500. package/src/Table/tableContextMenu.tsx +1 -0
  501. package/src/Table/types.ts +11 -0
  502. package/src/Table/useTableGroupedRows.doc.mjs +1 -1
  503. package/src/Table/useTableRowExpansion.doc.mjs +43 -66
  504. package/src/TextArea/TextArea.doc.mjs +42 -2
  505. package/src/TextArea/TextArea.test.tsx +219 -0
  506. package/src/TextArea/TextArea.tsx +34 -7
  507. package/src/TextInput/TextInput.doc.mjs +18 -2
  508. package/src/TextInput/TextInput.test.tsx +189 -0
  509. package/src/TextInput/TextInput.tsx +25 -31
  510. package/src/Thumbnail/Thumbnail.tsx +2 -9
  511. package/src/TimeInput/TimeInput.doc.mjs +2 -2
  512. package/src/TimeInput/TimeInput.test.tsx +84 -3
  513. package/src/TimeInput/TimeInput.tsx +26 -28
  514. package/src/Timestamp/Timestamp.doc.mjs +4 -3
  515. package/src/Timestamp/Timestamp.test.tsx +91 -14
  516. package/src/Timestamp/Timestamp.tsx +14 -2
  517. package/src/Timestamp/TimestampHoverCard.tsx +13 -35
  518. package/src/Timestamp/formatInstant.ts +16 -1
  519. package/src/Token/Token.tsx +5 -28
  520. package/src/Tokenizer/Tokenizer.doc.mjs +2 -2
  521. package/src/Tokenizer/Tokenizer.test.tsx +45 -3
  522. package/src/Tokenizer/Tokenizer.tsx +10 -2
  523. package/src/Tooltip/useTooltip.tsx +2 -23
  524. package/src/TopNav/TopNav.tsx +1 -3
  525. package/src/TopNav/TopNavHeading.tsx +42 -13
  526. package/src/TopNav/TopNavItem.tsx +3 -21
  527. package/src/TopNav/TopNavMegaMenu.doc.mjs +5 -0
  528. package/src/TopNav/TopNavMegaMenu.test.tsx +433 -33
  529. package/src/TopNav/TopNavMegaMenu.tsx +118 -53
  530. package/src/TopNav/TopNavMegaMenuItem.tsx +2 -9
  531. package/src/TopNav/TopNavMenu.tsx +34 -37
  532. package/src/TreeList/TreeList.doc.mjs +5 -0
  533. package/src/TreeList/TreeList.test.tsx +208 -8
  534. package/src/TreeList/TreeList.tsx +22 -0
  535. package/src/TreeList/TreeListBranches.tsx +25 -2
  536. package/src/TreeList/TreeListItem.tsx +89 -52
  537. package/src/Typeahead/BaseTypeahead.tsx +19 -6
  538. package/src/Typeahead/Typeahead.doc.mjs +1 -0
  539. package/src/Typeahead/Typeahead.test.tsx +76 -3
  540. package/src/hooks/containerReveal.stylex.ts +102 -0
  541. package/src/hooks/index.ts +4 -0
  542. package/src/hooks/useClipboard.doc.mjs +87 -0
  543. package/src/hooks/useClipboard.test.tsx +151 -0
  544. package/src/hooks/useClipboard.ts +135 -0
  545. package/src/hooks/useContainerReveal.doc.mjs +7 -7
  546. package/src/hooks/useContainerReveal.test.tsx +42 -62
  547. package/src/hooks/useContainerReveal.ts +36 -91
  548. package/src/hooks/useImageMode.test.ts +1 -1
  549. package/src/hooks/useIndicatorFocusRing.tsx +120 -0
  550. package/src/hooks/useInputStatusIcon.tsx +8 -8
  551. package/src/hooks/useLongPress.test.tsx +18 -0
  552. package/src/hooks/useLongPress.ts +12 -2
  553. package/src/hooks/useScrollLock.test.ts +72 -0
  554. package/src/hooks/useScrollLock.ts +51 -19
  555. package/src/hooks/useStreamingText.test.ts +46 -1
  556. package/src/hooks/useStreamingText.ts +49 -7
  557. package/src/hooks/useTreeFocus.test.tsx +50 -0
  558. package/src/hooks/useTreeFocus.ts +14 -3
  559. package/src/hooks/useTypeahead.test.tsx +150 -0
  560. package/src/hooks/useTypeahead.ts +24 -9
  561. package/src/i18n/InternationalizationProvider.doc.mjs +7 -2
  562. package/src/index.ts +1 -0
  563. package/src/theme/defineTheme.ts +27 -0
  564. package/src/theme/derivedVarRegistry.test.ts +21 -1
  565. package/src/theme/derivedVarRegistry.ts +19 -6
  566. package/src/theme/expandColorScale.test.ts +14 -0
  567. package/src/theme/expandColorScale.ts +24 -9
  568. package/src/theme/expandTypeScale.test.ts +43 -0
  569. package/src/theme/expandTypeScale.ts +17 -0
  570. package/src/theme/generateThemeRules.test.ts +78 -20
  571. package/src/theme/generateThemeRules.ts +11 -0
  572. package/src/theme/index.ts +15 -0
  573. package/src/theme/tokens.stylex.ts +18 -0
  574. package/src/theme/tokens.test.ts +37 -0
  575. package/src/utils/focusOutline.stylex.ts +177 -0
  576. package/src/utils/focusReturn.test.ts +42 -0
  577. package/src/utils/focusReturn.ts +31 -0
  578. package/src/utils/index.ts +5 -0
  579. package/src/utils/mergeRefs.test.ts +48 -0
  580. package/src/utils/mergeRefs.ts +12 -6
  581. package/src/utils/rtlStyles.ts +2 -2
  582. package/src/utils/themeProps.ts +26 -4
  583. package/dist/CheckboxInput/checkbox.markers.stylex.d.ts +0 -9
  584. package/dist/CheckboxInput/checkbox.markers.stylex.d.ts.map +0 -1
  585. package/dist/CheckboxInput/checkbox.markers.stylex.js +0 -14
  586. package/dist/RadioList/radio.markers.stylex.d.ts +0 -7
  587. package/dist/RadioList/radio.markers.stylex.d.ts.map +0 -1
  588. package/dist/RadioList/radio.markers.stylex.js +0 -12
  589. package/dist/hooks/containerReveal.pool.stylex.d.ts +0 -303
  590. package/dist/hooks/containerReveal.pool.stylex.d.ts.map +0 -1
  591. package/dist/hooks/containerReveal.pool.stylex.js +0 -292
  592. package/src/CheckboxInput/checkbox.markers.stylex.ts +0 -12
  593. package/src/RadioList/radio.markers.stylex.ts +0 -10
  594. package/src/hooks/containerReveal.pool.stylex.ts +0 -571
@@ -3,7 +3,7 @@
3
3
  /**
4
4
  * @file Selector.test.tsx
5
5
  * @input Uses vitest, @testing-library/react, @testing-library/user-event
6
- * @output Unit tests for Selector
6
+ * @output Unit tests for Selector behavior and selected-item geometry
7
7
  * @position Tests; validates Selector behavior
8
8
  *
9
9
  * SYNC: When Selector.tsx API changes, update these tests.
@@ -18,24 +18,27 @@ import {
18
18
  within,
19
19
  } from '@testing-library/react';
20
20
  import userEvent from '@testing-library/user-event';
21
+ import {useState} from 'react';
21
22
  import {Selector} from './Selector';
22
23
  import {SelectorOption} from './SelectorOption';
23
24
  import {Icon} from '../Icon';
25
+ import {RadioIndicator} from '../Indicator';
24
26
  import {InputGroup, InputGroupText} from '../InputGroup';
25
27
  import {__resetLiveRegionsForTest} from '../hooks/useAnnounce';
26
28
  import {defineTheme} from '../theme/defineTheme';
29
+ import {Theme} from '../theme/Theme';
27
30
  import {generateThemeCSS} from '../theme/generateThemeRules';
31
+ import {spacingVars} from '../theme/tokens.stylex';
28
32
 
29
33
  function generateThemeTestCSS(theme: Parameters<typeof generateThemeCSS>[0]) {
30
34
  const {prose, component} = generateThemeCSS(theme);
31
35
  return [prose, component].filter(Boolean).join('\n\n');
32
36
  }
33
- function politeRegion(): HTMLElement | null {
34
- return document.querySelector('[data-astryx-live-region="polite"]');
35
- }
36
37
 
37
38
  // Mock showPopover and hidePopover methods since they're not implemented in jsdom
38
39
  beforeEach(() => {
40
+ // The live regions are a document-level singleton; start each test clean.
41
+ __resetLiveRegionsForTest();
39
42
  HTMLElement.prototype.showPopover = vi.fn(function (this: HTMLElement) {
40
43
  this.setAttribute('popover-open', '');
41
44
  const event = new Event('toggle', {bubbles: false});
@@ -70,6 +73,23 @@ const h = {hidden: true} as const;
70
73
 
71
74
  const OPTIONS = ['Apple', 'Banana', 'Cherry'];
72
75
 
76
+ // Mirrors useTypeahead's default resetMs — how long the typed buffer survives.
77
+ const TYPEAHEAD_RESET_MS = 750;
78
+
79
+ const politeRegion = () =>
80
+ document.querySelector('[data-astryx-live-region="polite"]');
81
+
82
+ /**
83
+ * Type onto an element with no awaits between keystrokes. Typeahead only
84
+ * accumulates while keys land inside the reset window, so an awaited
85
+ * `user.keyboard` per character would put CI stalls on the critical path.
86
+ */
87
+ function type(text: string, element: HTMLElement) {
88
+ for (const key of text) {
89
+ fireEvent.keyDown(element, {key});
90
+ }
91
+ }
92
+
73
93
  function rect({
74
94
  top,
75
95
  bottom,
@@ -98,36 +118,103 @@ function rect({
98
118
  };
99
119
  }
100
120
 
101
- function mockSelectorRects() {
121
+ function mockSelectorRects({
122
+ anchor = rect({top: 160, bottom: 190, height: 30}),
123
+ trigger = rect({top: 160, bottom: 190, height: 30}),
124
+ listbox = rect({top: 190, bottom: 310, height: 120}),
125
+ selectedItem = rect({top: 220, bottom: 250, height: 30}),
126
+ listboxLayoutHeight = listbox.height,
127
+ selectedItemLayoutTop = selectedItem.top - listbox.top,
128
+ selectedItemLayoutHeight = selectedItem.height,
129
+ viewportHeight = 200,
130
+ }: {
131
+ anchor?: DOMRect;
132
+ trigger?: DOMRect;
133
+ listbox?: DOMRect;
134
+ selectedItem?: DOMRect;
135
+ listboxLayoutHeight?: number;
136
+ selectedItemLayoutTop?: number;
137
+ selectedItemLayoutHeight?: number;
138
+ viewportHeight?: number;
139
+ } = {}) {
102
140
  const originalGetBoundingClientRect =
103
141
  HTMLElement.prototype.getBoundingClientRect;
104
142
  const originalInnerHeight = Object.getOwnPropertyDescriptor(
105
143
  window,
106
144
  'innerHeight',
107
145
  );
146
+ const originalOffsetTop = Object.getOwnPropertyDescriptor(
147
+ HTMLElement.prototype,
148
+ 'offsetTop',
149
+ );
150
+ const originalOffsetHeight = Object.getOwnPropertyDescriptor(
151
+ HTMLElement.prototype,
152
+ 'offsetHeight',
153
+ );
108
154
  HTMLElement.prototype.getBoundingClientRect = function () {
155
+ if (this.classList.contains('astryx-selector')) {
156
+ return anchor;
157
+ }
109
158
  // The trigger is role="combobox" by default, or a plain button with
110
159
  // aria-haspopup="listbox" in hasSearch mode — match either.
111
160
  if (
112
161
  this.getAttribute('role') === 'combobox' ||
113
162
  this.getAttribute('aria-haspopup') === 'listbox'
114
163
  ) {
115
- return rect({top: 160, bottom: 190, height: 30});
164
+ return trigger;
116
165
  }
117
166
  if (this.getAttribute('role') === 'listbox') {
118
- return rect({top: 190, bottom: 310, height: 120});
167
+ return listbox;
119
168
  }
120
169
  if (this.id.endsWith('-item-1')) {
121
- return rect({top: 220, bottom: 250, height: 30});
170
+ return selectedItem;
122
171
  }
123
172
  return originalGetBoundingClientRect.call(this);
124
173
  };
174
+ Object.defineProperty(HTMLElement.prototype, 'offsetTop', {
175
+ configurable: true,
176
+ get() {
177
+ if (this.getAttribute('role') === 'listbox') {
178
+ return 0;
179
+ }
180
+ if (this.id.endsWith('-item-1')) {
181
+ return selectedItemLayoutTop;
182
+ }
183
+ return 0;
184
+ },
185
+ });
186
+ Object.defineProperty(HTMLElement.prototype, 'offsetHeight', {
187
+ configurable: true,
188
+ get() {
189
+ if (this.getAttribute('role') === 'listbox') {
190
+ return listboxLayoutHeight;
191
+ }
192
+ if (this.id.endsWith('-item-1')) {
193
+ return selectedItemLayoutHeight;
194
+ }
195
+ return 0;
196
+ },
197
+ });
125
198
  Object.defineProperty(window, 'innerHeight', {
126
- value: 200,
199
+ value: viewportHeight,
127
200
  configurable: true,
128
201
  });
129
202
  return () => {
130
203
  HTMLElement.prototype.getBoundingClientRect = originalGetBoundingClientRect;
204
+ if (originalOffsetTop) {
205
+ Object.defineProperty(
206
+ HTMLElement.prototype,
207
+ 'offsetTop',
208
+ originalOffsetTop,
209
+ );
210
+ }
211
+ if (originalOffsetHeight) {
212
+ Object.defineProperty(
213
+ HTMLElement.prototype,
214
+ 'offsetHeight',
215
+ originalOffsetHeight,
216
+ );
217
+ }
131
218
  if (originalInnerHeight) {
132
219
  Object.defineProperty(window, 'innerHeight', originalInnerHeight);
133
220
  }
@@ -152,6 +239,60 @@ describe('Selector', () => {
152
239
  expect(screen.getByRole('combobox')).toHaveTextContent('Banana');
153
240
  });
154
241
 
242
+ it('draws the selected mark through the check indicator', () => {
243
+ render(
244
+ <Selector
245
+ label="Fruit"
246
+ options={OPTIONS}
247
+ value="Banana"
248
+ onChange={() => {}}
249
+ />,
250
+ );
251
+
252
+ const selected = screen.getByRole('option', {name: /Banana/, hidden: true});
253
+ // The default check indicator IS the glyph — no wrapper element, so the
254
+ // host's theme target sits on the same node as astryx-icon.
255
+ const mark = selected.querySelector('.astryx-selector-check');
256
+ expect(mark).not.toBeNull();
257
+ expect(mark).toHaveClass('astryx-icon');
258
+ });
259
+
260
+ it('lets a theme replace the mark with a radio, which draws when unselected too', () => {
261
+ // The point of the indicator layer: one theme entry, and every
262
+ // single-selection mark becomes a radio — including the empty circle on
263
+ // rows that are NOT selected, which a check-only mark never drew.
264
+ const theme = defineTheme({
265
+ name: 'selector-radio-mark-test',
266
+ indicators: {check: RadioIndicator},
267
+ });
268
+
269
+ render(
270
+ <Theme theme={theme}>
271
+ <Selector
272
+ label="Fruit"
273
+ options={OPTIONS}
274
+ value="Banana"
275
+ onChange={() => {}}
276
+ />
277
+ </Theme>,
278
+ );
279
+
280
+ const options = screen.getAllByRole('option', {hidden: true});
281
+ expect(options.length).toBeGreaterThan(1);
282
+
283
+ // Every row has a radio, selected or not.
284
+ for (const option of options) {
285
+ expect(option.querySelector('.astryx-radio')).not.toBeNull();
286
+ }
287
+
288
+ // And exactly the selected one is filled.
289
+ const filled = options.filter(
290
+ o => o.querySelector('.astryx-radio-dot') != null,
291
+ );
292
+ expect(filled).toHaveLength(1);
293
+ expect(filled[0]).toHaveTextContent('Banana');
294
+ });
295
+
155
296
  it('renders custom option endContent', async () => {
156
297
  const user = userEvent.setup();
157
298
  render(
@@ -265,6 +406,81 @@ describe('Selector', () => {
265
406
  }
266
407
  });
267
408
 
409
+ it('aligns the selected item using untransformed layout geometry', async () => {
410
+ const restoreRects = mockSelectorRects({
411
+ anchor: rect({top: 160, bottom: 192, height: 32}),
412
+ trigger: rect({top: 166, bottom: 186, height: 20}),
413
+ // Simulate the 0.95 entry scale in visual rects while retaining the
414
+ // untransformed 120px list / 36px item offset used for positioning.
415
+ listbox: rect({top: 190, bottom: 304, height: 114}),
416
+ selectedItem: rect({
417
+ top: 224.2,
418
+ bottom: 254.6,
419
+ height: 30.4,
420
+ }),
421
+ listboxLayoutHeight: 120,
422
+ selectedItemLayoutTop: 36,
423
+ selectedItemLayoutHeight: 32,
424
+ viewportHeight: 900,
425
+ });
426
+ const user = userEvent.setup();
427
+ try {
428
+ render(
429
+ <Selector
430
+ label="Fruit"
431
+ options={OPTIONS}
432
+ value="Banana"
433
+ onChange={() => {}}
434
+ />,
435
+ );
436
+
437
+ await user.click(screen.getByRole('combobox'));
438
+ const popover = screen
439
+ .getByRole('listbox', {hidden: true})
440
+ .closest('[popover]');
441
+ await waitFor(() => {
442
+ // 68px geometric alignment plus the 1px optical correction.
443
+ expect(popover?.getAttribute('style')).toContain(
444
+ 'margin-block-start: -69px',
445
+ );
446
+ });
447
+ } finally {
448
+ restoreRects();
449
+ }
450
+ });
451
+
452
+ it('adds the border inset only to input-variant dropdowns', async () => {
453
+ const user = userEvent.setup();
454
+ const {unmount} = render(
455
+ <Selector
456
+ label="Input fruit"
457
+ options={OPTIONS}
458
+ value="Banana"
459
+ onChange={() => {}}
460
+ />,
461
+ );
462
+
463
+ await user.click(screen.getByRole('combobox'));
464
+ const inputDropdownClass = screen.getByRole('listbox', h).className;
465
+ unmount();
466
+
467
+ render(
468
+ <Selector
469
+ label="Ghost fruit"
470
+ options={OPTIONS}
471
+ value="Banana"
472
+ variant="ghost"
473
+ onChange={() => {}}
474
+ />,
475
+ );
476
+ await user.click(screen.getByRole('combobox'));
477
+ const ghostDropdownClass = screen.getByRole('listbox', h).className;
478
+
479
+ // The bordered input gets one extra StyleX rule for its border-width
480
+ // correction; the borderless ghost keeps the base menu inset.
481
+ expect(inputDropdownClass).not.toBe(ghostDropdownClass);
482
+ });
483
+
268
484
  it('does not apply selected-item overlay offset when placement is explicit', async () => {
269
485
  const restoreRects = mockSelectorRects();
270
486
  const user = userEvent.setup();
@@ -293,6 +509,89 @@ describe('Selector', () => {
293
509
  }
294
510
  });
295
511
 
512
+ describe('menu clearance', () => {
513
+ it('clears the trigger by the standard menu offset when placement is explicit', async () => {
514
+ const user = userEvent.setup();
515
+ render(
516
+ <Selector
517
+ label="Fruit"
518
+ options={OPTIONS}
519
+ value="Banana"
520
+ onChange={() => {}}
521
+ placement="above"
522
+ />,
523
+ );
524
+
525
+ await user.click(screen.getByRole('combobox'));
526
+ const popover = screen
527
+ .getByRole('listbox', {hidden: true})
528
+ .closest('[popover]') as HTMLElement;
529
+ // Both block edges, so the gap survives a position-try-fallbacks flip
530
+ // to the opposite side (#4803).
531
+ await waitFor(() => {
532
+ expect(popover.style.getPropertyValue('--x-marginBlockStart')).toBe(
533
+ spacingVars['--spacing-1'],
534
+ );
535
+ });
536
+ expect(popover.style.getPropertyValue('--x-marginBlockEnd')).toBe(
537
+ spacingVars['--spacing-1'],
538
+ );
539
+ });
540
+
541
+ it('clears the trigger in search mode', async () => {
542
+ const user = userEvent.setup();
543
+ render(
544
+ <Selector
545
+ label="Fruit"
546
+ options={OPTIONS}
547
+ value="Banana"
548
+ onChange={() => {}}
549
+ hasSearch
550
+ />,
551
+ );
552
+
553
+ // In hasSearch mode the trigger is a plain button, not a combobox.
554
+ await user.click(screen.getByRole('button', {name: 'Fruit'}));
555
+ const popover = screen
556
+ .getByRole('listbox', {hidden: true})
557
+ .closest('[popover]') as HTMLElement;
558
+ await waitFor(() => {
559
+ expect(popover.style.getPropertyValue('--x-marginBlockStart')).toBe(
560
+ spacingVars['--spacing-1'],
561
+ );
562
+ });
563
+ });
564
+
565
+ it('stays flush in the default selected-item overlay', async () => {
566
+ const restoreRects = mockSelectorRects();
567
+ const user = userEvent.setup();
568
+ try {
569
+ render(
570
+ <Selector
571
+ label="Fruit"
572
+ options={OPTIONS}
573
+ value="Banana"
574
+ onChange={() => {}}
575
+ />,
576
+ );
577
+
578
+ await user.click(screen.getByRole('combobox'));
579
+ const popover = screen
580
+ .getByRole('listbox', {hidden: true})
581
+ .closest('[popover]') as HTMLElement;
582
+ await waitFor(() => {
583
+ expect(popover.getAttribute('style')).toContain(
584
+ 'margin-block-start: -110px',
585
+ );
586
+ });
587
+ expect(popover.style.getPropertyValue('--x-marginBlockStart')).toBe('');
588
+ expect(popover.style.getPropertyValue('--x-marginBlockEnd')).toBe('');
589
+ } finally {
590
+ restoreRects();
591
+ }
592
+ });
593
+ });
594
+
296
595
  describe('hasClear', () => {
297
596
  it('shows selected value label when hasClear is enabled', () => {
298
597
  render(
@@ -988,101 +1287,627 @@ describe('Selector', () => {
988
1287
  });
989
1288
  });
990
1289
 
991
- describe('InputGroup integration', () => {
992
- it('uses the group Field chrome and composes group and selector labels', () => {
993
- render(
994
- <InputGroup
995
- label="Destination"
996
- description="Where the alert should route"
997
- status={{type: 'error', message: 'Destination is required'}}>
998
- <InputGroupText>#</InputGroupText>
999
- <Selector
1000
- label="Channel"
1001
- isLabelHidden
1002
- options={OPTIONS}
1003
- placeholder="Choose a channel"
1004
- />
1005
- </InputGroup>,
1006
- );
1290
+ describe('typeahead', () => {
1291
+ it('selects the matching option by typing on the closed trigger', async () => {
1292
+ const user = userEvent.setup();
1293
+ const onChange = vi.fn();
1294
+ render(<Selector label="Fruit" options={OPTIONS} onChange={onChange} />);
1007
1295
 
1008
- const group = screen.getByRole('group', {name: 'Destination'});
1009
- const groupLabelID = group.getAttribute('aria-labelledby');
1010
- const trigger = screen.getByRole('combobox', {
1011
- name: 'Destination Channel',
1012
- });
1013
- const labelledByIDs =
1014
- trigger.getAttribute('aria-labelledby')?.split(' ') ?? [];
1296
+ await user.tab();
1297
+ await user.keyboard('c');
1015
1298
 
1016
- expect(labelledByIDs).toHaveLength(2);
1017
- expect(labelledByIDs[0]).toBe(groupLabelID);
1018
- expect(document.getElementById(labelledByIDs[1])).toHaveTextContent(
1019
- 'Channel',
1020
- );
1021
- expect(trigger).toHaveAttribute(
1022
- 'aria-describedby',
1023
- group.getAttribute('aria-describedby'),
1299
+ expect(onChange).toHaveBeenCalledWith('Cherry');
1300
+ // Native select parity: the value changes without opening the menu.
1301
+ expect(screen.getByRole('combobox')).toHaveAttribute(
1302
+ 'aria-expanded',
1303
+ 'false',
1024
1304
  );
1025
- expect(screen.getByText('#')).toBeInTheDocument();
1026
1305
  });
1027
1306
 
1028
- it('keeps disabled reasons described when grouped', () => {
1029
- render(
1030
- <InputGroup label="Destination">
1031
- <InputGroupText>#</InputGroupText>
1307
+ it('cycles through options sharing a first letter on repeated presses', async () => {
1308
+ const user = userEvent.setup();
1309
+ function Harness() {
1310
+ const [value, setValue] = useState<string | undefined>(undefined);
1311
+ return (
1032
1312
  <Selector
1033
- label="Channel"
1034
- isLabelHidden
1035
- options={OPTIONS}
1036
- isDisabled
1037
- disabledMessage="Choose a project first"
1313
+ label="City"
1314
+ options={['Austin', 'Chicago', 'Cleveland', 'Columbus']}
1315
+ value={value}
1316
+ onChange={setValue}
1038
1317
  />
1039
- </InputGroup>,
1040
- );
1318
+ );
1319
+ }
1320
+ render(<Harness />);
1041
1321
 
1042
- const trigger = screen.getByRole('combobox', {
1043
- name: 'Destination Channel',
1044
- });
1045
- const tooltip = screen.getByRole('tooltip', h);
1322
+ await user.tab();
1323
+ await user.keyboard('c');
1324
+ expect(screen.getByRole('combobox')).toHaveTextContent('Chicago');
1325
+ await user.keyboard('c');
1326
+ expect(screen.getByRole('combobox')).toHaveTextContent('Cleveland');
1327
+ await user.keyboard('c');
1328
+ expect(screen.getByRole('combobox')).toHaveTextContent('Columbus');
1329
+ // Wraps back around past non-matching options.
1330
+ await user.keyboard('c');
1331
+ expect(screen.getByRole('combobox')).toHaveTextContent('Chicago');
1332
+ });
1046
1333
 
1047
- expect(trigger).not.toBeDisabled();
1048
- expect(trigger).toHaveAttribute('aria-disabled', 'true');
1049
- expect(trigger.getAttribute('aria-describedby')).toContain(tooltip.id);
1334
+ it('advances past the current selection on a fresh single-letter press', async () => {
1335
+ const user = userEvent.setup();
1336
+ function Harness() {
1337
+ const [value, setValue] = useState<string | undefined>('Chicago');
1338
+ return (
1339
+ <Selector
1340
+ label="City"
1341
+ options={['Austin', 'Chicago', 'Cleveland', 'Columbus']}
1342
+ value={value}
1343
+ onChange={setValue}
1344
+ />
1345
+ );
1346
+ }
1347
+ render(<Harness />);
1348
+
1349
+ const trigger = screen.getByRole('combobox');
1350
+ await user.tab();
1351
+ // Native select parity: the selected option's own initial moves on to
1352
+ // the next match. Anchoring the search AT the selection instead of after
1353
+ // it re-matches the current value, and the duplicate-select guard then
1354
+ // swallows the keystroke entirely.
1355
+ type('c', trigger);
1356
+
1357
+ expect(trigger).toHaveTextContent('Cleveland');
1050
1358
  });
1051
- });
1052
1359
 
1053
- describe('disabledMessage', () => {
1054
- it('shows the reason tooltip on hover when disabled with a reason', async () => {
1360
+ it('advances the highlight past the current one with the menu open', async () => {
1361
+ const user = userEvent.setup();
1055
1362
  render(
1056
1363
  <Selector
1057
- label="Fruit"
1058
- options={OPTIONS}
1059
- isDisabled
1060
- disabledMessage="You need the Editor role"
1061
- data-testid="fruit-selector"
1364
+ label="City"
1365
+ options={['Austin', 'Chicago', 'Cleveland', 'Columbus']}
1366
+ value="Chicago"
1367
+ onChange={() => {}}
1062
1368
  />,
1063
1369
  );
1064
1370
 
1065
- const container = screen.getByTestId('fruit-selector');
1066
- const tooltip = screen.getByRole('tooltip', h);
1067
- expect(tooltip).toHaveTextContent('You need the Editor role');
1068
-
1069
- fireEvent.mouseEnter(container);
1070
- await waitFor(() => {
1071
- expect(tooltip).toHaveAttribute('popover-open');
1072
- });
1371
+ const trigger = screen.getByRole('combobox');
1372
+ await user.tab();
1373
+ await user.keyboard('{Enter}'); // opens with the highlight on Chicago
1374
+ type('c', trigger);
1073
1375
 
1074
- fireEvent.mouseLeave(container);
1075
- await waitFor(() => {
1076
- expect(tooltip).not.toHaveAttribute('popover-open');
1077
- });
1376
+ const activeId = trigger.getAttribute('aria-activedescendant');
1377
+ expect(document.getElementById(activeId ?? '')).toHaveTextContent(
1378
+ 'Cleveland',
1379
+ );
1078
1380
  });
1079
1381
 
1080
- it('shows the reason tooltip on keyboard focus', async () => {
1382
+ it('treats a space mid-buffer as part of the match, not as open', async () => {
1081
1383
  const user = userEvent.setup();
1384
+ const onChange = vi.fn();
1082
1385
  render(
1083
1386
  <Selector
1084
- label="Fruit"
1085
- options={OPTIONS}
1387
+ label="State"
1388
+ options={['New Jersey', 'New York']}
1389
+ onChange={onChange}
1390
+ />,
1391
+ );
1392
+
1393
+ const trigger = screen.getByRole('combobox');
1394
+ await user.tab();
1395
+ // Synchronous keydowns: the buffer only accumulates while keystrokes
1396
+ // land inside the TYPEAHEAD_RESET_MS window, and awaiting between them
1397
+ // would put a CI stall on the critical path.
1398
+ type('new y', trigger);
1399
+
1400
+ expect(onChange).toHaveBeenLastCalledWith('New York');
1401
+ expect(trigger).toHaveAttribute('aria-expanded', 'false');
1402
+ });
1403
+
1404
+ it('opens and seeds the search input when typing on a closed hasSearch trigger', async () => {
1405
+ const user = userEvent.setup();
1406
+ render(<Selector label="Fruit" options={OPTIONS} hasSearch />);
1407
+
1408
+ await user.tab();
1409
+ await user.keyboard('c');
1410
+
1411
+ const search = screen.getByPlaceholderText('Search…');
1412
+ expect(search).toHaveValue('c');
1413
+ await waitFor(() => expect(search).toHaveFocus());
1414
+ });
1415
+
1416
+ it('accumulates a multi-character prefix and resets it after the timeout', async () => {
1417
+ const user = userEvent.setup();
1418
+ const onChange = vi.fn();
1419
+ // Controlled: the committed value has to feed back in, or the match
1420
+ // anchor stays -1 for the whole test and never gets exercised.
1421
+ function Harness() {
1422
+ const [value, setValue] = useState<string | undefined>(undefined);
1423
+ return (
1424
+ <Selector
1425
+ label="Fruit"
1426
+ options={['Apple', 'Banana', 'Blueberry']}
1427
+ value={value}
1428
+ onChange={next => {
1429
+ setValue(next);
1430
+ onChange(next);
1431
+ }}
1432
+ />
1433
+ );
1434
+ }
1435
+ render(<Harness />);
1436
+
1437
+ const trigger = screen.getByRole('combobox');
1438
+ await user.tab();
1439
+ type('b', trigger);
1440
+ expect(trigger).toHaveTextContent('Banana');
1441
+ // Within the window the buffer accumulates: "bl" → Blueberry. A
1442
+ // multi-character buffer refines, so it may keep the current match.
1443
+ type('l', trigger);
1444
+ expect(trigger).toHaveTextContent('Blueberry');
1445
+
1446
+ // Past the window the buffer starts fresh: "a" → Apple. A surviving
1447
+ // buffer would search "bla" and match nothing, so only a real reset
1448
+ // gets here — worth the one real wait in the suite.
1449
+ await new Promise(resolve =>
1450
+ setTimeout(resolve, TYPEAHEAD_RESET_MS + 100),
1451
+ );
1452
+ type('a', trigger);
1453
+ expect(trigger).toHaveTextContent('Apple');
1454
+ });
1455
+
1456
+ it('skips disabled options when matching', async () => {
1457
+ const user = userEvent.setup();
1458
+ function Harness() {
1459
+ const [value, setValue] = useState<string | undefined>(undefined);
1460
+ return (
1461
+ <Selector
1462
+ label="Fruit"
1463
+ options={[{value: 'Cherry', disabled: true}, 'Coconut']}
1464
+ value={value}
1465
+ onChange={setValue}
1466
+ />
1467
+ );
1468
+ }
1469
+ render(<Harness />);
1470
+
1471
+ const trigger = screen.getByRole('combobox');
1472
+ await user.tab();
1473
+ type('c', trigger);
1474
+ expect(trigger).toHaveTextContent('Coconut');
1475
+
1476
+ // The skip has to survive cycling too: with Coconut current, the next
1477
+ // press wraps onto the disabled Cherry and must pass over it.
1478
+ type('c', trigger);
1479
+ expect(trigger).toHaveTextContent('Coconut');
1480
+ expect(trigger).not.toHaveTextContent('Cherry');
1481
+ });
1482
+
1483
+ it('moves the highlight without committing when typing with the menu open', async () => {
1484
+ const user = userEvent.setup();
1485
+ const onChange = vi.fn();
1486
+ render(
1487
+ <Selector
1488
+ label="City"
1489
+ options={['Austin', 'Chicago', 'Cleveland']}
1490
+ onChange={onChange}
1491
+ />,
1492
+ );
1493
+
1494
+ await user.tab();
1495
+ await user.keyboard('{Enter}'); // open
1496
+ const trigger = screen.getByRole('combobox');
1497
+
1498
+ await user.keyboard('c');
1499
+ let activeId = trigger.getAttribute('aria-activedescendant');
1500
+ expect(document.getElementById(activeId ?? '')).toHaveTextContent(
1501
+ 'Chicago',
1502
+ );
1503
+
1504
+ // Repeated press cycles the highlight, still without committing.
1505
+ await user.keyboard('c');
1506
+ activeId = trigger.getAttribute('aria-activedescendant');
1507
+ expect(document.getElementById(activeId ?? '')).toHaveTextContent(
1508
+ 'Cleveland',
1509
+ );
1510
+ expect(onChange).not.toHaveBeenCalled();
1511
+ // aria-activedescendant already announces each match, so announcing
1512
+ // again here would make a screen reader say every match twice.
1513
+ // useAnnounce writes its text in a rAF callback, so let a frame pass —
1514
+ // asserting before it runs would pass no matter what the code does.
1515
+ await new Promise(resolve => requestAnimationFrame(() => resolve(null)));
1516
+ expect(politeRegion()?.textContent ?? '').toBe('');
1517
+
1518
+ await user.keyboard('{Enter}');
1519
+ expect(onChange).toHaveBeenCalledWith('Cleveland');
1520
+ });
1521
+
1522
+ it('does not fire onChange when the only match is already selected', async () => {
1523
+ const user = userEvent.setup();
1524
+ const onChange = vi.fn();
1525
+ render(
1526
+ <Selector
1527
+ label="Fruit"
1528
+ options={OPTIONS}
1529
+ value="Cherry"
1530
+ onChange={onChange}
1531
+ />,
1532
+ );
1533
+
1534
+ await user.tab();
1535
+ await user.keyboard('c');
1536
+
1537
+ expect(onChange).not.toHaveBeenCalled();
1538
+ });
1539
+
1540
+ it('ignores printable keys pressed with ctrl or meta modifiers', async () => {
1541
+ const user = userEvent.setup();
1542
+ const onChange = vi.fn();
1543
+ render(<Selector label="Fruit" options={OPTIONS} onChange={onChange} />);
1544
+
1545
+ await user.tab();
1546
+ await user.keyboard('{Control>}c{/Control}{Meta>}b{/Meta}');
1547
+
1548
+ expect(onChange).not.toHaveBeenCalled();
1549
+ });
1550
+
1551
+ it('announces the committed option to screen readers', async () => {
1552
+ const user = userEvent.setup();
1553
+ render(<Selector label="Fruit" options={OPTIONS} onChange={() => {}} />);
1554
+
1555
+ await user.tab();
1556
+ await user.keyboard('c');
1557
+
1558
+ // The trigger keeps focus and the menu never opens, so nothing else
1559
+ // prompts a re-read. The polite live region carries the new value.
1560
+ await waitFor(() => {
1561
+ expect(politeRegion()).toHaveTextContent('Cherry');
1562
+ });
1563
+ });
1564
+
1565
+ it('does not select while focusable-disabled', async () => {
1566
+ const user = userEvent.setup();
1567
+ const onChange = vi.fn();
1568
+ render(
1569
+ <Selector
1570
+ label="Fruit"
1571
+ options={OPTIONS}
1572
+ onChange={onChange}
1573
+ isDisabled
1574
+ disabledMessage="Ask an admin"
1575
+ />,
1576
+ );
1577
+
1578
+ // aria-disabled keeps the trigger focusable, so keydowns still arrive.
1579
+ screen.getByRole('combobox').focus();
1580
+ await user.keyboard('c');
1581
+
1582
+ expect(onChange).not.toHaveBeenCalled();
1583
+ });
1584
+
1585
+ it('cycles without duplicating changeAction while an action is pending', async () => {
1586
+ const user = userEvent.setup();
1587
+ const calls: string[] = [];
1588
+ render(
1589
+ <Selector
1590
+ label="City"
1591
+ options={['Chicago', 'Cleveland', 'Columbus']}
1592
+ value={undefined}
1593
+ changeAction={async value => {
1594
+ calls.push(value);
1595
+ // Never settles, so the value prop never catches up to what the
1596
+ // trigger already shows.
1597
+ await new Promise<void>(() => {});
1598
+ }}
1599
+ />,
1600
+ );
1601
+
1602
+ const trigger = screen.getByRole('combobox');
1603
+ await user.tab();
1604
+ type('ccc', trigger);
1605
+
1606
+ // The anchor must come from the optimistic value, not the stale prop.
1607
+ // Three options make that observable: with a stale anchor every press
1608
+ // re-matches Chicago, which the duplicate guard then swallows.
1609
+ expect(calls).toEqual(['Chicago', 'Cleveland', 'Columbus']);
1610
+ });
1611
+
1612
+ it('starts a fresh buffer after selecting from the open menu', async () => {
1613
+ const user = userEvent.setup();
1614
+ const onChange = vi.fn();
1615
+ render(
1616
+ <Selector
1617
+ label="Animal"
1618
+ options={['Cat', 'Dog']}
1619
+ onChange={onChange}
1620
+ />,
1621
+ );
1622
+
1623
+ await user.tab();
1624
+ await user.keyboard('{Enter}'); // open
1625
+ await user.keyboard('d'); // highlight Dog
1626
+ await user.keyboard('{Enter}'); // commit Dog, closes
1627
+ onChange.mockClear();
1628
+
1629
+ // The stale 'd' must not linger: 'c' is a fresh buffer, not "dc".
1630
+ await user.keyboard('c');
1631
+ expect(onChange).toHaveBeenCalledWith('Cat');
1632
+ });
1633
+
1634
+ it('starts a fresh buffer after the value is cleared', async () => {
1635
+ const user = userEvent.setup();
1636
+ const onChange = vi.fn();
1637
+ function Harness() {
1638
+ const [value, setValue] = useState<string | null>('Dog');
1639
+ return (
1640
+ <Selector
1641
+ label="Animal"
1642
+ options={['Cat', 'Dog']}
1643
+ hasClear
1644
+ value={value}
1645
+ onChange={next => {
1646
+ setValue(next);
1647
+ onChange(next);
1648
+ }}
1649
+ />
1650
+ );
1651
+ }
1652
+ render(<Harness />);
1653
+
1654
+ const trigger = screen.getByRole('combobox');
1655
+ await user.tab();
1656
+ // Fills the buffer with 'd' without committing — Dog is already current.
1657
+ type('d', trigger);
1658
+ fireEvent.keyDown(trigger, {key: 'Delete'});
1659
+ expect(onChange).toHaveBeenLastCalledWith(null);
1660
+
1661
+ // The stale 'd' must not survive the clear: 'c' is a fresh buffer, not
1662
+ // "dc", which would match nothing and leave the value cleared.
1663
+ type('c', trigger);
1664
+ expect(onChange).toHaveBeenLastCalledWith('Cat');
1665
+ });
1666
+
1667
+ it('matches on the label and commits the value', async () => {
1668
+ const user = userEvent.setup();
1669
+ const onChange = vi.fn();
1670
+ // Values deliberately crossed against labels: a native select matches
1671
+ // the rendered text and reports the value.
1672
+ function Harness() {
1673
+ const [value, setValue] = useState<string | undefined>(undefined);
1674
+ return (
1675
+ <Selector
1676
+ label="Fruit"
1677
+ options={[
1678
+ {value: 'zzz', label: 'Apple'},
1679
+ {value: 'apple', label: 'Zebra'},
1680
+ ]}
1681
+ value={value}
1682
+ onChange={next => {
1683
+ setValue(next);
1684
+ onChange(next);
1685
+ }}
1686
+ />
1687
+ );
1688
+ }
1689
+ render(<Harness />);
1690
+
1691
+ const trigger = screen.getByRole('combobox');
1692
+ await user.tab();
1693
+ type('a', trigger);
1694
+
1695
+ expect(onChange).toHaveBeenCalledWith('zzz');
1696
+ expect(trigger).toHaveTextContent('Apple');
1697
+
1698
+ // No other label starts with "a" — the option whose *value* is 'apple'
1699
+ // is labelled Zebra — so a second press stays put and commits nothing.
1700
+ type('a', trigger);
1701
+ expect(onChange).toHaveBeenCalledTimes(1);
1702
+ });
1703
+
1704
+ it('matches across sections, ignoring dividers and group titles', async () => {
1705
+ const user = userEvent.setup();
1706
+ const onChange = vi.fn();
1707
+ render(
1708
+ <Selector
1709
+ label="Fruit"
1710
+ options={[
1711
+ 'Almond',
1712
+ {type: 'divider'},
1713
+ {
1714
+ type: 'section',
1715
+ title: 'Tropical',
1716
+ options: [
1717
+ {value: 'mango', label: 'Mango'},
1718
+ {value: 'papaya', label: 'Papaya'},
1719
+ ],
1720
+ },
1721
+ ]}
1722
+ onChange={onChange}
1723
+ />,
1724
+ );
1725
+
1726
+ const trigger = screen.getByRole('combobox');
1727
+ await user.tab();
1728
+ type('p', trigger);
1729
+ expect(onChange).toHaveBeenCalledWith('papaya');
1730
+
1731
+ // The section title "Tropical" is decoration, not an option.
1732
+ onChange.mockClear();
1733
+ type('t', trigger);
1734
+ expect(onChange).not.toHaveBeenCalled();
1735
+ });
1736
+
1737
+ it('keeps aria-activedescendant on the matched option across a section', async () => {
1738
+ const user = userEvent.setup();
1739
+ render(
1740
+ <Selector
1741
+ label="Fruit"
1742
+ options={[
1743
+ 'Almond',
1744
+ {type: 'divider'},
1745
+ {
1746
+ type: 'section',
1747
+ title: 'Berries',
1748
+ options: [{value: 'blueberry', label: 'Blueberry'}],
1749
+ },
1750
+ ]}
1751
+ value="Almond"
1752
+ onChange={() => {}}
1753
+ />,
1754
+ );
1755
+
1756
+ const trigger = screen.getByRole('combobox');
1757
+ await user.tab();
1758
+ await user.keyboard('{Enter}'); // opens on Almond
1759
+ type('b', trigger);
1760
+
1761
+ const activeId = trigger.getAttribute('aria-activedescendant');
1762
+ expect(document.getElementById(activeId ?? '')).toHaveTextContent(
1763
+ 'Blueberry',
1764
+ );
1765
+ });
1766
+
1767
+ it('anchors at the top when the value matches no option', async () => {
1768
+ const user = userEvent.setup();
1769
+ const onChange = vi.fn();
1770
+ render(
1771
+ <Selector
1772
+ label="Fruit"
1773
+ options={['Apple', 'Apricot']}
1774
+ value="Durian"
1775
+ onChange={onChange}
1776
+ />,
1777
+ );
1778
+
1779
+ const trigger = screen.getByRole('combobox');
1780
+ await user.tab();
1781
+ type('a', trigger);
1782
+
1783
+ // Nothing is really selected, so the first match must stay reachable.
1784
+ expect(onChange).toHaveBeenCalledWith('Apple');
1785
+ });
1786
+
1787
+ it('lets Space open the menu after an abandoned typeahead', async () => {
1788
+ const user = userEvent.setup();
1789
+ render(<Selector label="Fruit" options={OPTIONS} />);
1790
+
1791
+ const trigger = screen.getByRole('combobox');
1792
+ await user.tab();
1793
+ await user.keyboard('{Enter}'); // open
1794
+ await user.keyboard('z'); // no match; buffer holds "z"
1795
+ await user.keyboard('{Escape}'); // close, abandoning the buffer
1796
+
1797
+ // A live "z" buffer would swallow Space as a match character.
1798
+ await user.keyboard(' ');
1799
+ expect(trigger).toHaveAttribute('aria-expanded', 'true');
1800
+ });
1801
+
1802
+ it('seeds every character typed before the search input takes focus', async () => {
1803
+ const user = userEvent.setup();
1804
+ render(<Selector label="Fruit" options={OPTIONS} hasSearch />);
1805
+
1806
+ await user.tab();
1807
+ // The popup opens on the first key, but focus only moves to the search
1808
+ // input on the next frame — the second key still lands on the trigger.
1809
+ await user.keyboard('ch');
1810
+
1811
+ const search = screen.getByPlaceholderText('Search…');
1812
+ await waitFor(() => expect(search).toHaveValue('ch'));
1813
+ });
1814
+ });
1815
+
1816
+ describe('InputGroup integration', () => {
1817
+ it('uses the group Field chrome and composes group and selector labels', () => {
1818
+ render(
1819
+ <InputGroup
1820
+ label="Destination"
1821
+ description="Where the alert should route"
1822
+ status={{type: 'error', message: 'Destination is required'}}>
1823
+ <InputGroupText>#</InputGroupText>
1824
+ <Selector
1825
+ label="Channel"
1826
+ isLabelHidden
1827
+ options={OPTIONS}
1828
+ placeholder="Choose a channel"
1829
+ />
1830
+ </InputGroup>,
1831
+ );
1832
+
1833
+ const group = screen.getByRole('group', {name: 'Destination'});
1834
+ const groupLabelID = group.getAttribute('aria-labelledby');
1835
+ const trigger = screen.getByRole('combobox', {
1836
+ name: 'Destination Channel',
1837
+ });
1838
+ const labelledByIDs =
1839
+ trigger.getAttribute('aria-labelledby')?.split(' ') ?? [];
1840
+
1841
+ expect(labelledByIDs).toHaveLength(2);
1842
+ expect(labelledByIDs[0]).toBe(groupLabelID);
1843
+ expect(document.getElementById(labelledByIDs[1])).toHaveTextContent(
1844
+ 'Channel',
1845
+ );
1846
+ expect(trigger).toHaveAttribute(
1847
+ 'aria-describedby',
1848
+ group.getAttribute('aria-describedby'),
1849
+ );
1850
+ expect(screen.getByText('#')).toBeInTheDocument();
1851
+ });
1852
+
1853
+ it('keeps disabled reasons described when grouped', () => {
1854
+ render(
1855
+ <InputGroup label="Destination">
1856
+ <InputGroupText>#</InputGroupText>
1857
+ <Selector
1858
+ label="Channel"
1859
+ isLabelHidden
1860
+ options={OPTIONS}
1861
+ isDisabled
1862
+ disabledMessage="Choose a project first"
1863
+ />
1864
+ </InputGroup>,
1865
+ );
1866
+
1867
+ const trigger = screen.getByRole('combobox', {
1868
+ name: 'Destination Channel',
1869
+ });
1870
+ const tooltip = screen.getByRole('tooltip', h);
1871
+
1872
+ expect(trigger).not.toBeDisabled();
1873
+ expect(trigger).toHaveAttribute('aria-disabled', 'true');
1874
+ expect(trigger.getAttribute('aria-describedby')).toContain(tooltip.id);
1875
+ });
1876
+ });
1877
+
1878
+ describe('disabledMessage', () => {
1879
+ it('shows the reason tooltip on hover when disabled with a reason', async () => {
1880
+ render(
1881
+ <Selector
1882
+ label="Fruit"
1883
+ options={OPTIONS}
1884
+ isDisabled
1885
+ disabledMessage="You need the Editor role"
1886
+ data-testid="fruit-selector"
1887
+ />,
1888
+ );
1889
+
1890
+ const container = screen.getByTestId('fruit-selector');
1891
+ const tooltip = screen.getByRole('tooltip', h);
1892
+ expect(tooltip).toHaveTextContent('You need the Editor role');
1893
+
1894
+ fireEvent.mouseEnter(container);
1895
+ await waitFor(() => {
1896
+ expect(tooltip).toHaveAttribute('popover-open');
1897
+ });
1898
+
1899
+ fireEvent.mouseLeave(container);
1900
+ await waitFor(() => {
1901
+ expect(tooltip).not.toHaveAttribute('popover-open');
1902
+ });
1903
+ });
1904
+
1905
+ it('shows the reason tooltip on keyboard focus', async () => {
1906
+ const user = userEvent.setup();
1907
+ render(
1908
+ <Selector
1909
+ label="Fruit"
1910
+ options={OPTIONS}
1086
1911
  isDisabled
1087
1912
  disabledMessage="You need the Editor role"
1088
1913
  />,
@@ -1318,6 +2143,28 @@ describe('Selector statusVariant forwarding', () => {
1318
2143
  });
1319
2144
  });
1320
2145
 
2146
+ describe('Selector empty-state theme target', () => {
2147
+ const OPTIONS = ['Apple', 'Banana', 'Cherry'];
2148
+
2149
+ it('renders the astryx-selector-empty-state target on the "No results found" element', async () => {
2150
+ const user = userEvent.setup();
2151
+ const {container} = render(
2152
+ <Selector
2153
+ label="Fruit"
2154
+ options={OPTIONS}
2155
+ onChange={() => {}}
2156
+ hasSearch
2157
+ />,
2158
+ );
2159
+ await user.click(screen.getByRole('button', {name: 'Fruit'}));
2160
+ await user.type(screen.getByRole('combobox', h), 'xyz');
2161
+
2162
+ const empty = container.querySelector('.astryx-selector-empty-state');
2163
+ expect(empty).not.toBeNull();
2164
+ expect(empty).toHaveTextContent('No results found');
2165
+ });
2166
+ });
2167
+
1321
2168
  describe('Selector clear icon theme target', () => {
1322
2169
  // Resolve the clear glyph span (the astryx-icon element inside the clear
1323
2170
  // button), independent of the theme target class.
@@ -1330,7 +2177,7 @@ describe('Selector clear icon theme target', () => {
1330
2177
  return icon as HTMLElement;
1331
2178
  };
1332
2179
 
1333
- it('renders the astryx-selector-clear-icon target on the clear glyph', () => {
2180
+ it('renders the astryx-input-clear-icon target (plus the legacy alias) on the clear glyph', () => {
1334
2181
  render(
1335
2182
  <Selector
1336
2183
  label="Fruit"
@@ -1340,11 +2187,13 @@ describe('Selector clear icon theme target', () => {
1340
2187
  hasClear
1341
2188
  />,
1342
2189
  );
1343
- // The stable theme target lands on the icon element itself (not the
1344
- // button), so a theme can restyle just this glyph (color, size, hover)
1345
- // via `defineTheme` — a button-level target could not reach the icon's
1346
- // own color/size.
2190
+ // The canonical target lands on the icon element itself (not the button),
2191
+ // so a theme can restyle just this glyph (color, size, hover) via
2192
+ // `defineTheme` — a button-level target could not reach the icon's own
2193
+ // color/size. The original per-component name rides along for a
2194
+ // deprecation window.
1347
2195
  const icon = getClearIcon();
2196
+ expect(icon).toHaveClass('astryx-input-clear-icon');
1348
2197
  expect(icon).toHaveClass('astryx-selector-clear-icon');
1349
2198
  expect(icon).toHaveClass('astryx-icon');
1350
2199
  });
@@ -1366,11 +2215,13 @@ describe('Selector clear icon theme target', () => {
1366
2215
  expect(onChange).toHaveBeenCalledWith(null);
1367
2216
  });
1368
2217
 
1369
- it('renders the default icon (secondary color, sm size) byte-identically', () => {
1370
- // Pixel-identical default guard: the clear glyph must carry the exact same
1371
- // StyleX color/size classes as a standalone secondary/sm icon. The added
1372
- // target class is purely additive it changes nothing until a theme
1373
- // targets it.
2218
+ it('routes the clear glyph through the shared clear button, keeping the legacy target', () => {
2219
+ // The clear affordance now composes the shared InputClearButton (a ghost
2220
+ // Button with a secondary/sm glyph), so the icon carries the canonical
2221
+ // `astryx-input-clear-icon` target andfor a deprecation window the
2222
+ // original `astryx-selector-clear-icon`. Aside from those target classes
2223
+ // it matches the shared button's own `close`/`sm`/`secondary` glyph
2224
+ // exactly, so the default look is defined in one place.
1374
2225
  render(
1375
2226
  <Selector
1376
2227
  label="Fruit"
@@ -1381,6 +2232,8 @@ describe('Selector clear icon theme target', () => {
1381
2232
  />,
1382
2233
  );
1383
2234
  const icon = getClearIcon();
2235
+ expect(icon).toHaveClass('astryx-input-clear-icon');
2236
+ expect(icon).toHaveClass('astryx-selector-clear-icon');
1384
2237
 
1385
2238
  const {container: refContainer} = render(
1386
2239
  <Icon icon="close" size="sm" color="secondary" />,
@@ -1390,7 +2243,11 @@ describe('Selector clear icon theme target', () => {
1390
2243
  const styleClasses = (el: HTMLElement) =>
1391
2244
  el.className
1392
2245
  .split(' ')
1393
- .filter(c => c !== 'astryx-selector-clear-icon')
2246
+ .filter(
2247
+ c =>
2248
+ c !== 'astryx-input-clear-icon' &&
2249
+ c !== 'astryx-selector-clear-icon',
2250
+ )
1394
2251
  .sort();
1395
2252
 
1396
2253
  expect(styleClasses(icon)).toEqual(styleClasses(refIcon));
@@ -1464,9 +2321,12 @@ describe('Selector indicator (chevron) icon theme target', () => {
1464
2321
  });
1465
2322
  });
1466
2323
 
1467
- it('renders the default icon (inherit color, sm size) byte-identically', () => {
2324
+ it('renders the default icon (secondary color, sm size) byte-identically', () => {
1468
2325
  // Pixel-identical default guard: the chevron glyph must carry the exact
1469
- // same StyleX color/size classes as a standalone inherit/sm icon. The added
2326
+ // same StyleX color/size classes as a standalone secondary/sm icon. The
2327
+ // glyph now sets --color-icon-secondary itself rather than inheriting it
2328
+ // from a wrapper span that set the same token, so the rendered color is
2329
+ // unchanged. The added
1470
2330
  // target class + data-state are purely additive — they change nothing until
1471
2331
  // a theme targets them.
1472
2332
  const {container} = render(
@@ -1475,12 +2335,12 @@ describe('Selector indicator (chevron) icon theme target', () => {
1475
2335
  const icon = getIndicatorIcon(container);
1476
2336
 
1477
2337
  const {container: refContainer} = render(
1478
- <Icon icon="chevronDown" size="sm" color="inherit" />,
2338
+ <Icon icon="chevronDown" size="sm" color="secondary" />,
1479
2339
  );
1480
2340
  const refIcon = refContainer.querySelector('.astryx-icon') as HTMLElement;
1481
2341
 
1482
2342
  // Exclude the additive theme-target classes (the stable target + its
1483
- // reflected state class) so only the StyleX color/size classes remain.
2343
+ // reflected state class) so only StyleX classes remain.
1484
2344
  const themeTargetClasses = new Set([
1485
2345
  'astryx-selector-indicator-icon',
1486
2346
  'collapsed',
@@ -1492,7 +2352,14 @@ describe('Selector indicator (chevron) icon theme target', () => {
1492
2352
  .filter(c => !themeTargetClasses.has(c))
1493
2353
  .sort();
1494
2354
 
1495
- expect(styleClasses(icon)).toEqual(styleClasses(refIcon));
2355
+ // A superset, not an exact match: the chevron additionally carries the
2356
+ // rotation styles, which live on the glyph precisely so a theme can reach
2357
+ // the transform through the same selector as the color. The guard that
2358
+ // matters is that every color/size class of a standalone icon is still
2359
+ // present — i.e. the default look has not drifted.
2360
+ expect(styleClasses(icon)).toEqual(
2361
+ expect.arrayContaining(styleClasses(refIcon)),
2362
+ );
1496
2363
  });
1497
2364
 
1498
2365
  it('exposes selector-indicator-icon so a theme reaches the icon size and per-state color', () => {
@@ -1640,3 +2507,272 @@ describe('Selector search affordances', () => {
1640
2507
  expect(trigger).toHaveAttribute('aria-expanded', 'true');
1641
2508
  });
1642
2509
  });
2510
+
2511
+ describe('Selector selected-marker theme target (selector-check)', () => {
2512
+ const openOptions = (): HTMLElement[] => screen.getAllByRole('option', h);
2513
+
2514
+ it('renders the astryx-selector-check target on the selected row only', () => {
2515
+ render(
2516
+ <Selector
2517
+ label="Fruit"
2518
+ options={OPTIONS}
2519
+ value="Banana"
2520
+ onChange={() => {}}
2521
+ isDefaultOpen
2522
+ />,
2523
+ );
2524
+ const options = openOptions();
2525
+ const selected = options.find(
2526
+ o => o.getAttribute('aria-selected') === 'true',
2527
+ )!;
2528
+ const check = selected.querySelector('.astryx-selector-check');
2529
+ expect(check).toBeInTheDocument();
2530
+ // The target lands on the checkmark glyph itself, so a theme can restyle or
2531
+ // hide it (e.g. to compose its own selected indicator via renderOption).
2532
+ expect(check).toHaveClass('astryx-icon');
2533
+
2534
+ const unselected = options.filter(
2535
+ o => o.getAttribute('aria-selected') !== 'true',
2536
+ );
2537
+ for (const row of unselected) {
2538
+ expect(
2539
+ row.querySelector('.astryx-selector-check'),
2540
+ ).not.toBeInTheDocument();
2541
+ }
2542
+ });
2543
+
2544
+ it('renders the default checkmark byte-identically aside from the target class', () => {
2545
+ // The added target class is purely additive — it changes nothing about the
2546
+ // glyph's own color/size until a theme targets it.
2547
+ render(
2548
+ <Selector
2549
+ label="Fruit"
2550
+ options={OPTIONS}
2551
+ value="Banana"
2552
+ onChange={() => {}}
2553
+ isDefaultOpen
2554
+ />,
2555
+ );
2556
+ const selected = screen
2557
+ .getAllByRole('option', h)
2558
+ .find(o => o.getAttribute('aria-selected') === 'true')!;
2559
+ const check = selected.querySelector(
2560
+ '.astryx-selector-check',
2561
+ ) as HTMLElement;
2562
+
2563
+ const {container: refContainer} = render(
2564
+ <Icon icon="check" size="sm" color="accent" />,
2565
+ );
2566
+ const refIcon = refContainer.querySelector('.astryx-icon') as HTMLElement;
2567
+ const styleClasses = (el: HTMLElement) =>
2568
+ el.className
2569
+ .split(' ')
2570
+ .filter(c => c !== 'astryx-selector-check')
2571
+ .sort();
2572
+ expect(styleClasses(check)).toEqual(styleClasses(refIcon));
2573
+ });
2574
+
2575
+ it('exposes selector-check so a theme can hide or restyle the marker', () => {
2576
+ const theme = defineTheme({
2577
+ name: 'selector-check-test',
2578
+ components: {
2579
+ 'selector-check': {
2580
+ base: {display: 'none'},
2581
+ },
2582
+ },
2583
+ });
2584
+ const css = generateThemeTestCSS(theme);
2585
+ expect(css).toContain('.astryx-selector-check {');
2586
+ expect(css).toContain('display: none');
2587
+ });
2588
+ });
2589
+
2590
+ describe('Selector disabled state theme target', () => {
2591
+ const getSelectorRoot = (container: HTMLElement): HTMLElement => {
2592
+ const root = container.querySelector('.astryx-selector');
2593
+ if (root == null) {
2594
+ throw new Error('selector root not found');
2595
+ }
2596
+ return root as HTMLElement;
2597
+ };
2598
+
2599
+ it('reflects data-disabled="disabled" on the root when disabled', () => {
2600
+ const {container} = render(
2601
+ <Selector
2602
+ label="Fruit"
2603
+ options={OPTIONS}
2604
+ onChange={() => {}}
2605
+ isDisabled
2606
+ />,
2607
+ );
2608
+ expect(getSelectorRoot(container)).toHaveAttribute(
2609
+ 'data-disabled',
2610
+ 'disabled',
2611
+ );
2612
+ });
2613
+
2614
+ it('omits the disabled class/attribute when enabled', () => {
2615
+ const {container} = render(
2616
+ <Selector label="Fruit" options={OPTIONS} onChange={() => {}} />,
2617
+ );
2618
+ const root = getSelectorRoot(container);
2619
+ expect(root).not.toHaveAttribute('data-disabled');
2620
+ expect(root).not.toHaveClass('disabled');
2621
+ });
2622
+
2623
+ it('exposes the disabled state so a theme can key on it', () => {
2624
+ const theme = defineTheme({
2625
+ name: 'selector-disabled-state-test',
2626
+ components: {
2627
+ selector: {
2628
+ 'disabled:disabled': {opacity: '0.4'},
2629
+ },
2630
+ },
2631
+ });
2632
+ const css = generateThemeTestCSS(theme);
2633
+ expect(css).toContain('.astryx-selector.disabled');
2634
+ expect(css).toContain('opacity: 0.4');
2635
+ });
2636
+ });
2637
+
2638
+ describe('Selector indicatorPosition', () => {
2639
+ const openRows = (): HTMLElement[] => screen.getAllByRole('option', h);
2640
+ const rowFor = (label: string): HTMLElement =>
2641
+ openRows().find(row => row.textContent?.includes(label))!;
2642
+
2643
+ it('draws the mark after the option content by default', () => {
2644
+ render(
2645
+ <Selector
2646
+ label="Fruit"
2647
+ options={OPTIONS}
2648
+ value="Banana"
2649
+ onChange={() => {}}
2650
+ isDefaultOpen
2651
+ />,
2652
+ );
2653
+ const row = rowFor('Banana');
2654
+ const mark = row.querySelector('.astryx-selector-check')!;
2655
+ const content = row.querySelector('.astryx-selector-option')!;
2656
+ expect(
2657
+ content.compareDocumentPosition(mark) & Node.DOCUMENT_POSITION_FOLLOWING,
2658
+ ).toBeTruthy();
2659
+ });
2660
+
2661
+ it('draws the mark before the option content when set to start', () => {
2662
+ render(
2663
+ <Selector
2664
+ label="Fruit"
2665
+ options={OPTIONS}
2666
+ value="Banana"
2667
+ onChange={() => {}}
2668
+ indicatorPosition="start"
2669
+ isDefaultOpen
2670
+ />,
2671
+ );
2672
+ const row = rowFor('Banana');
2673
+ const mark = row.querySelector('.astryx-selector-check')!;
2674
+ const content = row.querySelector('.astryx-selector-option')!;
2675
+ expect(
2676
+ content.compareDocumentPosition(mark) & Node.DOCUMENT_POSITION_PRECEDING,
2677
+ ).toBeTruthy();
2678
+ });
2679
+
2680
+ it('reserves the mark column on every row, at either position', () => {
2681
+ // The default check draws nothing when unchecked, so without a reserved
2682
+ // column the chosen row would be laid out differently from the rest —
2683
+ // indented at the start, truncating earlier at the end. Every row is two
2684
+ // children wide either way, so a row's geometry does not depend on whether
2685
+ // it happens to be the chosen one.
2686
+ const {unmount} = render(
2687
+ <Selector
2688
+ label="Fruit"
2689
+ options={OPTIONS}
2690
+ value="Banana"
2691
+ onChange={() => {}}
2692
+ indicatorPosition="start"
2693
+ isDefaultOpen
2694
+ />,
2695
+ );
2696
+ for (const row of openRows()) {
2697
+ expect(row.children).toHaveLength(2);
2698
+ }
2699
+ unmount();
2700
+
2701
+ render(
2702
+ <Selector
2703
+ label="Fruit"
2704
+ options={OPTIONS}
2705
+ value="Banana"
2706
+ onChange={() => {}}
2707
+ isDefaultOpen
2708
+ />,
2709
+ );
2710
+ for (const row of openRows()) {
2711
+ expect(row.children).toHaveLength(2);
2712
+ }
2713
+ });
2714
+
2715
+ it('positions a themed replacement indicator the same way', () => {
2716
+ const theme = defineTheme({
2717
+ name: 'selector-start-radio-mark-test',
2718
+ indicators: {check: RadioIndicator},
2719
+ });
2720
+ render(
2721
+ <Theme theme={theme}>
2722
+ <Selector
2723
+ label="Fruit"
2724
+ options={OPTIONS}
2725
+ value="Banana"
2726
+ onChange={() => {}}
2727
+ indicatorPosition="start"
2728
+ isDefaultOpen
2729
+ />
2730
+ </Theme>,
2731
+ );
2732
+ for (const row of openRows()) {
2733
+ const radio = row.querySelector('.astryx-radio')!;
2734
+ const content = row.querySelector('.astryx-selector-option')!;
2735
+ expect(
2736
+ content.compareDocumentPosition(radio) &
2737
+ Node.DOCUMENT_POSITION_PRECEDING,
2738
+ ).toBeTruthy();
2739
+ }
2740
+ });
2741
+ });
2742
+
2743
+ describe('Selector popup theme target', () => {
2744
+ // The surface is the same element whether or not the popup has a search
2745
+ // field — which is the reason the target lives there. Rendered on the
2746
+ // component's own content, it would land on the listbox in one branch and
2747
+ // on a wrapper in the other, so one theme rule would style two different
2748
+ // boxes.
2749
+ it.each([
2750
+ ['without search', false],
2751
+ ['with search', true],
2752
+ ])(
2753
+ 'puts astryx-selector-popup on the painting surface, %s',
2754
+ async (_label, hasSearch) => {
2755
+ const user = userEvent.setup();
2756
+ render(
2757
+ <Selector
2758
+ label="Fruit"
2759
+ options={['Apple', 'Banana']}
2760
+ value="Apple"
2761
+ onChange={() => {}}
2762
+ hasSearch={hasSearch}
2763
+ />,
2764
+ );
2765
+ // The trigger is a combobox in the plain variant and a listbox-popup
2766
+ // button in the search variant; the surface is the same either way.
2767
+ await user.click(
2768
+ screen.queryByRole('combobox') ??
2769
+ screen.getByRole('button', {name: /Fruit/}),
2770
+ );
2771
+
2772
+ const popup = document.querySelector('.astryx-selector-popup');
2773
+ expect(popup).not.toBeNull();
2774
+ expect(popup).toHaveClass('astryx-popover-surface');
2775
+ expect(popup?.querySelector('[role="listbox"]')).not.toBeNull();
2776
+ },
2777
+ );
2778
+ });