@primitiv-ui/react 0.1.29 → 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 (213) 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/RadioGroup/README.md +2 -2
  120. package/src/RadioGroup/RadioGroup.tsx +1 -1
  121. package/src/SegmentedControl/README.md +2 -2
  122. package/src/SegmentedControl/SegmentedControl.tsx +1 -1
  123. package/src/SegmentedControl/hooks/useSegmentedControlRoot.ts +1 -0
  124. package/src/Select/README.md +2 -2
  125. package/src/Select/Select.tsx +8 -8
  126. package/src/Select/__tests__/Select.placeholder.test.tsx +3 -3
  127. package/src/Select/__tests__/Select.typeahead.test.tsx +3 -3
  128. package/src/Select/__tests__/Select.value-mirror.test.tsx +1 -1
  129. package/src/Select/types.ts +2 -2
  130. package/src/SkipNav/README.md +3 -3
  131. package/src/SkipNav/SkipNav.tsx +5 -5
  132. package/src/Slider/README.md +37 -7
  133. package/src/Slider/Slider.tsx +9 -3
  134. package/src/Slot/Slot.tsx +2 -2
  135. package/src/Slot/__tests__/Slot.test.tsx +2 -2
  136. package/src/Slot/types.ts +1 -1
  137. package/src/SplitButton/README.md +225 -0
  138. package/src/SplitButton/SplitButton.tsx +457 -0
  139. package/src/SplitButton/SplitButtonContext.ts +26 -0
  140. package/src/SplitButton/__tests__/SplitButton.accessibility.test.tsx +55 -0
  141. package/src/SplitButton/__tests__/SplitButton.asChild.test.tsx +51 -0
  142. package/src/SplitButton/__tests__/SplitButton.basic-rendering.test.tsx +57 -0
  143. package/src/SplitButton/__tests__/SplitButton.data-attributes.test.tsx +28 -0
  144. package/src/SplitButton/__tests__/SplitButton.disabled.test.tsx +79 -0
  145. package/src/SplitButton/__tests__/SplitButton.error-handling.test.tsx +29 -0
  146. package/src/SplitButton/__tests__/SplitButton.keyboard-interaction.test.tsx +56 -0
  147. package/src/SplitButton/__tests__/SplitButton.state-modes.test.tsx +68 -0
  148. package/src/SplitButton/index.ts +2 -0
  149. package/src/SplitButton/types.ts +186 -0
  150. package/src/Switch/README.md +1 -1
  151. package/src/Switch/Switch.tsx +2 -2
  152. package/src/Switch/types.ts +1 -1
  153. package/src/Table/README.md +110 -8
  154. package/src/Table/Table.tsx +206 -10
  155. package/src/Table/TableExpandableContext.ts +7 -0
  156. package/src/Table/__tests__/Table.DetailRow.test.tsx +83 -0
  157. package/src/Table/__tests__/Table.ExpandTrigger.test.tsx +102 -0
  158. package/src/Table/__tests__/Table.Expandable.test.tsx +121 -0
  159. package/src/Table/types.ts +125 -0
  160. package/src/Tabs/README.md +4 -4
  161. package/src/Tabs/Tabs.tsx +5 -4
  162. package/src/Tabs/__tests__/Tabs.activation-mode.test.tsx +58 -0
  163. package/src/Tabs/__tests__/Tabs.basic-rendering.test.tsx +15 -0
  164. package/src/Tabs/__tests__/Tabs.change-event-callbacks.test.tsx +24 -0
  165. package/src/Tabs/__tests__/Tabs.disabled-tabs.test.tsx +36 -0
  166. package/src/Tabs/__tests__/Tabs.lazy-mount.test.tsx +17 -0
  167. package/src/Tabs/hooks/useTabsContent.ts +5 -0
  168. package/src/Tabs/hooks/useTabsRoot.ts +29 -5
  169. package/src/Tabs/hooks/useTabsTrigger.ts +7 -2
  170. package/src/ToggleGroup/README.md +4 -4
  171. package/src/Tooltip/README.md +7 -7
  172. package/src/Tooltip/Tooltip.tsx +2 -2
  173. package/src/Tooltip/__tests__/Tooltip.basic-rendering.test.tsx +50 -0
  174. package/src/Tooltip/__tests__/Tooltip.error-handling.test.tsx +37 -0
  175. package/src/Tooltip/__tests__/Tooltip.escape-hatches.test.tsx +73 -0
  176. package/src/Tooltip/__tests__/Tooltip.keyboard-interaction.test.tsx +134 -0
  177. package/src/Tooltip/__tests__/Tooltip.skip-delay.test.tsx +216 -0
  178. package/src/Tooltip/__tests__/Tooltip.timer-coordination.test.tsx +223 -0
  179. package/src/Tooltip/hooks/useTooltipContent.ts +3 -0
  180. package/src/Tooltip/hooks/useTooltipProvider.ts +40 -14
  181. package/src/Tooltip/hooks/useTooltipRoot.ts +30 -6
  182. package/src/Tree/README.md +69 -5
  183. package/src/Tree/Tree.tsx +1 -1
  184. package/src/Tree/__tests__/Tree.styled-wrapper.test.tsx +159 -0
  185. package/src/Tree/hooks/useTreePath.ts +33 -1
  186. package/src/Tree/index.ts +6 -1
  187. package/src/Tree/utils.ts +46 -4
  188. package/src/VisuallyHidden/VisuallyHidden.tsx +1 -1
  189. package/src/hooks/index.ts +1 -0
  190. package/src/hooks/useTypeahead.ts +95 -0
  191. package/src/index.ts +6 -0
  192. package/src/useLocalStorage/__tests__/useLocalStorage.reading.test.tsx +31 -0
  193. package/src/useLocalStorage/__tests__/useLocalStorage.resilience.test.tsx +97 -0
  194. package/src/useLocalStorage/__tests__/useLocalStorage.ssr.test.tsx +20 -0
  195. package/src/useLocalStorage/__tests__/useLocalStorage.writing.test.tsx +72 -0
  196. package/src/useLocalStorage/index.ts +1 -0
  197. package/src/useLocalStorage/useLocalStorage.ts +140 -0
  198. package/src/useMediaQuery/README.md +66 -0
  199. package/src/useMediaQuery/__tests__/useMediaQuery.matching.test.tsx +42 -0
  200. package/src/useMediaQuery/__tests__/useMediaQuery.ssr.test.tsx +19 -0
  201. package/src/useMediaQuery/__tests__/useMediaQuery.subscription.test.tsx +89 -0
  202. package/src/useMediaQuery/index.ts +1 -0
  203. package/src/useMediaQuery/useMediaQuery.ts +49 -0
  204. package/src/usePagination/README.md +170 -0
  205. package/src/usePagination/__tests__/usePagination.navigation.test.tsx +109 -0
  206. package/src/usePagination/__tests__/usePagination.page-count.test.tsx +56 -0
  207. package/src/usePagination/__tests__/usePagination.page-state.test.tsx +87 -0
  208. package/src/usePagination/__tests__/usePagination.range.test.tsx +82 -0
  209. package/src/usePagination/__tests__/usePagination.slice.test.tsx +59 -0
  210. package/src/usePagination/__tests__/usePagination.windowing.test.tsx +68 -0
  211. package/src/usePagination/index.ts +1 -0
  212. package/src/usePagination/usePagination.ts +292 -0
  213. package/src/utils/getKeyToActionMap.ts +1 -1
