@primitiv-ui/react 0.1.29 → 0.1.31

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 (216) hide show
  1. package/README.md +15 -2
  2. package/package.json +1 -1
  3. package/src/AccessibleIcon/AccessibleIcon.tsx +1 -1
  4. package/src/Accordion/Accordion.tsx +7 -7
  5. package/src/Accordion/README.md +2 -2
  6. package/src/Avatar/__tests__/Avatar.fallback.test.tsx +3 -3
  7. package/src/Breadcrumb/Breadcrumb.tsx +7 -7
  8. package/src/Breadcrumb/README.md +2 -2
  9. package/src/Breadcrumb/__tests__/Breadcrumb.ellipsis.test.tsx +1 -1
  10. package/src/Breadcrumb/types.ts +1 -1
  11. package/src/Button/__tests__/Button.contract.test.tsx +1 -1
  12. package/src/Carousel/Carousel.tsx +6 -6
  13. package/src/Carousel/README.md +22 -22
  14. package/src/Carousel/__tests__/Carousel.infinite.test.tsx +5 -5
  15. package/src/Carousel/__tests__/Carousel.intersection-observer.test.tsx +2 -2
  16. package/src/Carousel/__tests__/Carousel.slides-per-move.test.tsx +1 -1
  17. package/src/Carousel/loopEngine.ts +1 -1
  18. package/src/Carousel/types.ts +1 -1
  19. package/src/Checkbox/Checkbox.tsx +2 -2
  20. package/src/CheckboxCard/CheckboxCard.tsx +1 -1
  21. package/src/CheckboxCard/README.md +1 -1
  22. package/src/Collapsible/README.md +2 -2
  23. package/src/Combobox/Combobox.tsx +508 -0
  24. package/src/Combobox/ComboboxContext.ts +23 -0
  25. package/src/Combobox/README.md +170 -0
  26. package/src/Combobox/__tests__/Combobox.asChild.test.tsx +62 -0
  27. package/src/Combobox/__tests__/Combobox.basic-rendering.test.tsx +16 -0
  28. package/src/Combobox/__tests__/Combobox.content.test.tsx +36 -0
  29. package/src/Combobox/__tests__/Combobox.controlled-state.test.tsx +88 -0
  30. package/src/Combobox/__tests__/Combobox.empty.test.tsx +42 -0
  31. package/src/Combobox/__tests__/Combobox.ids.test.tsx +66 -0
  32. package/src/Combobox/__tests__/Combobox.item.test.tsx +76 -0
  33. package/src/Combobox/__tests__/Combobox.keyboard-interaction.test.tsx +196 -0
  34. package/src/Combobox/__tests__/Combobox.light-dismiss.test.tsx +101 -0
  35. package/src/Combobox/__tests__/Combobox.query.test.tsx +105 -0
  36. package/src/Combobox/hooks/index.ts +1 -0
  37. package/src/Combobox/hooks/useComboboxRoot.ts +2 -0
  38. package/src/Combobox/index.ts +3 -0
  39. package/src/Combobox/types.ts +133 -0
  40. package/src/ContextMenu/ContextMenu.tsx +3 -3
  41. package/src/ContextMenu/README.md +1 -1
  42. package/src/DirectionProvider/DirectionProvider.tsx +5 -5
  43. package/src/DirectionProvider/README.md +4 -4
  44. package/src/Divider/__tests__/Divider.contract.test.tsx +1 -1
  45. package/src/Drawer/Drawer.tsx +5 -5
  46. package/src/Drawer/README.md +2 -2
  47. package/src/Dropdown/Dropdown.tsx +2 -2
  48. package/src/Dropdown/README.md +7 -5
  49. package/src/EmptyState/README.md +2 -2
  50. package/src/Field/README.md +1 -1
  51. package/src/Field/hooks/useFieldProps.ts +1 -1
  52. package/src/Fieldset/Fieldset.tsx +3 -3
  53. package/src/Fieldset/README.md +2 -2
  54. package/src/Input/Input.tsx +1 -1
  55. package/src/Input/README.md +2 -2
  56. package/src/Listbox/Listbox.tsx +475 -0
  57. package/src/Listbox/ListboxContext.ts +48 -0
  58. package/src/Listbox/README.md +248 -0
  59. package/src/Listbox/__tests__/Listbox.active-descendant.test.tsx +128 -0
  60. package/src/Listbox/__tests__/Listbox.asChild.test.tsx +91 -0
  61. package/src/Listbox/__tests__/Listbox.basic-rendering.test.tsx +16 -0
  62. package/src/Listbox/__tests__/Listbox.controlled-state.test.tsx +114 -0
  63. package/src/Listbox/__tests__/Listbox.disabled.test.tsx +90 -0
  64. package/src/Listbox/__tests__/Listbox.fixtures.ts +27 -0
  65. package/src/Listbox/__tests__/Listbox.group-label.test.tsx +97 -0
  66. package/src/Listbox/__tests__/Listbox.group.test.tsx +45 -0
  67. package/src/Listbox/__tests__/Listbox.keyboard-edge-cases.test.tsx +144 -0
  68. package/src/Listbox/__tests__/Listbox.keyboard-interaction.test.tsx +112 -0
  69. package/src/Listbox/__tests__/Listbox.modifier-keys.test.tsx +86 -0
  70. package/src/Listbox/__tests__/Listbox.multi-select-keyboard.test.tsx +368 -0
  71. package/src/Listbox/__tests__/Listbox.multiple-mode.test.tsx +91 -0
  72. package/src/Listbox/__tests__/Listbox.mutation-hardening.test.tsx +480 -0
  73. package/src/Listbox/__tests__/Listbox.orientation.test.tsx +59 -0
  74. package/src/Listbox/__tests__/Listbox.reading-direction.test.tsx +65 -0
  75. package/src/Listbox/__tests__/Listbox.reorder.test.tsx +78 -0
  76. package/src/Listbox/__tests__/Listbox.selection-follows-focus.test.tsx +64 -0
  77. package/src/Listbox/__tests__/Listbox.strict-context.test.tsx +21 -0
  78. package/src/Listbox/__tests__/Listbox.typeahead.test.tsx +123 -0
  79. package/src/Listbox/__tests__/Listbox.uncontrolled-state.test.tsx +48 -0
  80. package/src/Listbox/constants.ts +2 -0
  81. package/src/Listbox/hooks/index.ts +1 -0
  82. package/src/Listbox/hooks/useListboxRoot.ts +407 -0
  83. package/src/Listbox/index.ts +3 -0
  84. package/src/Listbox/types.ts +238 -0
  85. package/src/MillerColumns/MillerColumns.tsx +104 -30
  86. package/src/MillerColumns/README.md +90 -20
  87. package/src/MillerColumns/__tests__/MillerColumns.aria.test.tsx +45 -0
  88. package/src/MillerColumns/__tests__/MillerColumns.auto-scroll.test.tsx +4 -2
  89. package/src/MillerColumns/__tests__/MillerColumns.basic-rendering.test.tsx +18 -3
  90. package/src/MillerColumns/__tests__/MillerColumns.data-attributes.test.tsx +48 -0
  91. package/src/MillerColumns/__tests__/MillerColumns.keyboard-interaction.test.tsx +35 -2
  92. package/src/MillerColumns/__tests__/MillerColumns.keyboard-selection-state.test.tsx +96 -0
  93. package/src/MillerColumns/__tests__/MillerColumns.preview-panel.test.tsx +29 -4
  94. package/src/MillerColumns/__tests__/MillerColumns.preview-visibility.test.tsx +79 -0
  95. package/src/MillerColumns/__tests__/MillerColumns.resize.test.tsx +189 -0
  96. package/src/MillerColumns/__tests__/MillerColumns.styled-parts.test.tsx +143 -0
  97. package/src/MillerColumns/__tests__/MillerColumns.typeahead.test.tsx +104 -0
  98. package/src/MillerColumns/constants.ts +2 -0
  99. package/src/MillerColumns/hooks/useMillerColumnsColumn.ts +5 -2
  100. package/src/MillerColumns/hooks/useMillerColumnsItem.ts +29 -7
  101. package/src/MillerColumns/hooks/useMillerColumnsResizeHandle.ts +80 -10
  102. package/src/MillerColumns/hooks/useMillerColumnsRoot.ts +0 -0
  103. package/src/MillerColumns/index.ts +4 -0
  104. package/src/MillerColumns/types.ts +56 -6
  105. package/src/MillerColumns/utils.ts +69 -3
  106. package/src/Modal/Modal.tsx +4 -4
  107. package/src/Modal/README.md +5 -5
  108. package/src/Modal/types.ts +1 -1
  109. package/src/NavigationMenu/NavigationMenu.tsx +6 -6
  110. package/src/NavigationMenu/README.md +3 -3
  111. package/src/NavigationMenu/hooks/useNavigationMenuRoot.ts +1 -1
  112. package/src/NavigationMenu/hooks/useNavigationMenuTrigger.ts +1 -1
  113. package/src/Popover/Popover.tsx +2 -2
  114. package/src/Popover/README.md +1 -1
  115. package/src/Portal/Portal.tsx +2 -2
  116. package/src/Portal/README.md +2 -2
  117. package/src/Progress/README.md +1 -1
  118. package/src/Radio/Radio.tsx +1 -1
  119. package/src/RadioCard/README.md +2 -1
  120. package/src/RadioCard/RadioCard.tsx +5 -2
  121. package/src/RadioCard/__tests__/RadioCard.disabled-items.test.tsx +23 -0
  122. package/src/RadioGroup/README.md +2 -2
  123. package/src/RadioGroup/RadioGroup.tsx +1 -1
  124. package/src/SegmentedControl/README.md +2 -2
  125. package/src/SegmentedControl/SegmentedControl.tsx +1 -1
  126. package/src/SegmentedControl/hooks/useSegmentedControlRoot.ts +1 -0
  127. package/src/Select/README.md +2 -2
  128. package/src/Select/Select.tsx +8 -8
  129. package/src/Select/__tests__/Select.placeholder.test.tsx +3 -3
  130. package/src/Select/__tests__/Select.typeahead.test.tsx +3 -3
  131. package/src/Select/__tests__/Select.value-mirror.test.tsx +1 -1
  132. package/src/Select/types.ts +2 -2
  133. package/src/SkipNav/README.md +3 -3
  134. package/src/SkipNav/SkipNav.tsx +5 -5
  135. package/src/Slider/README.md +37 -7
  136. package/src/Slider/Slider.tsx +9 -3
  137. package/src/Slot/Slot.tsx +2 -2
  138. package/src/Slot/__tests__/Slot.test.tsx +2 -2
  139. package/src/Slot/types.ts +1 -1
  140. package/src/SplitButton/README.md +225 -0
  141. package/src/SplitButton/SplitButton.tsx +457 -0
  142. package/src/SplitButton/SplitButtonContext.ts +26 -0
  143. package/src/SplitButton/__tests__/SplitButton.accessibility.test.tsx +55 -0
  144. package/src/SplitButton/__tests__/SplitButton.asChild.test.tsx +51 -0
  145. package/src/SplitButton/__tests__/SplitButton.basic-rendering.test.tsx +57 -0
  146. package/src/SplitButton/__tests__/SplitButton.data-attributes.test.tsx +28 -0
  147. package/src/SplitButton/__tests__/SplitButton.disabled.test.tsx +79 -0
  148. package/src/SplitButton/__tests__/SplitButton.error-handling.test.tsx +29 -0
  149. package/src/SplitButton/__tests__/SplitButton.keyboard-interaction.test.tsx +56 -0
  150. package/src/SplitButton/__tests__/SplitButton.state-modes.test.tsx +68 -0
  151. package/src/SplitButton/index.ts +2 -0
  152. package/src/SplitButton/types.ts +186 -0
  153. package/src/Switch/README.md +1 -1
  154. package/src/Switch/Switch.tsx +2 -2
  155. package/src/Switch/types.ts +1 -1
  156. package/src/Table/README.md +110 -8
  157. package/src/Table/Table.tsx +206 -10
  158. package/src/Table/TableExpandableContext.ts +7 -0
  159. package/src/Table/__tests__/Table.DetailRow.test.tsx +83 -0
  160. package/src/Table/__tests__/Table.ExpandTrigger.test.tsx +102 -0
  161. package/src/Table/__tests__/Table.Expandable.test.tsx +121 -0
  162. package/src/Table/types.ts +125 -0
  163. package/src/Tabs/README.md +4 -4
  164. package/src/Tabs/Tabs.tsx +5 -4
  165. package/src/Tabs/__tests__/Tabs.activation-mode.test.tsx +58 -0
  166. package/src/Tabs/__tests__/Tabs.basic-rendering.test.tsx +15 -0
  167. package/src/Tabs/__tests__/Tabs.change-event-callbacks.test.tsx +24 -0
  168. package/src/Tabs/__tests__/Tabs.disabled-tabs.test.tsx +36 -0
  169. package/src/Tabs/__tests__/Tabs.lazy-mount.test.tsx +17 -0
  170. package/src/Tabs/hooks/useTabsContent.ts +5 -0
  171. package/src/Tabs/hooks/useTabsRoot.ts +29 -5
  172. package/src/Tabs/hooks/useTabsTrigger.ts +7 -2
  173. package/src/ToggleGroup/README.md +4 -4
  174. package/src/Tooltip/README.md +7 -7
  175. package/src/Tooltip/Tooltip.tsx +2 -2
  176. package/src/Tooltip/__tests__/Tooltip.basic-rendering.test.tsx +50 -0
  177. package/src/Tooltip/__tests__/Tooltip.error-handling.test.tsx +37 -0
  178. package/src/Tooltip/__tests__/Tooltip.escape-hatches.test.tsx +73 -0
  179. package/src/Tooltip/__tests__/Tooltip.keyboard-interaction.test.tsx +134 -0
  180. package/src/Tooltip/__tests__/Tooltip.skip-delay.test.tsx +216 -0
  181. package/src/Tooltip/__tests__/Tooltip.timer-coordination.test.tsx +223 -0
  182. package/src/Tooltip/hooks/useTooltipContent.ts +3 -0
  183. package/src/Tooltip/hooks/useTooltipProvider.ts +40 -14
  184. package/src/Tooltip/hooks/useTooltipRoot.ts +30 -6
  185. package/src/Tree/README.md +69 -5
  186. package/src/Tree/Tree.tsx +1 -1
  187. package/src/Tree/__tests__/Tree.styled-wrapper.test.tsx +159 -0
  188. package/src/Tree/hooks/useTreePath.ts +33 -1
  189. package/src/Tree/index.ts +6 -1
  190. package/src/Tree/utils.ts +46 -4
  191. package/src/VisuallyHidden/VisuallyHidden.tsx +1 -1
  192. package/src/hooks/index.ts +1 -0
  193. package/src/hooks/useTypeahead.ts +95 -0
  194. package/src/index.ts +6 -0
  195. package/src/useLocalStorage/__tests__/useLocalStorage.reading.test.tsx +31 -0
  196. package/src/useLocalStorage/__tests__/useLocalStorage.resilience.test.tsx +97 -0
  197. package/src/useLocalStorage/__tests__/useLocalStorage.ssr.test.tsx +20 -0
  198. package/src/useLocalStorage/__tests__/useLocalStorage.writing.test.tsx +72 -0
  199. package/src/useLocalStorage/index.ts +1 -0
  200. package/src/useLocalStorage/useLocalStorage.ts +140 -0
  201. package/src/useMediaQuery/README.md +66 -0
  202. package/src/useMediaQuery/__tests__/useMediaQuery.matching.test.tsx +42 -0
  203. package/src/useMediaQuery/__tests__/useMediaQuery.ssr.test.tsx +19 -0
  204. package/src/useMediaQuery/__tests__/useMediaQuery.subscription.test.tsx +89 -0
  205. package/src/useMediaQuery/index.ts +1 -0
  206. package/src/useMediaQuery/useMediaQuery.ts +49 -0
  207. package/src/usePagination/README.md +170 -0
  208. package/src/usePagination/__tests__/usePagination.navigation.test.tsx +109 -0
  209. package/src/usePagination/__tests__/usePagination.page-count.test.tsx +56 -0
  210. package/src/usePagination/__tests__/usePagination.page-state.test.tsx +87 -0
  211. package/src/usePagination/__tests__/usePagination.range.test.tsx +82 -0
  212. package/src/usePagination/__tests__/usePagination.slice.test.tsx +59 -0
  213. package/src/usePagination/__tests__/usePagination.windowing.test.tsx +68 -0
  214. package/src/usePagination/index.ts +1 -0
  215. package/src/usePagination/usePagination.ts +292 -0
  216. package/src/utils/getKeyToActionMap.ts +1 -1
