@primitiv-ui/react 0.1.28 → 0.1.30

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 (456) hide show
  1. package/README.md +19 -2
  2. package/package.json +6 -1
  3. package/src/AccessibleIcon/AccessibleIcon.tsx +32 -9
  4. package/src/AccessibleIcon/__tests__/AccessibleIcon.test.tsx +5 -0
  5. package/src/AccessibleIcon/types.ts +15 -2
  6. package/src/Accordion/Accordion.tsx +32 -11
  7. package/src/Accordion/README.md +2 -2
  8. package/src/Accordion/__tests__/Accordion.asChild.test.tsx +46 -0
  9. package/src/Accordion/__tests__/Accordion.basic-rendering.test.tsx +15 -0
  10. package/src/Accordion/__tests__/Accordion.disabled-items.test.tsx +42 -0
  11. package/src/Accordion/__tests__/Accordion.error-handling.test.tsx +33 -0
  12. package/src/Accordion/hooks/useAccordionRoot.ts +44 -7
  13. package/src/Accordion/hooks/useAccordionTrigger.ts +9 -1
  14. package/src/Accordion/types.ts +72 -21
  15. package/src/Alert/Alert.tsx +36 -7
  16. package/src/Alert/__tests__/Alert.test.tsx +5 -0
  17. package/src/Alert/types.ts +18 -2
  18. package/src/Avatar/Avatar.tsx +99 -15
  19. package/src/Avatar/__tests__/Avatar.basic-rendering.test.tsx +10 -0
  20. package/src/Avatar/__tests__/Avatar.fallback.test.tsx +30 -0
  21. package/src/Avatar/__tests__/Avatar.image-loading.test.tsx +35 -0
  22. package/src/Avatar/hooks/useAvatarImage.ts +16 -0
  23. package/src/Avatar/types.ts +15 -3
  24. package/src/Breadcrumb/Breadcrumb.tsx +139 -21
  25. package/src/Breadcrumb/README.md +47 -0
  26. package/src/Breadcrumb/__tests__/Breadcrumb.basic-rendering.test.tsx +11 -0
  27. package/src/Breadcrumb/__tests__/Breadcrumb.ellipsis.test.tsx +110 -0
  28. package/src/Breadcrumb/types.ts +73 -7
  29. package/src/Button/Button.tsx +2 -0
  30. package/src/Button/__tests__/Button.basic-rendering.test.tsx +6 -0
  31. package/src/Button/__tests__/Button.contract.test.tsx +1 -1
  32. package/src/Button/types.ts +21 -0
  33. package/src/Carousel/Carousel.tsx +268 -20
  34. package/src/Carousel/README.md +764 -87
  35. package/src/Carousel/__tests__/Carousel.autoplay-status.test.tsx +239 -0
  36. package/src/Carousel/__tests__/Carousel.drag-status.test.tsx +292 -0
  37. package/src/Carousel/__tests__/Carousel.dynamic-slides.test.tsx +38 -0
  38. package/src/Carousel/__tests__/Carousel.indicators.test.tsx +101 -0
  39. package/src/Carousel/__tests__/Carousel.infinite.test.tsx +1453 -0
  40. package/src/Carousel/__tests__/Carousel.instant-scroll.test.tsx +124 -0
  41. package/src/Carousel/__tests__/Carousel.intersection-observer.test.tsx +90 -2
  42. package/src/Carousel/__tests__/Carousel.loop.test.tsx +163 -0
  43. package/src/Carousel/__tests__/Carousel.loopEngine.test.ts +77 -0
  44. package/src/Carousel/__tests__/Carousel.mouse-drag.test.tsx +504 -0
  45. package/src/Carousel/__tests__/Carousel.multi-slide-bounds.test.tsx +91 -0
  46. package/src/Carousel/__tests__/Carousel.orientation.test.tsx +207 -0
  47. package/src/Carousel/__tests__/Carousel.overscroll.test.tsx +532 -0
  48. package/src/Carousel/__tests__/Carousel.page-snap-points.test.tsx +50 -0
  49. package/src/Carousel/__tests__/Carousel.progress-text.test.tsx +76 -0
  50. package/src/Carousel/__tests__/Carousel.reduced-motion.test.tsx +3 -6
  51. package/src/Carousel/__tests__/Carousel.refresh-progress.test.tsx +2 -5
  52. package/src/Carousel/__tests__/Carousel.scroll-margin.test.tsx +167 -0
  53. package/src/Carousel/__tests__/Carousel.scroll-progress.test.tsx +518 -0
  54. package/src/Carousel/__tests__/Carousel.scroll-snap-change.test.tsx +32 -11
  55. package/src/Carousel/__tests__/Carousel.scroll-sync.test.tsx +237 -24
  56. package/src/Carousel/__tests__/Carousel.scroll-to-index.test.tsx +115 -0
  57. package/src/Carousel/__tests__/Carousel.slide-snap.test.tsx +172 -0
  58. package/src/Carousel/__tests__/Carousel.slides-per-move.test.tsx +179 -1
  59. package/src/Carousel/__tests__/Carousel.slides-per-page.test.tsx +91 -7
  60. package/src/Carousel/__tests__/Carousel.snap-type.test.tsx +35 -0
  61. package/src/Carousel/__tests__/Carousel.transition-modes.test.tsx +30 -5
  62. package/src/Carousel/__tests__/Carousel.translations.test.tsx +23 -0
  63. package/src/Carousel/__tests__/Carousel.wheel.test.tsx +160 -0
  64. package/src/Carousel/hooks/index.ts +1 -0
  65. package/src/Carousel/hooks/useCarouselLoop.ts +869 -0
  66. package/src/Carousel/hooks/useCarouselRoot.ts +338 -42
  67. package/src/Carousel/hooks/useCarouselSlide.ts +44 -9
  68. package/src/Carousel/hooks/useCarouselViewport.ts +649 -46
  69. package/src/Carousel/loopEngine.ts +54 -0
  70. package/src/Carousel/types.ts +458 -36
  71. package/src/Checkbox/Checkbox.tsx +11 -3
  72. package/src/Checkbox/__tests__/Checkbox.basic-rendering.test.tsx +14 -0
  73. package/src/Checkbox/hooks/useCheckboxInput.ts +4 -0
  74. package/src/Checkbox/types.ts +19 -0
  75. package/src/CheckboxCard/CheckboxCard.tsx +14 -5
  76. package/src/CheckboxCard/README.md +1 -1
  77. package/src/CheckboxCard/__tests__/CheckboxCard.basic-rendering.test.tsx +7 -0
  78. package/src/CheckboxCard/types.ts +65 -17
  79. package/src/Collapsible/Collapsible.tsx +49 -3
  80. package/src/Collapsible/README.md +41 -1
  81. package/src/Collapsible/__tests__/Collapsible.asChild.test.tsx +89 -0
  82. package/src/Collapsible/__tests__/Collapsible.basic-rendering.test.tsx +27 -0
  83. package/src/Collapsible/__tests__/Collapsible.collapsedHeight.test.tsx +140 -0
  84. package/src/Collapsible/hooks/useCollapsibleTrigger.ts +3 -1
  85. package/src/Collapsible/types.ts +89 -22
  86. package/src/Combobox/Combobox.tsx +508 -0
  87. package/src/Combobox/ComboboxContext.ts +23 -0
  88. package/src/Combobox/README.md +170 -0
  89. package/src/Combobox/__tests__/Combobox.asChild.test.tsx +62 -0
  90. package/src/Combobox/__tests__/Combobox.basic-rendering.test.tsx +16 -0
  91. package/src/Combobox/__tests__/Combobox.content.test.tsx +36 -0
  92. package/src/Combobox/__tests__/Combobox.controlled-state.test.tsx +88 -0
  93. package/src/Combobox/__tests__/Combobox.empty.test.tsx +42 -0
  94. package/src/Combobox/__tests__/Combobox.ids.test.tsx +66 -0
  95. package/src/Combobox/__tests__/Combobox.item.test.tsx +76 -0
  96. package/src/Combobox/__tests__/Combobox.keyboard-interaction.test.tsx +196 -0
  97. package/src/Combobox/__tests__/Combobox.light-dismiss.test.tsx +101 -0
  98. package/src/Combobox/__tests__/Combobox.query.test.tsx +105 -0
  99. package/src/Combobox/hooks/index.ts +1 -0
  100. package/src/Combobox/hooks/useComboboxRoot.ts +2 -0
  101. package/src/Combobox/index.ts +3 -0
  102. package/src/Combobox/types.ts +133 -0
  103. package/src/ContextMenu/ContextMenu.tsx +270 -4
  104. package/src/ContextMenu/README.md +1 -1
  105. package/src/ContextMenu/types.ts +148 -6
  106. package/src/DirectionProvider/DirectionProvider.tsx +41 -9
  107. package/src/DirectionProvider/README.md +4 -4
  108. package/src/DirectionProvider/__tests__/DirectionProvider.test.tsx +5 -0
  109. package/src/DirectionProvider/types.ts +28 -3
  110. package/src/Divider/Divider.tsx +24 -10
  111. package/src/Divider/__tests__/Divider.contract.test.tsx +1 -1
  112. package/src/Divider/__tests__/Divider.test.tsx +5 -0
  113. package/src/Divider/types.ts +10 -3
  114. package/src/Drawer/Drawer.tsx +315 -0
  115. package/src/Drawer/README.md +124 -0
  116. package/src/Drawer/__tests__/Drawer.behavior.test.tsx +100 -0
  117. package/src/Drawer/__tests__/Drawer.focus-trap.test.tsx +55 -0
  118. package/src/Drawer/__tests__/Drawer.rendering.test.tsx +111 -0
  119. package/src/Drawer/__tests__/Drawer.side.test.tsx +69 -0
  120. package/src/Drawer/__tests__/dialog-polyfill.ts +39 -0
  121. package/src/Drawer/index.ts +2 -0
  122. package/src/Drawer/types.ts +66 -0
  123. package/src/Dropdown/Dropdown.tsx +119 -2
  124. package/src/Dropdown/README.md +7 -5
  125. package/src/Dropdown/types.ts +241 -11
  126. package/src/EmptyState/EmptyState.tsx +74 -4
  127. package/src/EmptyState/README.md +2 -2
  128. package/src/EmptyState/__tests__/EmptyState.Actions.test.tsx +7 -2
  129. package/src/EmptyState/__tests__/EmptyState.Description.test.tsx +3 -1
  130. package/src/EmptyState/__tests__/EmptyState.Root.test.tsx +10 -0
  131. package/src/EmptyState/__tests__/EmptyState.Title.test.tsx +3 -1
  132. package/src/EmptyState/types.ts +49 -6
  133. package/src/Field/Field.tsx +23 -0
  134. package/src/Field/README.md +1 -1
  135. package/src/Field/__tests__/Field.basic-rendering.test.tsx +11 -0
  136. package/src/Field/__tests__/Field.error-handling.test.tsx +23 -0
  137. package/src/Field/__tests__/Field.state-cascade.test.tsx +21 -0
  138. package/src/Field/hooks/__tests__/useFieldProps.test.tsx +104 -9
  139. package/src/Field/hooks/useFieldProps.ts +1 -1
  140. package/src/Field/types.ts +60 -12
  141. package/src/Fieldset/Fieldset.tsx +55 -19
  142. package/src/Fieldset/README.md +2 -2
  143. package/src/Fieldset/__tests__/Fieldset.basic-rendering.test.tsx +9 -0
  144. package/src/Fieldset/types.ts +20 -2
  145. package/src/Input/Input.tsx +15 -10
  146. package/src/Input/README.md +2 -2
  147. package/src/Input/__tests__/Input.basic-rendering.test.tsx +5 -0
  148. package/src/Input/types.ts +19 -2
  149. package/src/InputGroup/InputGroup.tsx +11 -3
  150. package/src/InputGroup/__tests__/InputGroup.basic-rendering.test.tsx +14 -0
  151. package/src/InputGroup/types.ts +26 -5
  152. package/src/Listbox/Listbox.tsx +475 -0
  153. package/src/Listbox/ListboxContext.ts +48 -0
  154. package/src/Listbox/README.md +248 -0
  155. package/src/Listbox/__tests__/Listbox.active-descendant.test.tsx +128 -0
  156. package/src/Listbox/__tests__/Listbox.asChild.test.tsx +91 -0
  157. package/src/Listbox/__tests__/Listbox.basic-rendering.test.tsx +16 -0
  158. package/src/Listbox/__tests__/Listbox.controlled-state.test.tsx +114 -0
  159. package/src/Listbox/__tests__/Listbox.disabled.test.tsx +90 -0
  160. package/src/Listbox/__tests__/Listbox.fixtures.ts +27 -0
  161. package/src/Listbox/__tests__/Listbox.group-label.test.tsx +97 -0
  162. package/src/Listbox/__tests__/Listbox.group.test.tsx +45 -0
  163. package/src/Listbox/__tests__/Listbox.keyboard-edge-cases.test.tsx +144 -0
  164. package/src/Listbox/__tests__/Listbox.keyboard-interaction.test.tsx +112 -0
  165. package/src/Listbox/__tests__/Listbox.modifier-keys.test.tsx +86 -0
  166. package/src/Listbox/__tests__/Listbox.multi-select-keyboard.test.tsx +368 -0
  167. package/src/Listbox/__tests__/Listbox.multiple-mode.test.tsx +91 -0
  168. package/src/Listbox/__tests__/Listbox.mutation-hardening.test.tsx +480 -0
  169. package/src/Listbox/__tests__/Listbox.orientation.test.tsx +59 -0
  170. package/src/Listbox/__tests__/Listbox.reading-direction.test.tsx +65 -0
  171. package/src/Listbox/__tests__/Listbox.reorder.test.tsx +78 -0
  172. package/src/Listbox/__tests__/Listbox.selection-follows-focus.test.tsx +64 -0
  173. package/src/Listbox/__tests__/Listbox.strict-context.test.tsx +21 -0
  174. package/src/Listbox/__tests__/Listbox.typeahead.test.tsx +123 -0
  175. package/src/Listbox/__tests__/Listbox.uncontrolled-state.test.tsx +48 -0
  176. package/src/Listbox/constants.ts +2 -0
  177. package/src/Listbox/hooks/index.ts +1 -0
  178. package/src/Listbox/hooks/useListboxRoot.ts +407 -0
  179. package/src/Listbox/index.ts +3 -0
  180. package/src/Listbox/types.ts +238 -0
  181. package/src/MillerColumns/MillerColumns.tsx +186 -45
  182. package/src/MillerColumns/README.md +90 -20
  183. package/src/MillerColumns/__tests__/MillerColumns.aria.test.tsx +45 -0
  184. package/src/MillerColumns/__tests__/MillerColumns.auto-scroll.test.tsx +4 -2
  185. package/src/MillerColumns/__tests__/MillerColumns.basic-rendering.test.tsx +18 -3
  186. package/src/MillerColumns/__tests__/MillerColumns.data-attributes.test.tsx +48 -0
  187. package/src/MillerColumns/__tests__/MillerColumns.keyboard-interaction.test.tsx +35 -2
  188. package/src/MillerColumns/__tests__/MillerColumns.keyboard-selection-state.test.tsx +96 -0
  189. package/src/MillerColumns/__tests__/MillerColumns.preview-panel.test.tsx +29 -4
  190. package/src/MillerColumns/__tests__/MillerColumns.preview-visibility.test.tsx +79 -0
  191. package/src/MillerColumns/__tests__/MillerColumns.resize.test.tsx +189 -0
  192. package/src/MillerColumns/__tests__/MillerColumns.styled-parts.test.tsx +143 -0
  193. package/src/MillerColumns/__tests__/MillerColumns.typeahead.test.tsx +104 -0
  194. package/src/MillerColumns/constants.ts +2 -0
  195. package/src/MillerColumns/hooks/useMillerColumnsColumn.ts +5 -2
  196. package/src/MillerColumns/hooks/useMillerColumnsItem.ts +29 -7
  197. package/src/MillerColumns/hooks/useMillerColumnsResizeHandle.ts +80 -10
  198. package/src/MillerColumns/hooks/useMillerColumnsRoot.ts +0 -0
  199. package/src/MillerColumns/index.ts +4 -0
  200. package/src/MillerColumns/types.ts +136 -30
  201. package/src/MillerColumns/utils.ts +69 -3
  202. package/src/Modal/Modal.tsx +35 -8
  203. package/src/Modal/README.md +24 -15
  204. package/src/Modal/__tests__/Modal.focus-trap.test.tsx +162 -0
  205. package/src/Modal/hooks/useModalContent.ts +32 -0
  206. package/src/Modal/types.ts +110 -29
  207. package/src/NavigationMenu/NavigationMenu.tsx +890 -0
  208. package/src/NavigationMenu/NavigationMenuContext.ts +67 -0
  209. package/src/NavigationMenu/README.md +355 -0
  210. package/src/NavigationMenu/__tests__/NavigationMenu.anchor-positioning.test.tsx +132 -0
  211. package/src/NavigationMenu/__tests__/NavigationMenu.asChild.test.tsx +73 -0
  212. package/src/NavigationMenu/__tests__/NavigationMenu.basic-rendering.test.tsx +92 -0
  213. package/src/NavigationMenu/__tests__/NavigationMenu.contract.test.tsx +45 -0
  214. package/src/NavigationMenu/__tests__/NavigationMenu.controlled-state.test.tsx +134 -0
  215. package/src/NavigationMenu/__tests__/NavigationMenu.error-handling.test.tsx +62 -0
  216. package/src/NavigationMenu/__tests__/NavigationMenu.fixtures.tsx +79 -0
  217. package/src/NavigationMenu/__tests__/NavigationMenu.forceMount.test.tsx +52 -0
  218. package/src/NavigationMenu/__tests__/NavigationMenu.hover-interaction.test.tsx +254 -0
  219. package/src/NavigationMenu/__tests__/NavigationMenu.indicator.test.tsx +252 -0
  220. package/src/NavigationMenu/__tests__/NavigationMenu.keyboard-interaction.test.tsx +294 -0
  221. package/src/NavigationMenu/__tests__/NavigationMenu.keyboard-navigation.test.tsx +126 -0
  222. package/src/NavigationMenu/__tests__/NavigationMenu.link.test.tsx +139 -0
  223. package/src/NavigationMenu/__tests__/NavigationMenu.motion.test.tsx +568 -0
  224. package/src/NavigationMenu/__tests__/NavigationMenu.mouse-interaction.test.tsx +76 -0
  225. package/src/NavigationMenu/__tests__/NavigationMenu.uncontrolled-state.test.tsx +30 -0
  226. package/src/NavigationMenu/__tests__/NavigationMenu.viewport.test.tsx +113 -0
  227. package/src/NavigationMenu/constants.ts +5 -0
  228. package/src/NavigationMenu/hooks/index.ts +7 -0
  229. package/src/NavigationMenu/hooks/useNavigationMenuEntry.ts +33 -0
  230. package/src/NavigationMenu/hooks/useNavigationMenuIndicator.ts +70 -0
  231. package/src/NavigationMenu/hooks/useNavigationMenuLink.ts +72 -0
  232. package/src/NavigationMenu/hooks/useNavigationMenuRoot.ts +301 -0
  233. package/src/NavigationMenu/hooks/useNavigationMenuTopLevelEntry.ts +65 -0
  234. package/src/NavigationMenu/hooks/useNavigationMenuTrigger.ts +158 -0
  235. package/src/NavigationMenu/hooks/useNavigationMenuViewport.ts +101 -0
  236. package/src/NavigationMenu/index.ts +3 -0
  237. package/src/NavigationMenu/types.ts +259 -0
  238. package/src/NavigationMenu/utils.ts +89 -0
  239. package/src/Popover/Popover.tsx +427 -0
  240. package/src/Popover/PopoverContext.ts +13 -0
  241. package/src/Popover/README.md +106 -0
  242. package/src/Popover/__tests__/Popover.anchor.test.tsx +38 -0
  243. package/src/Popover/__tests__/Popover.asChild.test.tsx +160 -0
  244. package/src/Popover/__tests__/Popover.basic-rendering.test.tsx +106 -0
  245. package/src/Popover/__tests__/Popover.click-outside.test.tsx +149 -0
  246. package/src/Popover/__tests__/Popover.close.test.tsx +70 -0
  247. package/src/Popover/__tests__/Popover.content.test.tsx +58 -0
  248. package/src/Popover/__tests__/Popover.controlled-state.test.tsx +45 -0
  249. package/src/Popover/__tests__/Popover.error-handling.test.tsx +51 -0
  250. package/src/Popover/__tests__/Popover.focus.test.tsx +43 -0
  251. package/src/Popover/__tests__/Popover.keyboard-interaction.test.tsx +71 -0
  252. package/src/Popover/__tests__/Popover.mouse-interaction.test.tsx +69 -0
  253. package/src/Popover/__tests__/Popover.uncontrolled-state.test.tsx +44 -0
  254. package/src/Popover/hooks/index.ts +3 -0
  255. package/src/Popover/hooks/usePopoverContent.ts +98 -0
  256. package/src/Popover/hooks/usePopoverRoot.ts +84 -0
  257. package/src/Popover/hooks/usePopoverTrigger.ts +33 -0
  258. package/src/Popover/index.ts +2 -0
  259. package/src/Popover/types.ts +197 -0
  260. package/src/Portal/Portal.tsx +50 -10
  261. package/src/Portal/README.md +2 -2
  262. package/src/Portal/__tests__/Portal.basic-rendering.test.tsx +5 -0
  263. package/src/Portal/types.ts +5 -0
  264. package/src/Progress/Progress.tsx +33 -1
  265. package/src/Progress/README.md +1 -1
  266. package/src/Progress/__tests__/Progress.basic-rendering.test.tsx +10 -0
  267. package/src/Progress/__tests__/Progress.value.test.tsx +21 -0
  268. package/src/Progress/types.ts +20 -4
  269. package/src/Radio/Radio.tsx +10 -2
  270. package/src/Radio/__tests__/Radio.basic-rendering.test.tsx +14 -0
  271. package/src/Radio/hooks/useRadioRoot.ts +3 -0
  272. package/src/Radio/types.ts +20 -0
  273. package/src/RadioCard/RadioCard.tsx +11 -0
  274. package/src/RadioCard/__tests__/RadioCard.basic-rendering.test.tsx +8 -0
  275. package/src/RadioCard/__tests__/RadioCard.disabled-items.test.tsx +27 -0
  276. package/src/RadioCard/__tests__/RadioCard.keyboard-interaction.test.tsx +53 -0
  277. package/src/RadioCard/__tests__/RadioCard.ref-forwarding.test.tsx +54 -0
  278. package/src/RadioCard/__tests__/RadioCard.uncontrolled-state.test.tsx +21 -0
  279. package/src/RadioCard/hooks/useRadioCardRoot.ts +11 -0
  280. package/src/RadioCard/types.ts +95 -22
  281. package/src/RadioGroup/README.md +2 -2
  282. package/src/RadioGroup/RadioGroup.tsx +12 -1
  283. package/src/RadioGroup/__tests__/RadioGroup.basic-rendering.test.tsx +8 -0
  284. package/src/RadioGroup/__tests__/RadioGroup.disabled.test.tsx +27 -0
  285. package/src/RadioGroup/__tests__/RadioGroup.ref-forwarding.test.tsx +29 -0
  286. package/src/RadioGroup/__tests__/RadioGroup.roving-tabindex.test.tsx +31 -0
  287. package/src/RadioGroup/hooks/useRadioGroupRoot.ts +11 -0
  288. package/src/RadioGroup/types.ts +73 -9
  289. package/src/SegmentedControl/README.md +154 -0
  290. package/src/SegmentedControl/SegmentedControl.tsx +309 -0
  291. package/src/SegmentedControl/SegmentedControlContext.ts +28 -0
  292. package/src/SegmentedControl/__tests__/SegmentedControl.asChild.test.tsx +50 -0
  293. package/src/SegmentedControl/__tests__/SegmentedControl.basic-rendering.test.tsx +100 -0
  294. package/src/SegmentedControl/__tests__/SegmentedControl.controlled-state.test.tsx +107 -0
  295. package/src/SegmentedControl/__tests__/SegmentedControl.disabled-keydown-guards.test.tsx +62 -0
  296. package/src/SegmentedControl/__tests__/SegmentedControl.disabled.test.tsx +151 -0
  297. package/src/SegmentedControl/__tests__/SegmentedControl.error-handling.test.tsx +17 -0
  298. package/src/SegmentedControl/__tests__/SegmentedControl.keyboard-interaction.test.tsx +117 -0
  299. package/src/SegmentedControl/__tests__/SegmentedControl.orientation.test.tsx +54 -0
  300. package/src/SegmentedControl/__tests__/SegmentedControl.reading-direction.test.tsx +56 -0
  301. package/src/SegmentedControl/__tests__/SegmentedControl.ref-forwarding.test.tsx +45 -0
  302. package/src/SegmentedControl/__tests__/SegmentedControl.roving-tabindex.test.tsx +104 -0
  303. package/src/SegmentedControl/__tests__/SegmentedControl.uncontrolled-state.test.tsx +94 -0
  304. package/src/SegmentedControl/hooks/index.ts +2 -0
  305. package/src/SegmentedControl/hooks/useSegmentedControlContext.ts +1 -0
  306. package/src/SegmentedControl/hooks/useSegmentedControlRoot.ts +87 -0
  307. package/src/SegmentedControl/index.ts +2 -0
  308. package/src/SegmentedControl/types.ts +138 -0
  309. package/src/Select/README.md +122 -157
  310. package/src/Select/Select.tsx +596 -69
  311. package/src/Select/SelectContext.ts +38 -0
  312. package/src/Select/SelectItemIndicatorContext.ts +29 -0
  313. package/src/Select/__tests__/Select.asChild.test.tsx +2 -2
  314. package/src/Select/__tests__/Select.basic-rendering.test.tsx +22 -3
  315. package/src/Select/__tests__/Select.controlled-state.test.tsx +6 -6
  316. package/src/Select/__tests__/Select.data-attributes.test.tsx +4 -4
  317. package/src/Select/__tests__/Select.field-integration.test.tsx +11 -11
  318. package/src/Select/__tests__/Select.form.test.tsx +38 -0
  319. package/src/Select/__tests__/Select.group.test.tsx +6 -6
  320. package/src/Select/__tests__/Select.keyboard-edge.test.tsx +95 -0
  321. package/src/Select/__tests__/Select.keyboard.test.tsx +124 -0
  322. package/src/Select/__tests__/Select.mutation-hardening.test.tsx +317 -0
  323. package/src/Select/__tests__/Select.native-item.test.tsx +30 -0
  324. package/src/Select/__tests__/Select.open-close.test.tsx +57 -0
  325. package/src/Select/__tests__/Select.placeholder.test.tsx +7 -7
  326. package/src/Select/__tests__/Select.rich-asChild.test.tsx +60 -0
  327. package/src/Select/__tests__/Select.rich-mode.test.tsx +16 -0
  328. package/src/Select/__tests__/Select.selection.test.tsx +104 -0
  329. package/src/Select/__tests__/Select.separator.test.tsx +71 -0
  330. package/src/Select/__tests__/Select.strict-context.test.tsx +36 -0
  331. package/src/Select/__tests__/Select.typeahead.test.tsx +156 -0
  332. package/src/Select/__tests__/Select.value-mirror.test.tsx +240 -0
  333. package/src/Select/constants.ts +8 -0
  334. package/src/Select/hooks/index.ts +2 -0
  335. package/src/Select/hooks/useSelectContent.ts +149 -0
  336. package/src/Select/hooks/useSelectRoot.ts +149 -0
  337. package/src/Select/types.ts +199 -31
  338. package/src/SkipNav/README.md +3 -3
  339. package/src/SkipNav/SkipNav.tsx +72 -32
  340. package/src/SkipNav/__tests__/SkipNav.basic-rendering.test.tsx +6 -0
  341. package/src/SkipNav/types.ts +17 -9
  342. package/src/Slider/README.md +37 -7
  343. package/src/Slider/Slider.tsx +42 -4
  344. package/src/Slider/SliderContext.ts +28 -1
  345. package/src/Slider/types.ts +157 -9
  346. package/src/Slot/Slot.tsx +94 -19
  347. package/src/Slot/__tests__/Slot.test.tsx +112 -2
  348. package/src/Slot/composeEventHandlers.ts +26 -2
  349. package/src/Slot/types.ts +16 -0
  350. package/src/SplitButton/README.md +225 -0
  351. package/src/SplitButton/SplitButton.tsx +457 -0
  352. package/src/SplitButton/SplitButtonContext.ts +26 -0
  353. package/src/SplitButton/__tests__/SplitButton.accessibility.test.tsx +55 -0
  354. package/src/SplitButton/__tests__/SplitButton.asChild.test.tsx +51 -0
  355. package/src/SplitButton/__tests__/SplitButton.basic-rendering.test.tsx +57 -0
  356. package/src/SplitButton/__tests__/SplitButton.data-attributes.test.tsx +28 -0
  357. package/src/SplitButton/__tests__/SplitButton.disabled.test.tsx +79 -0
  358. package/src/SplitButton/__tests__/SplitButton.error-handling.test.tsx +29 -0
  359. package/src/SplitButton/__tests__/SplitButton.keyboard-interaction.test.tsx +56 -0
  360. package/src/SplitButton/__tests__/SplitButton.state-modes.test.tsx +68 -0
  361. package/src/SplitButton/index.ts +2 -0
  362. package/src/SplitButton/types.ts +186 -0
  363. package/src/Status/Status.tsx +36 -15
  364. package/src/Status/__tests__/Status.test.tsx +5 -0
  365. package/src/Status/types.ts +12 -1
  366. package/src/Switch/README.md +1 -1
  367. package/src/Switch/Switch.tsx +17 -4
  368. package/src/Switch/__tests__/Switch.basic-rendering.test.tsx +7 -0
  369. package/src/Switch/hooks/useSwitchRoot.ts +3 -0
  370. package/src/Switch/types.ts +49 -22
  371. package/src/Table/README.md +110 -8
  372. package/src/Table/Table.tsx +246 -16
  373. package/src/Table/TableExpandableContext.ts +7 -0
  374. package/src/Table/__tests__/Table.DetailRow.test.tsx +83 -0
  375. package/src/Table/__tests__/Table.ExpandTrigger.test.tsx +102 -0
  376. package/src/Table/__tests__/Table.Expandable.test.tsx +121 -0
  377. package/src/Table/types.ts +191 -17
  378. package/src/Tabs/README.md +4 -4
  379. package/src/Tabs/Tabs.tsx +13 -4
  380. package/src/Tabs/__tests__/Tabs.activation-mode.test.tsx +58 -0
  381. package/src/Tabs/__tests__/Tabs.basic-rendering.test.tsx +15 -0
  382. package/src/Tabs/__tests__/Tabs.change-event-callbacks.test.tsx +24 -0
  383. package/src/Tabs/__tests__/Tabs.disabled-tabs.test.tsx +36 -0
  384. package/src/Tabs/__tests__/Tabs.lazy-mount.test.tsx +17 -0
  385. package/src/Tabs/hooks/useTabsContent.ts +5 -0
  386. package/src/Tabs/hooks/useTabsRoot.ts +29 -5
  387. package/src/Tabs/hooks/useTabsTrigger.ts +7 -2
  388. package/src/Tabs/types.ts +29 -4
  389. package/src/Textarea/Textarea.tsx +9 -1
  390. package/src/Textarea/__tests__/Textarea.basic-rendering.test.tsx +5 -0
  391. package/src/Textarea/types.ts +20 -3
  392. package/src/Toggle/Toggle.tsx +51 -13
  393. package/src/Toggle/__tests__/Toggle.basic-rendering.test.tsx +5 -0
  394. package/src/Toggle/types.ts +54 -4
  395. package/src/ToggleGroup/README.md +4 -4
  396. package/src/ToggleGroup/ToggleGroup.tsx +14 -1
  397. package/src/ToggleGroup/__tests__/ToggleGroup.asChild.test.tsx +20 -0
  398. package/src/ToggleGroup/__tests__/ToggleGroup.basic-rendering.test.tsx +9 -0
  399. package/src/ToggleGroup/__tests__/ToggleGroup.dynamic-items.test.tsx +70 -0
  400. package/src/ToggleGroup/__tests__/ToggleGroup.error-handling.test.tsx +23 -0
  401. package/src/ToggleGroup/__tests__/ToggleGroup.keyboard-interaction.test.tsx +23 -1
  402. package/src/ToggleGroup/__tests__/ToggleGroup.ref-forwarding.test.tsx +58 -0
  403. package/src/ToggleGroup/hooks/useToggleGroupRoot.ts +21 -1
  404. package/src/ToggleGroup/types.ts +55 -2
  405. package/src/Tooltip/README.md +7 -7
  406. package/src/Tooltip/Tooltip.tsx +100 -22
  407. package/src/Tooltip/__tests__/Tooltip.basic-rendering.test.tsx +50 -0
  408. package/src/Tooltip/__tests__/Tooltip.error-handling.test.tsx +37 -0
  409. package/src/Tooltip/__tests__/Tooltip.escape-hatches.test.tsx +73 -0
  410. package/src/Tooltip/__tests__/Tooltip.keyboard-interaction.test.tsx +134 -0
  411. package/src/Tooltip/__tests__/Tooltip.skip-delay.test.tsx +216 -0
  412. package/src/Tooltip/__tests__/Tooltip.timer-coordination.test.tsx +223 -0
  413. package/src/Tooltip/hooks/__tests__/useTooltipProvider.test.tsx +40 -0
  414. package/src/Tooltip/hooks/useTooltipContent.ts +3 -0
  415. package/src/Tooltip/hooks/useTooltipProvider.ts +46 -8
  416. package/src/Tooltip/hooks/useTooltipRoot.ts +30 -6
  417. package/src/Tooltip/types.ts +89 -8
  418. package/src/Tree/README.md +69 -5
  419. package/src/Tree/Tree.tsx +188 -34
  420. package/src/Tree/__tests__/Tree.styled-wrapper.test.tsx +159 -0
  421. package/src/Tree/hooks/useTreePath.ts +33 -1
  422. package/src/Tree/index.ts +6 -1
  423. package/src/Tree/types.ts +54 -12
  424. package/src/Tree/utils.ts +46 -4
  425. package/src/VisuallyHidden/VisuallyHidden.tsx +33 -8
  426. package/src/VisuallyHidden/__tests__/VisuallyHidden.test.tsx +15 -1
  427. package/src/VisuallyHidden/types.ts +11 -1
  428. package/src/hooks/index.ts +1 -0
  429. package/src/hooks/useTypeahead.ts +95 -0
  430. package/src/index.ts +10 -0
  431. package/src/test/intersectionObserverPolyfill.ts +7 -0
  432. package/src/test/resizeObserverPolyfill.ts +80 -0
  433. package/src/useLocalStorage/__tests__/useLocalStorage.reading.test.tsx +31 -0
  434. package/src/useLocalStorage/__tests__/useLocalStorage.resilience.test.tsx +97 -0
  435. package/src/useLocalStorage/__tests__/useLocalStorage.ssr.test.tsx +20 -0
  436. package/src/useLocalStorage/__tests__/useLocalStorage.writing.test.tsx +72 -0
  437. package/src/useLocalStorage/index.ts +1 -0
  438. package/src/useLocalStorage/useLocalStorage.ts +140 -0
  439. package/src/useMediaQuery/README.md +66 -0
  440. package/src/useMediaQuery/__tests__/useMediaQuery.matching.test.tsx +42 -0
  441. package/src/useMediaQuery/__tests__/useMediaQuery.ssr.test.tsx +19 -0
  442. package/src/useMediaQuery/__tests__/useMediaQuery.subscription.test.tsx +89 -0
  443. package/src/useMediaQuery/index.ts +1 -0
  444. package/src/useMediaQuery/useMediaQuery.ts +49 -0
  445. package/src/usePagination/README.md +170 -0
  446. package/src/usePagination/__tests__/usePagination.navigation.test.tsx +109 -0
  447. package/src/usePagination/__tests__/usePagination.page-count.test.tsx +56 -0
  448. package/src/usePagination/__tests__/usePagination.page-state.test.tsx +87 -0
  449. package/src/usePagination/__tests__/usePagination.range.test.tsx +82 -0
  450. package/src/usePagination/__tests__/usePagination.slice.test.tsx +59 -0
  451. package/src/usePagination/__tests__/usePagination.windowing.test.tsx +68 -0
  452. package/src/usePagination/index.ts +1 -0
  453. package/src/usePagination/usePagination.ts +292 -0
  454. package/src/utils/focusable.ts +15 -0
  455. package/src/utils/getKeyToActionMap.ts +1 -1
  456. package/src/utils/index.ts +1 -0