@@ -106,7 +106,7 @@ pattern. A number is pixels; a string is a verbatim CSS length (`"4rem"`,
106
106
 
107
107
  ```tsx
108
108
  <Collapsible.Content collapsedHeight={96} className="preview">
109
- A long passage whose first 96px stays visible while collapsed…
109
+ A long passage whose first 96px stays visible while collapsed...
110
110
  </Collapsible.Content>
111
111
  ```
112
112
 
@@ -165,7 +165,7 @@ elements:
165
165
  <a href="#section">Toggle</a>
166
166
  {/* rendered with role="button" aria-disabled="true" */}
167
167
  </Collapsible.Trigger>
168
- <Collapsible.Content>…</Collapsible.Content>
168
+ <Collapsible.Content>...</Collapsible.Content>
169
169
  </Collapsible.Root>
170
170
  ```
171
171
 
@@ -0,0 +1,508 @@
1
+ import {
2
+ useCallback,
3
+ useEffect,
4
+ useId,
5
+ useMemo,
6
+ useRef,
7
+ useState,
8
+ type KeyboardEvent,
9
+ type ReactElement,
10
+ } from "react";
11
+
12
+ import { useCollection, useControllableState, useRovingTabindex } from "../hooks/index.ts";
13
+ import { Slot, composeEventHandlers, composeRefs } from "../Slot/index.ts";
14
+ import { deriveId } from "../utils/index.ts";
15
+
16
+ import { ComboboxProvider, useComboboxContext } from "./ComboboxContext";
17
+ import type {
18
+ ComboboxContentProps,
19
+ ComboboxEmptyProps,
20
+ ComboboxItemMeta,
21
+ ComboboxInputProps,
22
+ ComboboxItemProps,
23
+ ComboboxRootProps,
24
+ } from "./types";
25
+
26
+ /**
27
+ * The root of a Combobox — owns the open state, the selected value, the live
28
+ * query and the virtual-focus cursor, and provides {@link ComboboxContextValue}
29
+ * to its parts.
30
+ *
31
+ * **Filtering is consumer-owned.** There is deliberately no `filter` prop: the
32
+ * root reports keystrokes through
33
+ * {@link ComboboxRootProps.onQueryChange | `onQueryChange`} and you render the
34
+ * options you want. That keeps async loading, fuzzy matching and sorting where
35
+ * the data lives.
36
+ *
37
+ * **Query versus value.** While the popup is open the input holds whatever the
38
+ * user typed; committing a choice or pressing Escape resets the text from the
39
+ * selected value, so the field never sits showing a half-typed query that is
40
+ * not the value.
41
+ *
42
+ * **Virtual focus, not a roving tabstop.** DOM focus never leaves the input.
43
+ * The cursor is published as `aria-activedescendant` on the input and
44
+ * `data-highlighted` on the item, which is what lets the input keep focus while
45
+ * driving the list.
46
+ *
47
+ * @extends HTMLDivElement
48
+ */
49
+ export function ComboboxRoot({
50
+ asChild = false,
51
+ defaultOpen = false,
52
+ open: openProp,
53
+ onOpenChange,
54
+ defaultValue = "",
55
+ value: valueProp,
56
+ onQueryChange,
57
+ onValueChange,
58
+ children,
59
+ ...rest
60
+ }: ComboboxRootProps): ReactElement {
61
+ const [open, setOpen] = useControllableState(openProp, defaultOpen, onOpenChange);
62
+ const [query, setQueryState] = useState("");
63
+ const [value, setValue] = useControllableState(valueProp, defaultValue, onValueChange);
64
+ // The label of the committed value, kept so Escape can put the text back
65
+ // after the user has typed over it (D1: the field never keeps a stale query).
66
+ const [committedLabel, setCommittedLabel] = useState("");
67
+ // One listbox per combobox, so this is a fixed suffix rather than a
68
+ // `deriveId` per-item id — there is no per-item value to discriminate on.
69
+ const comboboxId = useId();
70
+ const listboxId = `${comboboxId}-listbox`;
71
+ // Typing always opens the popup: the user is narrowing a list, so the list
72
+ // has to be on screen to be narrowed.
73
+ const setQuery = useCallback(
74
+ (next: string) => {
75
+ setQueryState(next);
76
+ setOpen(true);
77
+ onQueryChange?.(next);
78
+ // Emptying the field is a DESELECT. D1 resets the text from the value on
79
+ // close, which taken alone made a cleared field un-clearable — Escape put
80
+ // the deleted label straight back. Clearing the value too keeps D1's
81
+ // invariant (the field never shows text that is not the value: here both
82
+ // are empty) while letting the gesture mean what the user intended.
83
+ // Guarded on there being something to clear, so an already-empty field
84
+ // never reports a change the consumer did not cause.
85
+ if (next === "" && value !== "") {
86
+ setValue("");
87
+ setCommittedLabel("");
88
+ }
89
+ },
90
+ [onQueryChange, setOpen, setValue, value],
91
+ );
92
+
93
+ // D1: committing a choice closes the popup and resets the text FROM THE
94
+ // VALUE, so the field never sits showing a half-typed query that is not the
95
+ // value. The label comes from the item because the value alone cannot say
96
+ // how it should read.
97
+ const select = useCallback(
98
+ (nextValue: string, label: string) => {
99
+ setValue(nextValue);
100
+ setQueryState(label);
101
+ setCommittedLabel(label);
102
+ setOpen(false);
103
+ },
104
+ [setValue, setOpen],
105
+ );
106
+
107
+ // The cursor. Null until the user asks for one with an arrow key — an
108
+ // unopened combobox must not imply a pre-made choice.
109
+ const [activeValue, setActiveValue] = useState<string | null>(null);
110
+ const {
111
+ register: registerItem,
112
+ keys: itemValues,
113
+ itemsRef,
114
+ } = useCollection<string, ComboboxItemMeta>();
115
+ const getItemId = useCallback(
116
+ (itemValue: string) => deriveId(comboboxId, "option", itemValue),
117
+ [comboboxId],
118
+ );
119
+
120
+ // APG: when the referenced option is not fully visible, scroll it into view.
121
+ // Every cursor move goes through here rather than calling setActiveValue
122
+ // directly, so no path can move the cursor without bringing it on screen —
123
+ // which matters because the styled panel scrolls (18rem by default), so an
124
+ // unscrolled cursor simply walks off the bottom of a filtered list. Mirrors
125
+ // Listbox's own `moveCursor`, down to `block: "nearest"` — the option that
126
+ // does nothing when the item is already visible, so ordinary arrowing does not
127
+ // jerk the list about.
128
+ const moveCursor = useCallback(
129
+ (target: string) => {
130
+ setActiveValue(target);
131
+ // The cursor only ever holds a value the registry put there, so the lookup
132
+ // cannot miss — the same assertion the Enter branch below relies on.
133
+ itemsRef.current.get(target)!.element.scrollIntoView({ block: "nearest" });
134
+ },
135
+ [itemsRef],
136
+ );
137
+
138
+ // Keys only — no tabIndex is manipulated anywhere, because DOM focus never
139
+ // leaves the input. Same use of this hook as NavigationMenu.
140
+ // `string | null`: unlike NavigationMenu's roving tabstop there is
141
+ // legitimately no cursor until the user asks for one, so null is a real
142
+ // state rather than an absence to be defaulted away.
143
+ const { handleKeyDown: handleArrowKeys } = useRovingTabindex<string | null>({
144
+ orientation: "vertical",
145
+ navigable: itemValues,
146
+ currentKey: activeValue,
147
+ includeHomeEnd: true,
148
+ onNavigate: (target) => {
149
+ // The hook is instantiated with `string | null` so that `currentKey` can be
150
+ // null (no cursor yet), which widens `target` to match — but null is only
151
+ // ever a *current* key, never a navigable one: `navigable` is itemValues,
152
+ // which holds strings only. So a navigation target is always a real value.
153
+ moveCursor(target!);
154
+ },
155
+ });
156
+
157
+ const dismiss = useCallback(() => {
158
+ setOpen(false);
159
+ setQueryState(committedLabel);
160
+ setActiveValue(null);
161
+ }, [committedLabel, setOpen]);
162
+
163
+ // useRovingTabindex deliberately refuses to move `next`/`prev` when there is
164
+ // no current key (RadioGroup's disabled-current contract), so seeding the
165
+ // first cursor is this component's job: per APG, ArrowDown takes the first
166
+ // item and ArrowUp the last.
167
+ const handleInputKeyDown = useCallback(
168
+ (event: KeyboardEvent<HTMLElement>) => {
169
+ if (event.key === "Escape") {
170
+ dismiss();
171
+ return;
172
+ }
173
+
174
+ if (event.key === "Enter") {
175
+ if (activeValue === null) return;
176
+ event.preventDefault();
177
+ // Enter resolves the label from the registry so it agrees with what a
178
+ // click on the same item would commit. The cursor can only ever hold a
179
+ // value the registry put there, so the lookup cannot miss — asserted
180
+ // rather than branched, as Listbox does for the same registry.
181
+ select(activeValue, itemsRef.current.get(activeValue)!.label);
182
+ return;
183
+ }
184
+
185
+ if (activeValue === null && itemValues.length > 0) {
186
+ if (event.key === "ArrowDown") {
187
+ event.preventDefault();
188
+ moveCursor(itemValues[0]);
189
+ return;
190
+ }
191
+ if (event.key === "ArrowUp") {
192
+ event.preventDefault();
193
+ moveCursor(itemValues[itemValues.length - 1]);
194
+ return;
195
+ }
196
+ }
197
+
198
+ handleArrowKeys(event);
199
+ },
200
+ [dismiss, activeValue, itemValues, handleArrowKeys, moveCursor, select, itemsRef],
201
+ );
202
+
203
+ const contextValue = useMemo(
204
+ () => ({
205
+ open,
206
+ listboxId,
207
+ query,
208
+ setQuery,
209
+ value,
210
+ select,
211
+ handleInputKeyDown,
212
+ dismiss,
213
+ activeValue,
214
+ getItemId,
215
+ registerItem,
216
+ }),
217
+ [
218
+ open,
219
+ listboxId,
220
+ query,
221
+ setQuery,
222
+ value,
223
+ select,
224
+ handleInputKeyDown,
225
+ dismiss,
226
+ activeValue,
227
+ getItemId,
228
+ registerItem,
229
+ ],
230
+ );
231
+
232
+ const Root = asChild ? Slot : "div";
233
+
234
+ return (
235
+ <ComboboxProvider value={contextValue}>
236
+ <Root {...rest}>{children}</Root>
237
+ </ComboboxProvider>
238
+ );
239
+ }
240
+
241
+ // Stryker disable next-line StringLiteral: equivalent — a DevTools label, asserted by no behaviour.
242
+ ComboboxRoot.displayName = "ComboboxRoot";
243
+
244
+ /**
245
+ * The editable text field. Carries `role="combobox"`, `aria-expanded`,
246
+ * `aria-controls`, `aria-autocomplete="list"` and — once the user asks for a
247
+ * cursor — `aria-activedescendant`, per the ARIA 1.2 combobox pattern.
248
+ *
249
+ * Owns the whole keyboard model: ArrowDown/ArrowUp move the cursor (seeding the
250
+ * first/last item when there is none), Home/End jump to the ends, Enter commits
251
+ * the cursor item, and Escape closes and restores the committed label.
252
+ *
253
+ * @extends HTMLInputElement
254
+ */
255
+ export function ComboboxInput({
256
+ asChild = false,
257
+ onChange,
258
+ onKeyDown,
259
+ ...rest
260
+ }: ComboboxInputProps): ReactElement {
261
+ const { open, listboxId, query, setQuery, activeValue, getItemId, handleInputKeyDown } =
262
+ useComboboxContext();
263
+
264
+ const Input = asChild ? Slot : "input";
265
+
266
+ return (
267
+ <Input
268
+ role="combobox"
269
+ aria-expanded={open}
270
+ aria-controls={listboxId}
271
+ aria-autocomplete="list"
272
+ value={query}
273
+ aria-activedescendant={activeValue === null ? undefined : getItemId(activeValue)}
274
+ onChange={composeEventHandlers(onChange, (event) => setQuery(event.target.value))}
275
+ onKeyDown={composeEventHandlers(onKeyDown, handleInputKeyDown)}
276
+ {...rest}
277
+ />
278
+ );
279
+ }
280
+
281
+ // Stryker disable next-line StringLiteral: equivalent — a DevTools label, asserted by no behaviour.
282
+ ComboboxInput.displayName = "ComboboxInput";
283
+
284
+ /**
285
+ * The popup listbox. **Unmounted while closed**, so a closed combobox has no
286
+ * list in the accessibility tree at all — not a hidden one.
287
+ *
288
+ * Rendered as a `popover="auto"` element and promoted with `showPopover()` on
289
+ * open, which buys two things no amount of CSS can: the **top layer**, so nothing
290
+ * on the page can paint over the panel, and the browser's own **light dismiss**,
291
+ * so clicking outside closes it. There is deliberately no hand-rolled
292
+ * pointerdown-outside listener — the browser already does that detection, and it
293
+ * reports the result back as a `toggle` event, which is the only way React hears
294
+ * about a close it did not initiate. Same construction as `Select.Content`.
295
+ *
296
+ * A light dismiss runs the full {@link ComboboxContextValue.dismiss | `dismiss`},
297
+ * not just a close, so clicking away and pressing Escape leave the field in the
298
+ * same state rather than one of them stranding a half-typed query on screen.
299
+ *
300
+ * @extends HTMLDivElement
301
+ */
302
+ export function ComboboxContent({
303
+ asChild = false,
304
+ children,
305
+ ref,
306
+ ...rest
307
+ }: ComboboxContentProps): ReactElement | null {
308
+ const { open, listboxId, dismiss } = useComboboxContext();
309
+ const localRef = useRef<HTMLDivElement | null>(null);
310
+ // Composed, not overwritten: the popover machinery needs `localRef` to call
311
+ // showPopover() on, so a consumer ref replacing it would silently take out both
312
+ // the top layer and light dismiss.
313
+ const setRef = useMemo(() => composeRefs(localRef, ref), [ref]);
314
+
315
+ // Promote into the top layer. Keyed on `open` rather than mount, because this
316
+ // component stays mounted across opens and only its DOM output comes and goes —
317
+ // and because `open` is a boolean, the element can never already be showing
318
+ // when this runs, so showPopover() cannot throw InvalidStateError.
319
+ useEffect(() => {
320
+ if (!open) return;
321
+ localRef.current!.showPopover();
322
+ }, [open]);
323
+
324
+ // The browser's report that it closed the popup behind React's back.
325
+ useEffect(() => {
326
+ if (!open) return;
327
+ const element = localRef.current!;
328
+ const handleToggle = (event: Event) => {
329
+ // Only a close is news. A toggle to "open" is this component's own
330
+ // showPopover() echoing back, and acting on it would thrash state.
331
+ if ((event as ToggleEvent).newState === "closed") dismiss();
332
+ };
333
+ element.addEventListener("toggle", handleToggle);
334
+ return () => element.removeEventListener("toggle", handleToggle);
335
+ }, [open, dismiss]);
336
+
337
+ if (!open) return null;
338
+
339
+ const Content = asChild ? Slot : "div";
340
+
341
+ return (
342
+ <Content role="listbox" id={listboxId} popover="auto" {...rest} ref={setRef}>
343
+ {children}
344
+ </Content>
345
+ );
346
+ }
347
+
348
+ // Stryker disable next-line StringLiteral: equivalent — a DevTools label, asserted by no behaviour.
349
+ ComboboxContent.displayName = "ComboboxContent";
350
+
351
+ /**
352
+ * One selectable option in the popup listbox. Renders `role="option"` with
353
+ * `aria-selected`, plus `data-highlighted` while the cursor sits on it.
354
+ *
355
+ * Registers itself with the root so the arrow keys know the DOM order and Enter
356
+ * resolves the same label a click would.
357
+ *
358
+ * @extends HTMLDivElement
359
+ */
360
+ export function ComboboxItem({
361
+ asChild = false,
362
+ value: itemValue,
363
+ children,
364
+ onClick,
365
+ ref,
366
+ ...rest
367
+ }: ComboboxItemProps): ReactElement {
368
+ const { value, select, activeValue, getItemId, registerItem } = useComboboxContext();
369
+ const selected = value === itemValue;
370
+ const highlighted = activeValue === itemValue;
371
+
372
+ // An item is commonly an icon plus a label, so children are not always a
373
+ // string. There is no reliable way to read a label out of arbitrary JSX, so
374
+ // the value stands in — consumers wanting a nicer closed-state label should
375
+ // pass string children. Registered here so click and Enter agree.
376
+ const label = typeof children === "string" ? children : itemValue;
377
+
378
+ const localRef = useRef<HTMLDivElement | null>(null);
379
+ // Composed, not overwritten. Spreading `ref={localRef}` ahead of `{...rest}`
380
+ // would let a consumer ref replace it, registering `element: null` — the same
381
+ // shape of bug that silently disabled Content's whole popover. Harmless today
382
+ // because nothing reads `element` yet, which is precisely why it would have gone
383
+ // unnoticed until the first feature that does (scroll-into-view).
384
+ const setRef = useMemo(() => composeRefs(localRef, ref), [ref]);
385
+ useEffect(() => {
386
+ // Non-null by construction: this effect runs after React has attached the
387
+ // ref, so an item is never registered without its element (see
388
+ // ComboboxItemMeta.element).
389
+ registerItem(itemValue, { element: localRef.current!, label });
390
+ return () => registerItem(itemValue, null);
391
+ }, [itemValue, label, registerItem]);
392
+
393
+ const Item = asChild ? Slot : "div";
394
+
395
+ return (
396
+ <Item
397
+ role="option"
398
+ id={getItemId(itemValue)}
399
+ aria-selected={selected}
400
+ data-highlighted={highlighted ? "" : undefined}
401
+ onClick={composeEventHandlers(onClick, () => {
402
+ select(itemValue, label);
403
+ })}
404
+ {...rest}
405
+ ref={setRef}
406
+ >
407
+ {children}
408
+ </Item>
409
+ );
410
+ }
411
+
412
+ // Stryker disable next-line StringLiteral: equivalent — a DevTools label, asserted by no behaviour.
413
+ ComboboxItem.displayName = "ComboboxItem";
414
+
415
+ /**
416
+ * The no-results message shown inside the popup while the filtered list is
417
+ * empty. `role="presentation"`, so it is never announced as an option and the
418
+ * cursor has nothing to land on — the panel simply keeps its height while the
419
+ * user carries on typing. Rendering it is your call, since filtering is
420
+ * consumer-owned.
421
+ *
422
+ * @extends HTMLDivElement
423
+ */
424
+ export function ComboboxEmpty({
425
+ asChild = false,
426
+ children,
427
+ ...rest
428
+ }: ComboboxEmptyProps): ReactElement {
429
+ const Empty = asChild ? Slot : "div";
430
+
431
+ return (
432
+ <Empty role="presentation" {...rest}>
433
+ {children}
434
+ </Empty>
435
+ );
436
+ }
437
+
438
+ // Stryker disable next-line StringLiteral: equivalent — a DevTools label, asserted by no behaviour.
439
+ ComboboxEmpty.displayName = "ComboboxEmpty";
440
+
441
+ /**
442
+ * The shape of the exported `Combobox` value — callable as
443
+ * {@link ComboboxRoot | `Combobox.Root`} and carrying its sub-components as
444
+ * static properties.
445
+ */
446
+ export type TComboboxCompound = typeof ComboboxRoot & {
447
+ Root: typeof ComboboxRoot;
448
+ Input: typeof ComboboxInput;
449
+ Content: typeof ComboboxContent;
450
+ Item: typeof ComboboxItem;
451
+ Empty: typeof ComboboxEmpty;
452
+ };
453
+
454
+ /**
455
+ * Headless, accessible **Combobox** — an editable text field with a filtered
456
+ * popup listbox, implementing the
457
+ * {@link https://www.w3.org/WAI/ARIA/apg/patterns/combobox/ | WAI-ARIA APG combobox pattern}.
458
+ *
459
+ * `Combobox` is both callable (an alias of {@link ComboboxRoot | `Combobox.Root`})
460
+ * and carries its sub-components as static properties.
461
+ *
462
+ * - {@link ComboboxRoot | `Combobox.Root`} — state owner and context provider.
463
+ * - {@link ComboboxInput | `Combobox.Input`} — the editable field and the whole
464
+ * keyboard model.
465
+ * - {@link ComboboxContent | `Combobox.Content`} — the popup listbox.
466
+ * - {@link ComboboxItem | `Combobox.Item`} — one selectable option.
467
+ * - {@link ComboboxEmpty | `Combobox.Empty`} — the no-results message.
468
+ *
469
+ * v1 is **single-select with consumer-owned filtering**. Multi-select, async
470
+ * option loading and virtualization are deliberately out of scope.
471
+ *
472
+ * @example Filtering is yours to do
473
+ * ```tsx
474
+ * import { Combobox } from "@primitiv-ui/react";
475
+ *
476
+ * const [query, setQuery] = useState("");
477
+ * const matches = FRAMEWORKS.filter((f) =>
478
+ * f.label.toLowerCase().includes(query.toLowerCase()),
479
+ * );
480
+ *
481
+ * <Combobox.Root onQueryChange={setQuery} onValueChange={setFramework}>
482
+ * <Combobox.Input aria-label="Framework" />
483
+ * <Combobox.Content aria-label="Frameworks">
484
+ * {matches.map((f) => (
485
+ * <Combobox.Item key={f.value} value={f.value}>
486
+ * {f.label}
487
+ * </Combobox.Item>
488
+ * ))}
489
+ * {matches.length === 0 && <Combobox.Empty>No matches</Combobox.Empty>}
490
+ * </Combobox.Content>
491
+ * </Combobox.Root>;
492
+ * ```
493
+ *
494
+ * @see {@link ComboboxRoot} for the state model and the query/value split.
495
+ * @see {@link ComboboxInput} for the keyboard model and ARIA wiring.
496
+ */
497
+ const ComboboxCompound: TComboboxCompound = Object.assign(ComboboxRoot, {
498
+ Root: ComboboxRoot,
499
+ Input: ComboboxInput,
500
+ Content: ComboboxContent,
501
+ Item: ComboboxItem,
502
+ Empty: ComboboxEmpty,
503
+ });
504
+
505
+ // Stryker disable next-line StringLiteral: equivalent — a DevTools label, asserted by no behaviour.
506
+ ComboboxCompound.displayName = "Combobox";
507
+
508
+ export { ComboboxCompound as Combobox };
@@ -0,0 +1,23 @@
1
+ import type { Context, Provider } from "react";
2
+
3
+ import { createStrictContext } from "../utils/index.ts";
4
+
5
+ import type { ComboboxContextValue } from "./types";
6
+
7
+ const comboboxContextPair = createStrictContext<ComboboxContextValue>(
8
+ "Combobox sub-components must be rendered as children of Combobox.Root",
9
+ // Stryker disable next-line StringLiteral: equivalent — a DevTools label, asserted by no behaviour.
10
+ "ComboboxContext",
11
+ );
12
+
13
+ /** Strict React context carrying the {@link ComboboxContextValue} from
14
+ * `Combobox.Root` to its parts. `null` outside a Root. */
15
+ export const ComboboxContext: Context<ComboboxContextValue | null> = comboboxContextPair[0];
16
+
17
+ /** Reads the {@link ComboboxContextValue}; throws outside a `Combobox.Root`. */
18
+ export const useComboboxContext: () => ComboboxContextValue = comboboxContextPair[1];
19
+
20
+ /** Provider for {@link ComboboxContext}, used by `Combobox.Root`. */
21
+ const ComboboxProvider: Provider<ComboboxContextValue | null> = ComboboxContext.Provider;
22
+
23
+ export { ComboboxProvider };