@@ -29,6 +29,9 @@ export function useTooltipContent({
29
29
  useEffect(() => {
30
30
  if (!open) return;
31
31
  const handlePointerDown = (event: PointerEvent) => {
32
+ // Stryker disable next-line OptionalChaining: equivalent — this listener
33
+ // is only attached while `open` is true, and the Content `<div>` (and
34
+ // therefore internalRef.current) is always mounted whenever open is true.
32
35
  if (internalRef.current?.contains(event.target as Node)) return;
33
36
  onPointerDownOutside?.(event);
34
37
  if (event.defaultPrevented) return;
@@ -14,15 +14,32 @@ export function useTooltipProvider({
14
14
  const [isOpenGlobally, setIsOpenGlobally] = useState(false);
15
15
  const skipDelayTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
16
16
 
17
- const onOpenGlobally = useCallback(() => {
18
- if (skipDelayTimerRef.current !== null) {
19
- clearTimeout(skipDelayTimerRef.current);
20
- skipDelayTimerRef.current = null;
21
- }
22
- setIsOpenGlobally(true);
23
- }, []);
17
+ const onOpenGlobally = useCallback(
18
+ () => {
19
+ // Stryker disable next-line ConditionalExpression: equivalent —
20
+ // clearTimeout(null) is a spec'd no-op, so forcing this guard true is
21
+ // harmless when no skip-delay timer is pending; the false / never-clear
22
+ // variant is killed by the "clears a pending skip-delay timer when a
23
+ // tooltip opens again within the skip window" test.
24
+ if (skipDelayTimerRef.current !== null) {
25
+ clearTimeout(skipDelayTimerRef.current);
26
+ skipDelayTimerRef.current = null;
27
+ }
28
+ setIsOpenGlobally(true);
29
+ },
30
+ // Stryker disable next-line ArrayDeclaration: equivalent — no dependency to freeze.
31
+ [],
32
+ );
24
33
 
25
34
  const onCloseGlobally = useCallback(() => {
35
+ // Cancel before re-scheduling. The ref tracks only the newest timer, so
36
+ // overwriting it without cancelling orphans the previous one: it survives,
37
+ // fires unobserved, and can no longer be cancelled by a later open. Two
38
+ // closes in a row is not hypothetical — `closeImmediate` is unguarded and
39
+ // both Escape and blur are wired straight to it, so pressing Escape and then
40
+ // tabbing away leaks a timer. `clearTimeout(undefined)` is a spec'd no-op, so
41
+ // this needs no guard for the first close.
42
+ clearTimeout(skipDelayTimerRef.current ?? undefined);
26
43
  skipDelayTimerRef.current = setTimeout(() => {
27
44
  setIsOpenGlobally(false);
28
45
  skipDelayTimerRef.current = null;
@@ -33,13 +50,22 @@ export function useTooltipProvider({
33
50
  // component it was scheduled from unless explicitly cancelled — without
34
51
  // this, an in-flight timer fires after unmount and can throw once its
35
52
  // surrounding environment (e.g. a test's jsdom `window`) is gone.
36
- useEffect(() => {
37
- return () => {
38
- if (skipDelayTimerRef.current !== null) {
39
- clearTimeout(skipDelayTimerRef.current);
40
- }
41
- };
42
- }, []);
53
+ useEffect(
54
+ () => {
55
+ return () => {
56
+ // Stryker disable next-line ConditionalExpression: equivalent —
57
+ // clearTimeout(null) is a spec'd no-op, so forcing this guard true is
58
+ // harmless when no skip-delay timer is pending; the false / never-clear
59
+ // variant is killed by the "clears the pending skip-delay timeout on
60
+ // unmount" test.
61
+ if (skipDelayTimerRef.current !== null) {
62
+ clearTimeout(skipDelayTimerRef.current);
63
+ }
64
+ };
65
+ },
66
+ // Stryker disable next-line ArrayDeclaration: equivalent — no dependency to freeze (mount/unmount only).
67
+ [],
68
+ );
43
69
 
44
70
  const contextValue: TooltipProviderContextValue = {
45
71
  delayDuration,
@@ -31,6 +31,11 @@ export function useTooltipRoot({
31
31
  const gracePeriodTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
32
32
 
33
33
  const openImmediate = useCallback(() => {
34
+ // Stryker disable next-line ConditionalExpression: equivalent —
35
+ // clearTimeout(null) is a spec'd no-op, so forcing this guard true is
36
+ // harmless when no open timer is pending; the false / never-clear variant
37
+ // is killed by the "cancels a pending hover-delay open timer when the
38
+ // trigger is focused before it elapses" test.
34
39
  if (openTimerRef.current !== null) {
35
40
  clearTimeout(openTimerRef.current);
36
41
  openTimerRef.current = null;
@@ -52,6 +57,11 @@ export function useTooltipRoot({
52
57
  }, [openImmediate, effectiveDelayDuration, providerCtx.isOpenGlobally]);
53
58
 
54
59
  const closeImmediate = useCallback(() => {
60
+ // Stryker disable next-line ConditionalExpression: equivalent —
61
+ // clearTimeout(null) is a spec'd no-op, so forcing this guard true is
62
+ // harmless when no grace timer is pending; the false / never-clear variant
63
+ // is killed by the "cancels a pending grace-period timer when the tooltip
64
+ // closes immediately during the grace window" test.
55
65
  if (gracePeriodTimerRef.current !== null) {
56
66
  clearTimeout(gracePeriodTimerRef.current);
57
67
  gracePeriodTimerRef.current = null;
@@ -60,14 +70,28 @@ export function useTooltipRoot({
60
70
  providerCtx.onCloseGlobally();
61
71
  }, [setOpen, providerCtx]);
62
72
 
63
- const cancelGrace = useCallback(() => {
64
- if (gracePeriodTimerRef.current !== null) {
65
- clearTimeout(gracePeriodTimerRef.current);
66
- gracePeriodTimerRef.current = null;
67
- }
68
- }, []);
73
+ const cancelGrace = useCallback(
74
+ () => {
75
+ // Stryker disable next-line ConditionalExpression: equivalent —
76
+ // clearTimeout(null) is a spec'd no-op, so forcing this guard true is
77
+ // harmless when no grace timer is pending; the false / never-clear
78
+ // variant is killed by the "cancels the grace-period close timer when
79
+ // the pointer moves into the content" test.
80
+ if (gracePeriodTimerRef.current !== null) {
81
+ clearTimeout(gracePeriodTimerRef.current);
82
+ gracePeriodTimerRef.current = null;
83
+ }
84
+ },
85
+ // Stryker disable next-line ArrayDeclaration: equivalent — no dependency to freeze.
86
+ [],
87
+ );
69
88
 
70
89
  const closeWithGrace = useCallback(() => {
90
+ // Stryker disable next-line ConditionalExpression: equivalent —
91
+ // clearTimeout(null) is a spec'd no-op, so forcing this guard true is
92
+ // harmless when no open timer is pending; the false / never-clear variant
93
+ // is killed by the "cancels a pending hover-delay open timer when the
94
+ // pointer leaves before it elapses" test.
71
95
  if (openTimerRef.current !== null) {
72
96
  clearTimeout(openTimerRef.current);
73
97
  openTimerRef.current = null;
@@ -56,7 +56,7 @@ Passing both `default*` and the controlled prop is a type error.
56
56
 
57
57
  ```ts
58
58
  // Uncontrolled
59
- <Tree.Root defaultExpandedValues={["src"]} onExpandedChange={(v) => …}>
59
+ <Tree.Root defaultExpandedValues={["src"]} onExpandedChange={(v) => ...}>
60
60
 
61
61
  // Controlled
62
62
  <Tree.Root expandedValues={values} onExpandedChange={setValues}>
@@ -70,14 +70,14 @@ The selection shape depends on `selectionMode`:
70
70
 
71
71
  ```ts
72
72
  // Single (default) — selectedValue is `string | null`
73
- <Tree.Root defaultSelectedValue="readme" onSelectedValueChange={(v) => …}>
73
+ <Tree.Root defaultSelectedValue="readme" onSelectedValueChange={(v) => ...}>
74
74
  <Tree.Root selectedValue={v} onSelectedValueChange={setV}>
75
75
 
76
76
  // Multiple — selectedValues is `string[]`
77
77
  <Tree.Root
78
78
  selectionMode="multiple"
79
79
  defaultSelectedValues={["readme", "index"]}
80
- onSelectedValuesChange={(v) => …}
80
+ onSelectedValuesChange={(v) => ...}
81
81
  >
82
82
  <Tree.Root
83
83
  selectionMode="multiple"
@@ -187,7 +187,7 @@ styles.
187
187
  > [role="treeitem"][data-depth="2"] > .row {
188
188
  > padding-inline-start: 3rem;
189
189
  > }
190
- > /* …extend as deep as your tree can go */
190
+ > /* ...extend as deep as your tree can go */
191
191
  > ```
192
192
  >
193
193
  > **2. Set a CSS variable inline from the render layer** — the
@@ -223,6 +223,70 @@ styles.
223
223
  > }
224
224
  > ```
225
225
 
226
+ ## Wrapping Tree in a styled layer
227
+
228
+ `Tree.Branch` finds its control and content by **inspecting its children before
229
+ rendering them**, so it sees the component you wrote — not what that component
230
+ renders. A styling layer that wraps each part in its own component to attach
231
+ classes is therefore invisible to it, and the branch throws:
232
+
233
+ ```
234
+ A Tree.Branch must contain a <Tree.BranchControl>.
235
+ ```
236
+
237
+ Set `TREE_PART` on the wrapper and it partitions like the part it renders:
238
+
239
+ ```tsx
240
+ import { TREE_PART, Tree } from "@primitiv-ui/react";
241
+
242
+ function StyledBranchControl(props: TreeBranchControlProps) {
243
+ return <Tree.BranchControl {...props} className="row" />;
244
+ }
245
+ StyledBranchControl[TREE_PART] = "branch-control";
246
+
247
+ function StyledBranchContent(props: TreeBranchContentProps) {
248
+ return <Tree.BranchContent {...props} className="group" />;
249
+ }
250
+ StyledBranchContent[TREE_PART] = "branch-content";
251
+ ```
252
+
253
+ The accepted names are `"branch-control"` and `"branch-content"`
254
+ (`TreePartName`). Only these two parts need it — every other part is found by
255
+ position or context, not by inspection. The one-control and one-content rules
256
+ still apply: two marked controls in a branch is still an error.
257
+
258
+ This is what the registry `tree` component does; no other primitive in the
259
+ library needs a marker, because none of the others read their children's types.
260
+
261
+ ### Indenting without `attr()`
262
+
263
+ `useTreeLevel()` returns `{ depth }` — `0` directly under `Tree.Root`, one
264
+ deeper per enclosing `BranchContent`. A styling layer needs the depth as a
265
+ *number* to apply the indent as the **row's own padding**, which is what keeps
266
+ each row's box full-width so a hover or selected band runs edge to edge rather
267
+ than starting at the nested level. Reading `data-depth` back in CSS would need
268
+ `attr(data-depth type(<integer>))` — Chrome 133+ / Safari 18.2+, unsupported in
269
+ Firefox — so the depth is exposed here instead.
270
+
271
+ ```tsx
272
+ function StyledItem(props: TreeItemProps) {
273
+ const { depth } = useTreeLevel();
274
+ return (
275
+ <Tree.Item
276
+ {...props}
277
+ style={{ "--depth": depth } as CSSProperties}
278
+ className="row"
279
+ />
280
+ );
281
+ }
282
+ ```
283
+
284
+ ```css
285
+ .row {
286
+ padding-inline-start: calc(var(--depth, 0) * 1.25rem + 0.5rem);
287
+ }
288
+ ```
289
+
226
290
  ## Selection path
227
291
 
228
292
  `Tree.Item` and `Tree.Branch` accept an optional `label` prop. It does
@@ -256,7 +320,7 @@ import { Tree } from "@primitiv-ui/react";
256
320
  import { ChevronRight } from "@primitiv-ui/icons";
257
321
 
258
322
  <Tree.Root defaultSelectedValue="index">
259
- …
323
+ ...
260
324
  <Tree.SelectionPath separator={<ChevronRight />} />
261
325
  </Tree.Root>;
262
326
  ```
package/src/Tree/Tree.tsx CHANGED
@@ -88,7 +88,7 @@ import type {
88
88
  * selectionMode="multiple"
89
89
  * onSelectedValuesChange={(values) => console.log(values)}
90
90
  * >
91
- * {…}
91
+ * {...}
92
92
  * </Tree.Root>
93
93
  * ```
94
94
  */
@@ -0,0 +1,159 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import type { ComponentPropsWithRef } from "react";
3
+ import { describe, expect, it } from "vitest";
4
+
5
+ import { Tree, TREE_PART, useTreeLevel } from "../index";
6
+
7
+ /**
8
+ * A styled layer wraps every part in its own component to attach classes — the
9
+ * shape `primitiv add tree` copies into a consumer repo. `Tree.Branch` finds its
10
+ * control and content by inspecting its children, so those wrappers have to be
11
+ * recognisable as the parts they render; without that, Tree is the one primitive
12
+ * in the library that cannot be wrapped at all.
13
+ */
14
+ function StyledBranchControl(props: ComponentPropsWithRef<typeof Tree.BranchControl>) {
15
+ return <Tree.BranchControl {...props} className="styled-control" />;
16
+ }
17
+ StyledBranchControl[TREE_PART] = "branch-control";
18
+
19
+ function StyledBranchContent(props: ComponentPropsWithRef<typeof Tree.BranchContent>) {
20
+ return <Tree.BranchContent {...props} className="styled-content" />;
21
+ }
22
+ StyledBranchContent[TREE_PART] = "branch-content";
23
+
24
+ describe("Tree — styled wrappers", () => {
25
+ it("accepts a wrapper component in place of Tree.BranchControl", () => {
26
+ // Arrange / Act
27
+ render(
28
+ <Tree.Root defaultExpandedValues={["src"]}>
29
+ <Tree.Branch value="src">
30
+ <StyledBranchControl>src</StyledBranchControl>
31
+ <StyledBranchContent>
32
+ <Tree.Item value="index.ts">index.ts</Tree.Item>
33
+ </StyledBranchContent>
34
+ </Tree.Branch>
35
+ </Tree.Root>,
36
+ );
37
+
38
+ // Assert
39
+ expect(screen.getByText("src")).toHaveClass("styled-control");
40
+ expect(screen.getByRole("group")).toHaveClass("styled-content");
41
+ });
42
+
43
+ it("labels the branch by its wrapped control, so the name is just the row", () => {
44
+ // Arrange / Act
45
+ render(
46
+ <Tree.Root defaultExpandedValues={["src"]}>
47
+ <Tree.Branch value="src">
48
+ <StyledBranchControl>src</StyledBranchControl>
49
+ <StyledBranchContent>
50
+ <Tree.Item value="index.ts">index.ts</Tree.Item>
51
+ </StyledBranchContent>
52
+ </Tree.Branch>
53
+ </Tree.Root>,
54
+ );
55
+
56
+ // Assert — the branch treeitem takes its accessible name from the control
57
+ // row alone, not from the whole subtree.
58
+ expect(screen.getByRole("treeitem", { name: "src" })).toHaveAttribute(
59
+ "aria-expanded",
60
+ "true",
61
+ );
62
+ });
63
+
64
+ it("reads forceMount from the wrapper element, not from what it renders", () => {
65
+ // A styled layer animates the open/close transition, which needs the group
66
+ // to stay mounted while closed. Tree.Branch reads `forceMount` off the
67
+ // element it is handed, so setting it *inside* the wrapper is invisible —
68
+ // the subtree unmounts and there is nothing left to animate. This is the
69
+ // contract the registry surface depends on.
70
+
71
+ // Arrange / Act — closed, with forceMount on the wrapper element
72
+ const { rerender } = render(
73
+ <Tree.Root>
74
+ <Tree.Branch value="src">
75
+ <StyledBranchControl>src</StyledBranchControl>
76
+ <StyledBranchContent forceMount>
77
+ <Tree.Item value="index.ts">index.ts</Tree.Item>
78
+ </StyledBranchContent>
79
+ </Tree.Branch>
80
+ </Tree.Root>,
81
+ );
82
+
83
+ // Assert — still in the DOM, marked closed and hidden from AT
84
+ const group = document.querySelector('[role="group"]');
85
+ expect(group).toHaveAttribute("data-state", "closed");
86
+ expect(group).toHaveAttribute("aria-hidden", "true");
87
+
88
+ // Act — without it, the branch drops the subtree entirely
89
+ rerender(
90
+ <Tree.Root>
91
+ <Tree.Branch value="src">
92
+ <StyledBranchControl>src</StyledBranchControl>
93
+ <StyledBranchContent>
94
+ <Tree.Item value="index.ts">index.ts</Tree.Item>
95
+ </StyledBranchContent>
96
+ </Tree.Branch>
97
+ </Tree.Root>,
98
+ );
99
+
100
+ // Assert
101
+ expect(document.querySelector('[role="group"]')).toBeNull();
102
+ });
103
+
104
+ it("still rejects two controls when both are wrappers", () => {
105
+ // Arrange / Act / Assert
106
+ expect(() =>
107
+ render(
108
+ <Tree.Root>
109
+ <Tree.Branch value="src">
110
+ <StyledBranchControl>one</StyledBranchControl>
111
+ <StyledBranchControl>two</StyledBranchControl>
112
+ </Tree.Branch>
113
+ </Tree.Root>,
114
+ ),
115
+ ).toThrow("A Tree.Branch may contain at most one <Tree.BranchControl>.");
116
+ });
117
+ });
118
+
119
+ describe("useTreeLevel", () => {
120
+ function Depth() {
121
+ const { depth } = useTreeLevel();
122
+ return <span data-testid={`depth-${depth}`}>{depth}</span>;
123
+ }
124
+
125
+ it("reports the nesting depth so a styled layer can indent without attr()", () => {
126
+ // A styled layer needs the depth as a NUMBER to compute a row's indent.
127
+ // Reading it back off `data-depth` in CSS needs `attr(... type(<integer>))`,
128
+ // which Firefox does not support — so the depth is exposed here instead.
129
+
130
+ // Arrange / Act
131
+ render(
132
+ <Tree.Root defaultExpandedValues={["a", "b"]}>
133
+ <Depth />
134
+ <Tree.Branch value="a">
135
+ <StyledBranchControl>a</StyledBranchControl>
136
+ <StyledBranchContent>
137
+ <Depth />
138
+ <Tree.Branch value="b">
139
+ <StyledBranchControl>b</StyledBranchControl>
140
+ <StyledBranchContent>
141
+ <Depth />
142
+ </StyledBranchContent>
143
+ </Tree.Branch>
144
+ </StyledBranchContent>
145
+ </Tree.Branch>
146
+ </Tree.Root>,
147
+ );
148
+
149
+ // Assert — 0 at the root, one deeper per BranchContent
150
+ expect(screen.getByTestId("depth-0")).toBeInTheDocument();
151
+ expect(screen.getByTestId("depth-1")).toBeInTheDocument();
152
+ expect(screen.getByTestId("depth-2")).toBeInTheDocument();
153
+ });
154
+
155
+ it("throws outside a Tree", () => {
156
+ // Arrange / Act / Assert
157
+ expect(() => render(<Depth />)).toThrow();
158
+ });
159
+ });
@@ -1,4 +1,4 @@
1
- import { useTreeContext } from "../TreeContext";
1
+ import { useTreeContext, useTreeLevelContext } from "../TreeContext";
2
2
 
3
3
  import type { TreePathSegment } from "../types";
4
4
 
@@ -66,3 +66,35 @@ export function useTreeSelectionPaths(): TreePathSegment[][] {
66
66
  const { selectedOrder, getPath } = useTreeContext();
67
67
  return selectedOrder.map((value) => getPath(value));
68
68
  }
69
+
70
+ /**
71
+ * Read the current nesting depth inside a `Tree` — `0` for a node directly
72
+ * under {@link TreeRoot | `Tree.Root`}, one deeper per enclosing
73
+ * {@link TreeBranchContent | `Tree.BranchContent`}.
74
+ *
75
+ * A styling layer needs the depth as a *number* to compute a row's indent, and
76
+ * so to make the row span the tree's full width (a hover or selected band that
77
+ * runs edge to edge, as VS Code and most file trees do). Reading it back off
78
+ * the `data-depth` attribute in CSS requires `attr(data-depth type(<integer>))`,
79
+ * which Firefox does not support — hence this hook.
80
+ *
81
+ * @throws If called outside a `Tree.Root`.
82
+ *
83
+ * @example Indenting a row so its background still spans the full width
84
+ * ```tsx
85
+ * function StyledItem(props: TreeItemProps) {
86
+ * const { depth } = useTreeLevel();
87
+ * return (
88
+ * <Tree.Item
89
+ * {...props}
90
+ * style={{ "--depth": depth } as CSSProperties}
91
+ * className="row"
92
+ * />
93
+ * );
94
+ * }
95
+ * ```
96
+ */
97
+ export function useTreeLevel(): { depth: number } {
98
+ const { depth } = useTreeLevelContext();
99
+ return { depth };
100
+ }
package/src/Tree/index.ts CHANGED
@@ -1,3 +1,8 @@
1
1
  export * from "./Tree";
2
2
  export * from "./types";
3
- export { useTreePath, useTreeSelectionPaths } from "./hooks/useTreePath";
3
+ export { TREE_PART, type TreePartName } from "./utils";
4
+ export {
5
+ useTreeLevel,
6
+ useTreePath,
7
+ useTreeSelectionPaths,
8
+ } from "./hooks/useTreePath";
package/src/Tree/utils.ts CHANGED
@@ -3,14 +3,56 @@ import type { ReactElement, ReactNode } from "react";
3
3
 
4
4
  import { TreeBranchControl, TreeBranchContent } from "./Tree";
5
5
 
6
- /** Whether `node` is a `Tree.BranchControl` element. */
6
+ /**
7
+ * The property a component sets on itself to declare which `Tree` part it
8
+ * stands in for.
9
+ *
10
+ * `Tree.Branch` finds its control and content by inspecting its children before
11
+ * rendering them, so it sees the component the consumer wrote — not what that
12
+ * component renders. A styled layer wraps every part in its own component to
13
+ * attach classes (that is exactly what `primitiv add tree` copies), and without
14
+ * a marker those wrappers are unrecognisable, making `Tree` the one primitive
15
+ * in the library that cannot be wrapped at all.
16
+ *
17
+ * Set it on the wrapper and it partitions like the part it renders:
18
+ *
19
+ * ```tsx
20
+ * function StyledBranchControl(props: TreeBranchControlProps) {
21
+ * return <Tree.BranchControl {...props} className="row" />;
22
+ * }
23
+ * StyledBranchControl[TREE_PART] = "branch-control";
24
+ * ```
25
+ */
26
+ export const TREE_PART: "__primitivTreePart" = "__primitivTreePart";
27
+
28
+ /** The part names {@link TREE_PART} accepts. */
29
+ export type TreePartName = "branch-control" | "branch-content";
30
+
31
+ /** Read a component's {@link TREE_PART} marker, if it declares one. */
32
+ function treePartOf(type: unknown): TreePartName | undefined {
33
+ return (type as Record<string, TreePartName | undefined> | null)?.[TREE_PART];
34
+ }
35
+
36
+ /**
37
+ * Whether `node` is a `Tree.BranchControl` element — either the part itself, or
38
+ * a component that marks itself as standing in for it (see {@link TREE_PART}).
39
+ */
7
40
  export function isBranchControlElement(node: ReactNode): node is ReactElement {
8
- return isValidElement(node) && node.type === TreeBranchControl;
41
+ return (
42
+ isValidElement(node) &&
43
+ (node.type === TreeBranchControl || treePartOf(node.type) === "branch-control")
44
+ );
9
45
  }
10
46
 
11
- /** Whether `node` is a `Tree.BranchContent` element. */
47
+ /**
48
+ * Whether `node` is a `Tree.BranchContent` element — either the part itself, or
49
+ * a component that marks itself as standing in for it (see {@link TREE_PART}).
50
+ */
12
51
  export function isBranchContentElement(node: ReactNode): node is ReactElement {
13
- return isValidElement(node) && node.type === TreeBranchContent;
52
+ return (
53
+ isValidElement(node) &&
54
+ (node.type === TreeBranchContent || treePartOf(node.type) === "branch-content")
55
+ );
14
56
  }
15
57
 
16
58
  /**
@@ -61,7 +61,7 @@ const visuallyHiddenStyle: CSSProperties = {
61
61
  * ```tsx
62
62
  * <nav aria-labelledby="nav-label">
63
63
  * <VisuallyHidden id="nav-label">Primary navigation</VisuallyHidden>
64
- * …
64
+ * ...
65
65
  * </nav>
66
66
  * ```
67
67
  *
@@ -1,3 +1,4 @@
1
1
  export { useControllableState } from "./useControllableState";
2
2
  export { useCollection } from "./useCollection";
3
3
  export { useRovingTabindex } from "./useRovingTabindex";
4
+ export { useTypeahead } from "./useTypeahead";
@@ -0,0 +1,95 @@
1
+ import { useCallback, useRef, type KeyboardEvent } from "react";
2
+
3
+ type UseTypeaheadArgs = {
4
+ /** Reads the navigable (enabled) option values in current DOM order. Called
5
+ * per keystroke so an in-place reorder is picked up. */
6
+ getNavigable: () => string[];
7
+ /** Reads an option's visible label, used for prefix matching. */
8
+ getLabel: (optionValue: string) => string;
9
+ /** The option the cursor is on, or `undefined` before it is seeded. */
10
+ currentKey: string | undefined;
11
+ /** Moves the cursor to the matched option. */
12
+ onMatch: (optionValue: string) => void;
13
+ /** Milliseconds of inactivity after which the accumulated query resets. */
14
+ resetMs: number;
15
+ };
16
+
17
+ /**
18
+ * Printable-character typeahead for a list cursor, per APG's
19
+ * recommendation for lists longer than about seven options. Shared by
20
+ * `Listbox` and `MillerColumns` (which scopes it to the focused column).
21
+ *
22
+ * Keystrokes accumulate into a query that resets after `resetMs` of
23
+ * inactivity. A query of all-identical
24
+ * characters is treated as a repeat of that single character, which cycles
25
+ * through the options starting with it rather than searching for a literal
26
+ * run — so pressing "a" three times walks the "a" options instead of looking
27
+ * for "aaa". Matching is a case-insensitive prefix test against the option's
28
+ * text, and the search wraps.
29
+ */
30
+ export function useTypeahead({
31
+ getNavigable,
32
+ getLabel,
33
+ currentKey,
34
+ onMatch,
35
+ resetMs,
36
+ }: UseTypeaheadArgs): {
37
+ handleTypeahead: (event: KeyboardEvent<HTMLElement>) => void;
38
+ } {
39
+ const stateRef = useRef<{ query: string; timer: number | null }>({
40
+ query: "",
41
+ timer: null,
42
+ });
43
+
44
+ const handleTypeahead = useCallback(
45
+ (event: KeyboardEvent<HTMLElement>) => {
46
+ if (event.key.length !== 1) return;
47
+
48
+ const navigable = getNavigable();
49
+ // Stryker disable next-line ConditionalExpression: equivalent — the match loop is bounded by navigable.length, so an empty list exits immediately anyway.
50
+ if (navigable.length === 0) return;
51
+
52
+ const state = stateRef.current;
53
+ // Stryker disable next-line ConditionalExpression,EqualityOperator: equivalent — keystroke handling is synchronous, so clearing (or not clearing) the pending reset cannot change this search's outcome.
54
+ if (state.timer !== null) window.clearTimeout(state.timer);
55
+ state.query = (state.query + event.key).toLowerCase();
56
+ state.timer = window.setTimeout(() => {
57
+ state.query = "";
58
+ state.timer = null;
59
+ }, resetMs);
60
+
61
+ const isRepeat =
62
+ // Stryker disable next-line ConditionalExpression,EqualityOperator: equivalent — for a single character `every()` is trivially true, so both forms yield the same isRepeat.
63
+ state.query.length > 1 &&
64
+ state.query.split("").every((char) => char === state.query[0]);
65
+ const searchQuery = isRepeat ? state.query[0] : state.query;
66
+
67
+ // A single character (or a repeat of one) advances past the current
68
+ // option so pressing the same letter walks the matches; a genuine
69
+ // multi-character query narrows in place instead. With no cursor at all
70
+ // there is nothing to advance past, so the search starts at the top.
71
+ const currentIndex =
72
+ // Stryker disable next-line ConditionalExpression: equivalent — indexOf(undefined) is itself -1.
73
+ currentKey === undefined ? -1 : navigable.indexOf(currentKey);
74
+ // Stryker disable next-line ConditionalExpression,EqualityOperator: equivalent — with no cursor the two spellings produce the same scan: startIndex -1 with offset 1 and startIndex 0 with offset 0 both begin at index 0 and step identically.
75
+ const hasCursor = currentIndex >= 0;
76
+ const startIndex = hasCursor ? currentIndex : 0;
77
+ const offset =
78
+ hasCursor && (searchQuery.length === 1 || isRepeat) ? 1 : 0;
79
+
80
+ // Stryker disable next-line EqualityOperator: equivalent — the index wraps modulo navigable.length, so an extra iteration only re-checks an already-visited option.
81
+ for (let i = 0; i < navigable.length; i++) {
82
+ const index = (startIndex + offset + i) % navigable.length;
83
+ const label = getLabel(navigable[index]).trim().toLowerCase();
84
+ if (label.startsWith(searchQuery)) {
85
+ event.preventDefault();
86
+ onMatch(navigable[index]);
87
+ return;
88
+ }
89
+ }
90
+ },
91
+ [getNavigable, getLabel, currentKey, onMatch, resetMs],
92
+ );
93
+
94
+ return { handleTypeahead };
95
+ }