@astryxdesign/core 0.3.0 → 0.4.0-canary.058f476

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