@@ -1,7 +1,27 @@
1
1
  import { fireEvent, render, screen } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
2
3
 
3
4
  import { MillerColumns } from "../MillerColumns";
4
5
 
6
+ function Resizable({
7
+ dir,
8
+ ...handleProps
9
+ }: {
10
+ dir?: "ltr" | "rtl";
11
+ minWidth?: number;
12
+ maxWidth?: number;
13
+ step?: number;
14
+ }) {
15
+ return (
16
+ <MillerColumns.Root dir={dir}>
17
+ <MillerColumns.Column>
18
+ <MillerColumns.ResizeHandle aria-label="Resize" {...handleProps} />
19
+ <MillerColumns.Item value="a">A</MillerColumns.Item>
20
+ </MillerColumns.Column>
21
+ </MillerColumns.Root>
22
+ );
23
+ }
24
+
5
25
  describe("MillerColumns — resize handle", () => {
6
26
  it("renders a vertical separator inside a column", () => {
7
27
  render(
@@ -19,6 +39,175 @@ describe("MillerColumns — resize handle", () => {
19
39
  expect(handle).toHaveAttribute("data-miller-columns-resize-handle");
20
40
  });
21
41
 
42
+ it("is a focusable splitter carrying its value range", () => {
43
+ render(
44
+ <MillerColumns.Root>
45
+ <MillerColumns.Column>
46
+ <MillerColumns.ResizeHandle
47
+ aria-label="Resize"
48
+ minWidth={120}
49
+ maxWidth={400}
50
+ />
51
+ <MillerColumns.Item value="a">A</MillerColumns.Item>
52
+ </MillerColumns.Column>
53
+ </MillerColumns.Root>,
54
+ );
55
+
56
+ const handle = screen.getByRole("separator", { name: "Resize" });
57
+
58
+ expect(handle).toHaveAttribute("tabindex", "0");
59
+ expect(handle).toHaveAttribute("aria-valuemin", "120");
60
+ expect(handle).toHaveAttribute("aria-valuemax", "400");
61
+ expect(handle).toHaveAttribute("aria-valuenow");
62
+ });
63
+
64
+ it("steps the column width with the arrow keys", async () => {
65
+ const user = userEvent.setup();
66
+
67
+ render(<Resizable minWidth={100} maxWidth={400} />);
68
+
69
+ const handle = screen.getByRole("separator");
70
+ const column = screen.getByRole("group");
71
+
72
+ // Seed a known width so stepping is measured from something.
73
+ fireEvent.pointerDown(handle, { clientX: 0 });
74
+ fireEvent.pointerMove(window, { clientX: 200 });
75
+ fireEvent.pointerUp(window);
76
+
77
+ handle.focus();
78
+ await user.keyboard("{ArrowRight}");
79
+ expect(column).toHaveStyle({ width: "210px" });
80
+
81
+ await user.keyboard("{ArrowLeft}{ArrowLeft}");
82
+ expect(column).toHaveStyle({ width: "190px" });
83
+ });
84
+
85
+ it("honours a custom step", async () => {
86
+ const user = userEvent.setup();
87
+
88
+ render(<Resizable minWidth={100} maxWidth={400} step={25} />);
89
+
90
+ const handle = screen.getByRole("separator");
91
+ const column = screen.getByRole("group");
92
+
93
+ fireEvent.pointerDown(handle, { clientX: 0 });
94
+ fireEvent.pointerMove(window, { clientX: 200 });
95
+ fireEvent.pointerUp(window);
96
+
97
+ handle.focus();
98
+ await user.keyboard("{ArrowRight}");
99
+
100
+ expect(column).toHaveStyle({ width: "225px" });
101
+ });
102
+
103
+ it("clamps stepping to the min and max width", async () => {
104
+ const user = userEvent.setup();
105
+
106
+ render(<Resizable minWidth={100} maxWidth={220} />);
107
+
108
+ const handle = screen.getByRole("separator");
109
+ const column = screen.getByRole("group");
110
+
111
+ fireEvent.pointerDown(handle, { clientX: 0 });
112
+ fireEvent.pointerMove(window, { clientX: 215 });
113
+ fireEvent.pointerUp(window);
114
+
115
+ handle.focus();
116
+ await user.keyboard("{ArrowRight}{ArrowRight}");
117
+
118
+ expect(column).toHaveStyle({ width: "220px" });
119
+ expect(handle).toHaveAttribute("aria-valuenow", "220");
120
+ });
121
+
122
+ it("jumps to the min and max width with Home and End", async () => {
123
+ const user = userEvent.setup();
124
+
125
+ render(<Resizable minWidth={100} maxWidth={400} />);
126
+
127
+ const handle = screen.getByRole("separator");
128
+ const column = screen.getByRole("group");
129
+
130
+ handle.focus();
131
+ await user.keyboard("{End}");
132
+ expect(column).toHaveStyle({ width: "400px" });
133
+
134
+ await user.keyboard("{Home}");
135
+ expect(column).toHaveStyle({ width: "100px" });
136
+ });
137
+
138
+ it("mirrors the stepping arrows under rtl", async () => {
139
+ const user = userEvent.setup();
140
+
141
+ render(<Resizable dir="rtl" minWidth={100} maxWidth={400} />);
142
+
143
+ const handle = screen.getByRole("separator");
144
+ const column = screen.getByRole("group");
145
+
146
+ fireEvent.pointerDown(handle, { clientX: 0 });
147
+ fireEvent.pointerMove(window, { clientX: 200 });
148
+ fireEvent.pointerUp(window);
149
+
150
+ handle.focus();
151
+ // The column grows leftwards in rtl, so ArrowLeft widens it.
152
+ await user.keyboard("{ArrowLeft}");
153
+
154
+ expect(column).toHaveStyle({ width: "210px" });
155
+ });
156
+
157
+ it("leaves the width alone for keys it does not handle", async () => {
158
+ const user = userEvent.setup();
159
+
160
+ render(<Resizable minWidth={100} maxWidth={400} />);
161
+
162
+ const handle = screen.getByRole("separator");
163
+ const column = screen.getByRole("group");
164
+
165
+ fireEvent.pointerDown(handle, { clientX: 0 });
166
+ fireEvent.pointerMove(window, { clientX: 200 });
167
+ fireEvent.pointerUp(window);
168
+
169
+ handle.focus();
170
+ await user.keyboard("{ArrowUp}{ArrowDown}a");
171
+
172
+ expect(column).toHaveStyle({ width: "200px" });
173
+ });
174
+
175
+ it("ignores End when no maxWidth bounds the column", async () => {
176
+ const user = userEvent.setup();
177
+
178
+ // Without a maxWidth there is no largest width to jump to, so End
179
+ // must not resize to Infinity.
180
+ render(<Resizable minWidth={100} />);
181
+
182
+ const handle = screen.getByRole("separator");
183
+ const column = screen.getByRole("group");
184
+
185
+ fireEvent.pointerDown(handle, { clientX: 0 });
186
+ fireEvent.pointerMove(window, { clientX: 200 });
187
+ fireEvent.pointerUp(window);
188
+
189
+ handle.focus();
190
+ await user.keyboard("{End}");
191
+
192
+ expect(handle).toHaveAttribute("aria-valuenow", "200");
193
+ expect(column).toHaveStyle({ width: "200px" });
194
+ expect(handle).not.toHaveAttribute("aria-valuemax");
195
+ });
196
+
197
+ it("clamps a pointer drag to the min and max width", () => {
198
+ render(<Resizable minWidth={120} maxWidth={260} />);
199
+
200
+ const handle = screen.getByRole("separator");
201
+ const column = screen.getByRole("group");
202
+
203
+ fireEvent.pointerDown(handle, { clientX: 0 });
204
+ fireEvent.pointerMove(window, { clientX: 1000 });
205
+ expect(column).toHaveStyle({ width: "260px" });
206
+
207
+ fireEvent.pointerMove(window, { clientX: -1000 });
208
+ expect(column).toHaveStyle({ width: "120px" });
209
+ });
210
+
22
211
  it("throws when ResizeHandle is rendered outside Column", () => {
23
212
  expect(() =>
24
213
  render(
@@ -0,0 +1,143 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { Children, type ComponentProps, type ReactNode } from "react";
4
+
5
+ import { MillerColumns } from "../MillerColumns";
6
+ import { MILLER_COLUMNS_PART } from "../utils";
7
+
8
+ /**
9
+ * A stand-in for a styling layer's wrapper — exactly what the registry
10
+ * `miller-columns` surface renders. `Item` inspects its children *before*
11
+ * rendering them, so it sees this component, not the `MillerColumns.Column`
12
+ * it returns.
13
+ */
14
+ function StyledColumn(props: ComponentProps<typeof MillerColumns.Column>) {
15
+ return <MillerColumns.Column {...props} />;
16
+ }
17
+ StyledColumn[MILLER_COLUMNS_PART] = "column" as const;
18
+
19
+ function Tree() {
20
+ return (
21
+ <MillerColumns.Root>
22
+ <MillerColumns.Column>
23
+ <MillerColumns.Item value="fruit">
24
+ Fruit
25
+ <StyledColumn>
26
+ <MillerColumns.Item value="apple">Apple</MillerColumns.Item>
27
+ </StyledColumn>
28
+ </MillerColumns.Item>
29
+ <MillerColumns.Item value="veg">
30
+ Veg
31
+ <StyledColumn>
32
+ <MillerColumns.Item value="carrot">Carrot</MillerColumns.Item>
33
+ </StyledColumn>
34
+ </MillerColumns.Item>
35
+ </MillerColumns.Column>
36
+ </MillerColumns.Root>
37
+ );
38
+ }
39
+
40
+ describe("MillerColumns — styled wrappers standing in for parts", () => {
41
+ it("keeps a wrapped child column closed until its branch is selected", async () => {
42
+ const user = userEvent.setup();
43
+
44
+ render(<Tree />);
45
+
46
+ // Nothing is selected, so the child column must not be mounted at all.
47
+ expect(
48
+ screen.queryByRole("treeitem", { name: "Apple" }),
49
+ ).not.toBeInTheDocument();
50
+
51
+ await user.click(screen.getByRole("treeitem", { name: "Fruit" }));
52
+
53
+ expect(screen.getByRole("treeitem", { name: "Apple" })).toBeInTheDocument();
54
+ });
55
+
56
+ it("opens only the selected branch's column, not every sibling's at once", async () => {
57
+ const user = userEvent.setup();
58
+
59
+ render(<Tree />);
60
+
61
+ await user.click(screen.getByRole("treeitem", { name: "Fruit" }));
62
+
63
+ // Selecting Fruit opens a slot at depth 1. Every unrecognised wrapped
64
+ // column projects into that slot on sight, so Veg's children ride along.
65
+ expect(screen.getByRole("treeitem", { name: "Apple" })).toBeInTheDocument();
66
+ expect(
67
+ screen.queryByRole("treeitem", { name: "Carrot" }),
68
+ ).not.toBeInTheDocument();
69
+ });
70
+
71
+ it("treats a wrapped child column as the branch's column, not cell content", () => {
72
+ render(<Tree />);
73
+
74
+ // A branch is a branch because it nests a column — even a wrapped one.
75
+ expect(screen.getByRole("treeitem", { name: "Fruit" })).toHaveAttribute(
76
+ "aria-expanded",
77
+ "false",
78
+ );
79
+ expect(screen.getByRole("treeitem", { name: "Fruit" })).toHaveAttribute(
80
+ "data-has-children",
81
+ );
82
+ });
83
+ });
84
+
85
+ /**
86
+ * The registry surface wraps BOTH parts, and its Item maps over `children` to
87
+ * wrap bare text in a label span before handing them to the headless. That map
88
+ * sits between the consumer's JSX and `partitionItemChildren`, so it is the
89
+ * thing most likely to lose a nested column.
90
+ */
91
+ function wrapRowTextNodes(children: ReactNode): ReactNode {
92
+ const mapped = Children.map(children, (child) =>
93
+ typeof child === "string" || typeof child === "number" ? (
94
+ <span className="label">{child}</span>
95
+ ) : (
96
+ child
97
+ ),
98
+ );
99
+ return Array.isArray(mapped) && mapped.length === 1 ? mapped[0] : mapped;
100
+ }
101
+
102
+ function StyledItem({
103
+ children,
104
+ ...props
105
+ }: ComponentProps<typeof MillerColumns.Item>) {
106
+ return (
107
+ <MillerColumns.Item {...props}>
108
+ {wrapRowTextNodes(children)}
109
+ </MillerColumns.Item>
110
+ );
111
+ }
112
+
113
+ describe("MillerColumns — the registry's own composition", () => {
114
+ it("opens only the selected branch when Item and Column are both wrapped", async () => {
115
+ const user = userEvent.setup();
116
+
117
+ render(
118
+ <MillerColumns.Root>
119
+ <MillerColumns.Column>
120
+ <StyledItem value="fruit">
121
+ Fruit
122
+ <StyledColumn>
123
+ <StyledItem value="apple">Apple</StyledItem>
124
+ </StyledColumn>
125
+ </StyledItem>
126
+ <StyledItem value="veg">
127
+ Veg
128
+ <StyledColumn>
129
+ <StyledItem value="carrot">Carrot</StyledItem>
130
+ </StyledColumn>
131
+ </StyledItem>
132
+ </MillerColumns.Column>
133
+ </MillerColumns.Root>,
134
+ );
135
+
136
+ await user.click(screen.getByRole("treeitem", { name: "Fruit" }));
137
+
138
+ expect(screen.getByRole("treeitem", { name: "Apple" })).toBeInTheDocument();
139
+ expect(
140
+ screen.queryByRole("treeitem", { name: "Carrot" }),
141
+ ).not.toBeInTheDocument();
142
+ });
143
+ });
@@ -0,0 +1,104 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+
4
+ import { TYPEAHEAD_RESET_MS } from "../constants";
5
+ import { MillerColumns } from "../MillerColumns";
6
+
7
+ function Tree() {
8
+ return (
9
+ <MillerColumns.Root>
10
+ <MillerColumns.Column>
11
+ <MillerColumns.Item value="apricot">Apricot</MillerColumns.Item>
12
+ <MillerColumns.Item value="banana">Banana</MillerColumns.Item>
13
+ <MillerColumns.Item value="blueberry">Blueberry</MillerColumns.Item>
14
+ <MillerColumns.Item value="cherry">
15
+ Cherry
16
+ <MillerColumns.Column>
17
+ <MillerColumns.Item value="morello">Morello</MillerColumns.Item>
18
+ <MillerColumns.Item value="maraschino">
19
+ Maraschino
20
+ </MillerColumns.Item>
21
+ </MillerColumns.Column>
22
+ </MillerColumns.Item>
23
+ </MillerColumns.Column>
24
+ </MillerColumns.Root>
25
+ );
26
+ }
27
+
28
+ describe("MillerColumns — typeahead", () => {
29
+ it("focuses the first item whose label starts with the typed character", async () => {
30
+ const user = userEvent.setup();
31
+
32
+ render(<Tree />);
33
+ screen.getByRole("treeitem", { name: "Apricot" }).focus();
34
+
35
+ await user.keyboard("c");
36
+
37
+ expect(screen.getByRole("treeitem", { name: "Cherry" })).toHaveFocus();
38
+ });
39
+
40
+ it("accumulates characters into a prefix query", async () => {
41
+ const user = userEvent.setup();
42
+
43
+ render(<Tree />);
44
+ screen.getByRole("treeitem", { name: "Apricot" }).focus();
45
+
46
+ await user.keyboard("bl");
47
+
48
+ expect(screen.getByRole("treeitem", { name: "Blueberry" })).toHaveFocus();
49
+ });
50
+
51
+ it("cycles through matches when the same character repeats", async () => {
52
+ const user = userEvent.setup();
53
+
54
+ render(<Tree />);
55
+ screen.getByRole("treeitem", { name: "Apricot" }).focus();
56
+
57
+ await user.keyboard("b");
58
+ expect(screen.getByRole("treeitem", { name: "Banana" })).toHaveFocus();
59
+
60
+ await user.keyboard("b");
61
+ expect(screen.getByRole("treeitem", { name: "Blueberry" })).toHaveFocus();
62
+ });
63
+
64
+ it("searches only within the focused column", async () => {
65
+ const user = userEvent.setup();
66
+
67
+ render(<Tree />);
68
+ await user.click(screen.getByRole("treeitem", { name: "Cherry" }));
69
+ screen.getByRole("treeitem", { name: "Morello" }).focus();
70
+
71
+ // "B" matches Banana and Blueberry, but both are in the parent column.
72
+ await user.keyboard("b");
73
+ expect(screen.getByRole("treeitem", { name: "Morello" })).toHaveFocus();
74
+
75
+ // Let the query lapse — otherwise the next keys extend it to "bma".
76
+ await new Promise((resolve) =>
77
+ setTimeout(resolve, TYPEAHEAD_RESET_MS + 100),
78
+ );
79
+
80
+ await user.keyboard("ma");
81
+ expect(screen.getByRole("treeitem", { name: "Maraschino" })).toHaveFocus();
82
+ });
83
+
84
+ it("skips disabled items", async () => {
85
+ const user = userEvent.setup();
86
+
87
+ render(
88
+ <MillerColumns.Root>
89
+ <MillerColumns.Column>
90
+ <MillerColumns.Item value="a">Apricot</MillerColumns.Item>
91
+ <MillerColumns.Item value="b1" disabled>
92
+ Banana
93
+ </MillerColumns.Item>
94
+ <MillerColumns.Item value="b2">Blueberry</MillerColumns.Item>
95
+ </MillerColumns.Column>
96
+ </MillerColumns.Root>,
97
+ );
98
+ screen.getByRole("treeitem", { name: "Apricot" }).focus();
99
+
100
+ await user.keyboard("b");
101
+
102
+ expect(screen.getByRole("treeitem", { name: "Blueberry" })).toHaveFocus();
103
+ });
104
+ });
@@ -0,0 +1,2 @@
1
+ /** Milliseconds of typing inactivity after which the typeahead query resets. */
2
+ export const TYPEAHEAD_RESET_MS = 500;
@@ -7,17 +7,20 @@ import type { MillerColumnsColumnContextValue } from "../types";
7
7
  import { useMillerColumnsContext } from "./useMillerColumnsContext";
8
8
 
9
9
  export function useMillerColumnsColumn() {
10
- const { slots, columnWidths } = useMillerColumnsContext();
10
+ const { slots, columnWidths, getColumnItems } = useMillerColumnsContext();
11
11
  const parentColumn = useContext(MillerColumnsColumnContext);
12
12
  const depth = parentColumn ? parentColumn.depth + 1 : 0;
13
13
 
14
14
  const slot = slots.get(depth) ?? null;
15
15
  const width = columnWidths.get(depth);
16
+ // Selecting a childless branch opens a column with nothing in it; the
17
+ // styling layer needs to know so it can say so rather than show a void.
18
+ const empty = getColumnItems(depth).length === 0;
16
19
 
17
20
  const columnContextValue = useMemo<MillerColumnsColumnContextValue>(
18
21
  () => ({ depth }),
19
22
  [depth],
20
23
  );
21
24
 
22
- return { slot, depth, width, columnContextValue };
25
+ return { slot, depth, width, empty, columnContextValue };
23
26
  }
@@ -2,7 +2,7 @@ import {
2
2
  FocusEvent,
3
3
  KeyboardEvent,
4
4
  MouseEvent,
5
- useEffect,
5
+ useLayoutEffect,
6
6
  useMemo,
7
7
  useRef,
8
8
  } from "react";
@@ -31,6 +31,7 @@ export function useMillerColumnsItem(
31
31
  hasChildren: boolean,
32
32
  ) {
33
33
  const {
34
+ dir,
34
35
  activePath,
35
36
  select,
36
37
  registerItem,
@@ -40,18 +41,26 @@ export function useMillerColumnsItem(
40
41
  requestColumnFocus,
41
42
  activeItem,
42
43
  setActiveItem,
44
+ handleTypeahead,
43
45
  } = useMillerColumnsContext();
44
46
  const { depth } = useMillerColumnsColumnContext();
45
47
 
46
48
  const selected = activePath[depth] === value;
49
+ // The deepest selected item — the one the user actually landed on, as
50
+ // opposed to the ancestors its path passes through. Nothing else in the
51
+ // DOM distinguishes the two, and styling them alike loses the depth cue.
52
+ const terminal = selected && depth === activePath.length - 1;
47
53
 
48
54
  const itemRef = useRef<HTMLDivElement>(null);
49
55
  const composedRef = ref ? composeRefs(itemRef, ref) : itemRef;
50
56
 
51
- useEffect(() => {
52
- registerItem(depth, value, itemRef.current, disabled);
53
- return () => registerItem(depth, value, null, disabled);
54
- }, [depth, value, disabled, registerItem]);
57
+ // Registration is a *layout* effect so the resulting state update lands
58
+ // before paint. As a passive effect, a populated column would paint one
59
+ // frame with `data-empty` before its items registered.
60
+ useLayoutEffect(() => {
61
+ registerItem(depth, value, itemRef.current, disabled, hasChildren);
62
+ return () => registerItem(depth, value, null, disabled, hasChildren);
63
+ }, [depth, value, disabled, hasChildren, registerItem]);
55
64
 
56
65
  const enabledValues = getColumnItems(depth)
57
66
  .filter((item) => !item.disabled)
@@ -100,10 +109,15 @@ export function useMillerColumnsItem(
100
109
  : depth === 0 &&
101
110
  getColumnItems(0).find((item) => !item.disabled)?.value === value;
102
111
 
112
+ // Columns run left-to-right under ltr and right-to-left under rtl, so
113
+ // the key that steps *into* a child column mirrors with the direction.
114
+ const stepInKey = dir === "rtl" ? "ArrowLeft" : "ArrowRight";
115
+ const stepOutKey = dir === "rtl" ? "ArrowRight" : "ArrowLeft";
116
+
103
117
  function handleKeyDown(event: KeyboardEvent<HTMLDivElement>) {
104
118
  onKeyDown?.(event);
105
119
 
106
- if (event.key === "ArrowRight") {
120
+ if (event.key === stepInKey) {
107
121
  if (!hasChildren || disabled) {
108
122
  return;
109
123
  }
@@ -117,7 +131,7 @@ export function useMillerColumnsItem(
117
131
  return;
118
132
  }
119
133
 
120
- if (event.key === "ArrowLeft") {
134
+ if (event.key === stepOutKey) {
121
135
  if (depth === 0) {
122
136
  return;
123
137
  }
@@ -129,6 +143,13 @@ export function useMillerColumnsItem(
129
143
  }
130
144
 
131
145
  rovingKeyDown(event);
146
+
147
+ // The keymap gets first refusal — it calls preventDefault on every key
148
+ // it consumes, so only a still-unhandled printable character reaches
149
+ // typeahead (Space activates, and never falls through).
150
+ if (!event.defaultPrevented) {
151
+ handleTypeahead(event);
152
+ }
132
153
  }
133
154
 
134
155
  const itemProps = {
@@ -139,6 +160,7 @@ export function useMillerColumnsItem(
139
160
  "aria-level": depth + 1,
140
161
  ...(hasChildren ? { "aria-expanded": selected } : {}),
141
162
  "data-state": selected ? "selected" : "unselected",
163
+ ...(terminal ? { "data-terminal": "" } : {}),
142
164
  "data-depth": depth,
143
165
  ...(hasChildren ? { "data-has-children": "" } : {}),
144
166
  ...(disabled ? { "aria-disabled": true, "data-disabled": "" } : {}),