@lovett/ui 0.0.10 → 0.1.0

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 (158) hide show
  1. package/dist/chunk-RBYWGBQ2.js +2752 -0
  2. package/dist/chunk-RBYWGBQ2.js.map +1 -0
  3. package/dist/index.d.ts +6064 -80
  4. package/dist/index.js +21561 -9696
  5. package/dist/index.js.map +1 -1
  6. package/dist/rich-composer-impl-5NO443A6.js +1859 -0
  7. package/dist/rich-composer-impl-5NO443A6.js.map +1 -0
  8. package/dist/styles.css +1586 -2
  9. package/dist/theme-v2.css +228 -0
  10. package/dist/tokens.css +121 -0
  11. package/package.json +8 -1
  12. package/src/__tests__/anchor.test.tsx +422 -0
  13. package/src/__tests__/avatar.test.tsx +272 -0
  14. package/src/__tests__/bar-chart.test.tsx +809 -0
  15. package/src/__tests__/board.test.tsx +420 -0
  16. package/src/__tests__/chart-math.test.ts +922 -0
  17. package/src/__tests__/chart-series.test.ts +339 -0
  18. package/src/__tests__/code-block.test.tsx +134 -0
  19. package/src/__tests__/combobox.test.tsx +677 -0
  20. package/src/__tests__/display-popover.test.tsx +195 -0
  21. package/src/__tests__/display-store.test.tsx +208 -0
  22. package/src/__tests__/donut-chart.test.tsx +397 -0
  23. package/src/__tests__/dropdown-menu.test.tsx +572 -0
  24. package/src/__tests__/filter-menu.test.tsx +175 -0
  25. package/src/__tests__/gauge-ring.test.tsx +233 -0
  26. package/src/__tests__/helpers/geometry.ts +58 -0
  27. package/src/__tests__/layer-stack.test.tsx +228 -0
  28. package/src/__tests__/line-chart.test.tsx +612 -0
  29. package/src/__tests__/popover.test.tsx +460 -0
  30. package/src/__tests__/ranked-bars.test.tsx +343 -0
  31. package/src/__tests__/remark-underline.test.ts +194 -0
  32. package/src/__tests__/select.test.tsx +543 -0
  33. package/src/__tests__/sparkline.test.tsx +368 -0
  34. package/src/__tests__/stat-layer.test.tsx +271 -0
  35. package/src/__tests__/stat-strip.test.tsx +175 -0
  36. package/src/__tests__/status.test.tsx +293 -0
  37. package/src/__tests__/tabs.test.tsx +303 -0
  38. package/src/__tests__/token-shape.test.ts +132 -2
  39. package/src/__tests__/tooltip.test.tsx +355 -0
  40. package/src/avatar.tsx +352 -0
  41. package/src/bar-chart.tsx +1214 -0
  42. package/src/board.tsx +658 -0
  43. package/src/calculator-shell-v2.tsx +19 -39
  44. package/src/chart-frame.tsx +960 -0
  45. package/src/chart-legend.tsx +304 -0
  46. package/src/chart-tooltip.tsx +267 -0
  47. package/src/code-block.tsx +62 -8
  48. package/src/combobox.tsx +796 -0
  49. package/src/delta-chip.tsx +263 -0
  50. package/src/detail/__tests__/activity-pane.test.tsx +186 -0
  51. package/src/detail/__tests__/detail-chrome.test.tsx +134 -0
  52. package/src/detail/__tests__/detail-surface.test.tsx +529 -0
  53. package/src/detail/__tests__/field-row.test.tsx +357 -0
  54. package/src/detail/activity-pane.tsx +507 -0
  55. package/src/detail/calendar.tsx +355 -0
  56. package/src/detail/detail-divider.tsx +261 -0
  57. package/src/detail/detail-header.tsx +287 -0
  58. package/src/detail/detail-menu.tsx +254 -0
  59. package/src/detail/detail-surface.tsx +1110 -0
  60. package/src/detail/field-list.tsx +196 -0
  61. package/src/detail/field-row.tsx +1131 -0
  62. package/src/detail/index.ts +58 -0
  63. package/src/detail/segmented-choice.tsx +94 -0
  64. package/src/detail/types.ts +129 -0
  65. package/src/display-popover.tsx +487 -0
  66. package/src/display-store.tsx +242 -0
  67. package/src/donut-chart.tsx +988 -0
  68. package/src/dropdown-menu.tsx +412 -151
  69. package/src/filter-core/EXPORTS.md +85 -0
  70. package/src/filter-core/__tests__/columns.test.ts +159 -0
  71. package/src/filter-core/__tests__/faceting.test.ts +193 -0
  72. package/src/filter-core/__tests__/filter-fns.test.ts +519 -0
  73. package/src/filter-core/__tests__/operators.test.ts +235 -0
  74. package/src/filter-core/__tests__/state.test.ts +268 -0
  75. package/src/filter-core/__tests__/url.test.ts +350 -0
  76. package/src/filter-core/columns.ts +134 -0
  77. package/src/filter-core/date-utils.ts +38 -0
  78. package/src/filter-core/examples/task-filter-columns.ts +121 -0
  79. package/src/filter-core/faceting.ts +120 -0
  80. package/src/filter-core/filter-fns.ts +335 -0
  81. package/src/filter-core/index.ts +105 -0
  82. package/src/filter-core/operators.ts +433 -0
  83. package/src/filter-core/state.ts +280 -0
  84. package/src/filter-core/types.ts +247 -0
  85. package/src/filter-core/url.ts +261 -0
  86. package/src/filter-dropdown.tsx +12 -0
  87. package/src/filter-menu.tsx +649 -0
  88. package/src/floating-drawer.tsx +19 -1
  89. package/src/gauge-ring.tsx +435 -0
  90. package/src/hue.ts +52 -0
  91. package/src/index.ts +402 -0
  92. package/src/kbd.tsx +27 -4
  93. package/src/lib/anchor.ts +427 -0
  94. package/src/lib/chart.ts +866 -0
  95. package/src/lib/focus.ts +74 -0
  96. package/src/lib/layer-stack.ts +215 -0
  97. package/src/lib/refs.ts +31 -0
  98. package/src/lib/remark-underline.ts +443 -0
  99. package/src/lib/series.ts +169 -0
  100. package/src/line-chart.tsx +1176 -0
  101. package/src/markdown.tsx +26 -7
  102. package/src/metric-card.tsx +57 -22
  103. package/src/modal.tsx +73 -66
  104. package/src/popover.tsx +407 -0
  105. package/src/progress-ledger.tsx +304 -0
  106. package/src/ranked-bars.tsx +386 -0
  107. package/src/segmented-pill.tsx +32 -9
  108. package/src/select.tsx +646 -0
  109. package/src/sortable.tsx +315 -1
  110. package/src/sparkline.tsx +416 -0
  111. package/src/stat-card.tsx +376 -0
  112. package/src/stat-row.tsx +108 -70
  113. package/src/stat-strip.tsx +327 -0
  114. package/src/status.tsx +215 -0
  115. package/src/styles.css +1586 -2
  116. package/src/tabs.tsx +206 -25
  117. package/src/task-card.tsx +610 -0
  118. package/src/thread/__tests__/comment-body-hostile.test.tsx +331 -0
  119. package/src/thread/__tests__/comment-tree.test.ts +151 -0
  120. package/src/thread/__tests__/emoji.test.ts +187 -0
  121. package/src/thread/__tests__/fixtures/thread-fixture.ts +235 -0
  122. package/src/thread/__tests__/link-preview-source.test.ts +120 -0
  123. package/src/thread/__tests__/link-preview.test.tsx +600 -0
  124. package/src/thread/__tests__/markdown-format.test.ts +82 -0
  125. package/src/thread/__tests__/markdown-spec.test.ts +469 -0
  126. package/src/thread/__tests__/relative-time.test.ts +71 -0
  127. package/src/thread/__tests__/rich-composer.test.tsx +799 -0
  128. package/src/thread/__tests__/scroll-caret.test.ts +58 -0
  129. package/src/thread/__tests__/suggestion-list.test.tsx +648 -0
  130. package/src/thread/__tests__/thread-scroll-ownership.test.tsx +88 -0
  131. package/src/thread/__tests__/thread.test.tsx +662 -0
  132. package/src/thread/__tests__/use-attachments.test.tsx +679 -0
  133. package/src/thread/actions.tsx +196 -0
  134. package/src/thread/attachments.tsx +1071 -0
  135. package/src/thread/comment-body.tsx +148 -0
  136. package/src/thread/comment-tree.ts +182 -0
  137. package/src/thread/comment.tsx +915 -0
  138. package/src/thread/composer-footer.tsx +125 -0
  139. package/src/thread/composer.tsx +319 -0
  140. package/src/thread/emoji.ts +283 -0
  141. package/src/thread/index.ts +153 -0
  142. package/src/thread/link-preview.tsx +341 -0
  143. package/src/thread/markdown-format.ts +155 -0
  144. package/src/thread/markdown-spec.ts +754 -0
  145. package/src/thread/rail.tsx +372 -0
  146. package/src/thread/reactions.tsx +415 -0
  147. package/src/thread/relative-time.tsx +94 -0
  148. package/src/thread/rich-composer-impl.tsx +1601 -0
  149. package/src/thread/rich-composer.tsx +195 -0
  150. package/src/thread/scroll-caret.ts +37 -0
  151. package/src/thread/suggestion-list.tsx +182 -0
  152. package/src/thread/thread.tsx +718 -0
  153. package/src/thread/types.ts +218 -0
  154. package/src/thread/use-attachments.ts +598 -0
  155. package/src/thread/use-now.ts +73 -0
  156. package/src/thread/use-thread.ts +316 -0
  157. package/src/tokens.css +121 -0
  158. package/src/tooltip.tsx +297 -0
@@ -0,0 +1,796 @@
1
+ /**
2
+ * Combobox — a text input that filters a listbox; single- or multi-select.
3
+ *
4
+ * WAI-ARIA APG "editable combobox with list autocomplete": the `<input>` is
5
+ * `role="combobox"` with `aria-autocomplete="list"`, `aria-expanded`,
6
+ * `aria-controls` and `aria-activedescendant`; the popup is `role="listbox"`
7
+ * (`aria-multiselectable` in multi mode) of `role="option"` rows, optionally
8
+ * inside `role="group"` sections with a heading. DOM focus never leaves the
9
+ * input: a mousedown in the list is prevented, so clicking an option keeps
10
+ * the caret where it was.
11
+ *
12
+ * Filtering is client-side by default (case-insensitive substring over label
13
+ * and description) and starts only once the user TYPES — opening a
14
+ * single-select that already holds a value shows the whole list, not the one
15
+ * row matching the selected label. Async-friendly: pass `shouldFilter={false}`
16
+ * to show `options` exactly as given, read the query through
17
+ * `onInputValueChange`, fetch, and flip `loading` while you wait. The
18
+ * component never fetches anything itself.
19
+ *
20
+ * Single mode: picking an option sets it and closes; the input shows the
21
+ * selected label; the label text is selected on focus so typing replaces it;
22
+ * leaving the field (blur / outside click) with the text deliberately emptied
23
+ * clears the value (`onValueChange(null)`), while Escape closes and reverts.
24
+ * Multi mode: selected options render as chips ahead of the input; picking
25
+ * toggles and keeps the list open; Backspace on an empty input removes the
26
+ * last chip. Keep the selected options present in `options` (a chip whose
27
+ * option has scrolled out of an async result set falls back to its raw
28
+ * value as the label).
29
+ *
30
+ * Keyboard: ArrowDown / ArrowUp open the list and move the highlight
31
+ * (disabled options are skipped, no wrap), Enter picks the highlighted
32
+ * option, Escape closes (through the shared layer stack — a Combobox in a
33
+ * Popover in a Modal peels one layer per keypress), Tab and blur close.
34
+ * Home / End are left to the caret.
35
+ *
36
+ * Promoted per ADR-0030 Decision G (meta-ads-audit-dashboard task manager:
37
+ * the row / card / context-menu / palette pickers are all "popover + search +
38
+ * live counts", which is exactly `count` on each option). Second consumer is
39
+ * the workspace app: `lenses/sem-spec/components/location-autocomplete.tsx`
40
+ * hand-rolls this role, and TagChipInput carries the chip model this one
41
+ * generalises.
42
+ *
43
+ * Token discipline: the shell is `.input-shell` (INTERNAL `--bg-input` /
44
+ * `--border-input`, owned by styles.css); the listbox shares
45
+ * `LISTBOX_CLASS` + `POPOVER_SURFACE_STYLE` with Select; chips are public
46
+ * `--surface-overlay` on `--foreground`; counts and group headings
47
+ * `--text-muted`. Geometry from `useAnchoredPosition`, motion from
48
+ * `.ds-enter-pop` gated on `positioned` (prefers-reduced-motion honoured in
49
+ * styles.css).
50
+ *
51
+ * TODO(tokens): the type / size literals here (`text-[12px]` chips,
52
+ * `max-w-[12rem]`, `text-[12.5px]` and `text-[11px] tracking-[0.4px]` in
53
+ * the list, `minWidth: '6rem'`) mirror dropdown-menu.tsx and sit off the
54
+ * spacing scale; snap them once a type-scale token exists (review
55
+ * 2026-09-05, MINOR).
56
+ *
57
+ * Usage:
58
+ *
59
+ * <Combobox
60
+ * aria-label="Assignee"
61
+ * placeholder="Search people…"
62
+ * options={people.map((p) => ({ value: p.id, label: p.name, count: p.openTasks }))}
63
+ * value={assigneeId}
64
+ * onValueChange={setAssigneeId}
65
+ * />
66
+ *
67
+ * <Combobox
68
+ * multiple
69
+ * aria-label="Statuses"
70
+ * groups={[{ id: 'open', label: 'Open' }, { id: 'closed', label: 'Closed' }]}
71
+ * options={statuses} // each with `group: 'open' | 'closed'`
72
+ * value={selected}
73
+ * onValueChange={setSelected}
74
+ * renderOption={(o, { selected }) => <StatusRow status={o} checked={selected} />}
75
+ * />
76
+ *
77
+ * // Async: the consumer owns the query and the fetch.
78
+ * <Combobox shouldFilter={false} loading={isFetching} options={results}
79
+ * inputValue={query} onInputValueChange={setQuery} … />
80
+ */
81
+
82
+ import {
83
+ forwardRef,
84
+ useCallback,
85
+ useEffect,
86
+ useId,
87
+ useLayoutEffect,
88
+ useMemo,
89
+ useRef,
90
+ useState,
91
+ type CSSProperties,
92
+ type FocusEvent as ReactFocusEvent,
93
+ type KeyboardEvent as ReactKeyboardEvent,
94
+ type ReactElement,
95
+ type ReactNode,
96
+ type RefAttributes,
97
+ } from 'react'
98
+ import { createPortal } from 'react-dom'
99
+ import { ChevronDown, Loader2, X } from 'lucide-react'
100
+ import { cn } from './lib/utils'
101
+ import { composeRefs } from './lib/refs'
102
+ import { useLayer } from './lib/layer-stack'
103
+ import {
104
+ useAnchoredPosition,
105
+ useOutsideClick,
106
+ type AnchorAlign,
107
+ type AnchorSide,
108
+ } from './lib/anchor'
109
+ import { POPOVER_SURFACE_STYLE } from './popover'
110
+ import { LISTBOX_CLASS, ListboxOption } from './select'
111
+
112
+ export type ComboboxSize = 'sm' | 'md'
113
+
114
+ export interface ComboboxOption<T extends string = string> {
115
+ value: T
116
+ label: string
117
+ /** Secondary line under the label. Searched by the default filter. */
118
+ description?: string
119
+ /** Leading adornment — a Lucide icon, an avatar, a colour dot. */
120
+ icon?: ReactNode
121
+ /** Rendered but not selectable; skipped by the arrow keys. */
122
+ disabled?: boolean
123
+ /** Live count at the row's trailing edge (tasks per status, per assignee). */
124
+ count?: number
125
+ /** Key into `groups`. Ungrouped options render first. */
126
+ group?: string
127
+ }
128
+
129
+ export interface ComboboxGroup {
130
+ id: string
131
+ label: string
132
+ }
133
+
134
+ export interface ComboboxOptionState {
135
+ selected: boolean
136
+ highlighted: boolean
137
+ /** The query the list is currently filtered by ('' when unfiltered). */
138
+ query: string
139
+ }
140
+
141
+ interface ComboboxBaseProps<T extends string> {
142
+ options: ReadonlyArray<ComboboxOption<T>>
143
+ /**
144
+ * Group order and labels. Options whose `group` is not listed here render
145
+ * in a trailing group labelled by the key; omit `groups` entirely to
146
+ * derive groups from the options in order of first appearance.
147
+ */
148
+ groups?: ReadonlyArray<ComboboxGroup>
149
+ /** Shows a spinner in the shell and `loadingMessage` while the list is empty. */
150
+ loading?: boolean
151
+ placeholder?: string
152
+ /** Control height — matches `<Input inputSize>`. Default `md`. */
153
+ size?: ComboboxSize
154
+ disabled?: boolean
155
+ /** Error chrome (`is-error`) on the shell + `aria-invalid` on the input. */
156
+ error?: boolean
157
+ /** Controlled query text. Omit for internal state. */
158
+ inputValue?: string
159
+ defaultInputValue?: string
160
+ onInputValueChange?: (value: string) => void
161
+ /** Filter `options` by the query on the client. Default true; false for async. */
162
+ shouldFilter?: boolean
163
+ /** Custom match. Default: case-insensitive substring over label + description. */
164
+ filter?: (option: ComboboxOption<T>, query: string) => boolean
165
+ /** Default "No results". */
166
+ emptyMessage?: ReactNode
167
+ /** Default "Loading…". */
168
+ loadingMessage?: ReactNode
169
+ /** Replaces the default row body (icon / label / description / count). The check mark stays. */
170
+ renderOption?: (option: ComboboxOption<T>, state: ComboboxOptionState) => ReactNode
171
+ /** Controlled open state. Omit for internal state. */
172
+ open?: boolean
173
+ onOpenChange?: (open: boolean) => void
174
+ /** Preferred side for the listbox. Default `bottom`; flips when it cannot fit. */
175
+ side?: AnchorSide
176
+ /** Default `start`. */
177
+ align?: AnchorAlign
178
+ /** id of the `<input>`, for `<label htmlFor>`. */
179
+ id?: string
180
+ /**
181
+ * Focus the input on mount. Also marks it `data-autofocus`: inside a
182
+ * Popover the popover does the focusing, once its content is positioned.
183
+ */
184
+ autoFocus?: boolean
185
+ 'aria-label'?: string
186
+ 'aria-labelledby'?: string
187
+ /** Extra classes on the `.input-shell`. */
188
+ className?: string
189
+ /** Extra classes on the listbox surface. */
190
+ listboxClassName?: string
191
+ }
192
+
193
+ export interface ComboboxSingleProps<T extends string = string> extends ComboboxBaseProps<T> {
194
+ multiple?: false
195
+ /** Controlled value; `null` = nothing selected. */
196
+ value?: T | null
197
+ defaultValue?: T | null
198
+ /** `null` when the user empties the field and leaves it. */
199
+ onValueChange?: (value: T | null) => void
200
+ }
201
+
202
+ export interface ComboboxMultipleProps<T extends string = string> extends ComboboxBaseProps<T> {
203
+ multiple: true
204
+ value?: ReadonlyArray<T>
205
+ defaultValue?: ReadonlyArray<T>
206
+ onValueChange?: (value: T[]) => void
207
+ }
208
+
209
+ export type ComboboxProps<T extends string = string> =
210
+ | ComboboxSingleProps<T>
211
+ | ComboboxMultipleProps<T>
212
+
213
+ const SHELL_SIZE_CLASS: Record<ComboboxSize, string> = {
214
+ sm: 'size-sm',
215
+ md: '',
216
+ }
217
+
218
+ /**
219
+ * Multi mode lets the shell grow with its chips. `.input-shell` is unlayered
220
+ * CSS (fixed height, `align-items: stretch`), which no layered Tailwind
221
+ * utility can override — so the overrides are inline, token-valued.
222
+ */
223
+ const MULTI_SHELL_STYLE: Record<ComboboxSize, CSSProperties> = {
224
+ sm: {
225
+ height: 'auto',
226
+ minHeight: 'var(--ctrl-sm)',
227
+ alignItems: 'center',
228
+ flexWrap: 'wrap',
229
+ gap: 'var(--space-1)',
230
+ paddingTop: 'var(--space-1)',
231
+ paddingBottom: 'var(--space-1)',
232
+ },
233
+ md: {
234
+ height: 'auto',
235
+ minHeight: 'var(--ctrl-md)',
236
+ alignItems: 'center',
237
+ flexWrap: 'wrap',
238
+ gap: 'var(--space-1)',
239
+ paddingTop: 'var(--space-1)',
240
+ paddingBottom: 'var(--space-1)',
241
+ },
242
+ }
243
+
244
+ /**
245
+ * `.input-shell input` sets `min-width: 0`; once chips share the row the
246
+ * input needs room to type in before it wraps to its own line. Inline for
247
+ * the same cascade reason as above.
248
+ */
249
+ const MULTI_INPUT_STYLE: CSSProperties = { minWidth: '6rem' }
250
+
251
+ function defaultFilter(option: ComboboxOption<string>, query: string): boolean {
252
+ const needle = query.toLowerCase()
253
+ return (
254
+ option.label.toLowerCase().includes(needle) ||
255
+ (option.description?.toLowerCase().includes(needle) ?? false)
256
+ )
257
+ }
258
+
259
+ function sanitizeId(id: string): string {
260
+ return id.replace(/[^a-zA-Z0-9_-]/g, '')
261
+ }
262
+
263
+ interface FlatItem {
264
+ option: ComboboxOption<string>
265
+ /** Index into the flat, keyboard-navigable list. */
266
+ index: number
267
+ }
268
+
269
+ interface Section {
270
+ key: string
271
+ /** null = the ungrouped run at the top. */
272
+ label: string | null
273
+ items: FlatItem[]
274
+ }
275
+
276
+ const ComboboxImpl = forwardRef<HTMLInputElement, ComboboxProps<string>>(function Combobox(
277
+ props,
278
+ forwardedRef,
279
+ ) {
280
+ const {
281
+ options,
282
+ groups,
283
+ loading,
284
+ placeholder,
285
+ size = 'md',
286
+ disabled,
287
+ error,
288
+ inputValue: inputValueProp,
289
+ defaultInputValue = '',
290
+ onInputValueChange,
291
+ shouldFilter = true,
292
+ filter,
293
+ emptyMessage = 'No results',
294
+ loadingMessage = 'Loading…',
295
+ renderOption,
296
+ open: openProp,
297
+ onOpenChange,
298
+ side = 'bottom',
299
+ align = 'start',
300
+ id,
301
+ autoFocus,
302
+ 'aria-label': ariaLabel,
303
+ 'aria-labelledby': ariaLabelledby,
304
+ className,
305
+ listboxClassName,
306
+ } = props
307
+ const isMultiple = props.multiple === true
308
+
309
+ // ---- selection (normalised to a string[] internally) -------------------
310
+ const [uncontrolledSelection, setUncontrolledSelection] = useState<string[]>(() => {
311
+ if (props.multiple === true) return [...(props.defaultValue ?? [])]
312
+ return props.defaultValue == null ? [] : [props.defaultValue]
313
+ })
314
+ const isControlledValue = props.value !== undefined
315
+ const selection: ReadonlyArray<string> =
316
+ props.value === undefined
317
+ ? uncontrolledSelection
318
+ : props.multiple === true
319
+ ? props.value
320
+ : props.value === null
321
+ ? []
322
+ : [props.value]
323
+
324
+ const emit = (next: string[]) => {
325
+ if (!isControlledValue) setUncontrolledSelection(next)
326
+ if (props.multiple === true) props.onValueChange?.(next)
327
+ else props.onValueChange?.(next[0] ?? null)
328
+ }
329
+
330
+ // ---- query text ---------------------------------------------------------
331
+ const [uncontrolledInput, setUncontrolledInput] = useState(defaultInputValue)
332
+ const inputValue = inputValueProp ?? uncontrolledInput
333
+ const setInputValue = (next: string) => {
334
+ if (inputValueProp === undefined) setUncontrolledInput(next)
335
+ onInputValueChange?.(next)
336
+ }
337
+ // True once the user has typed since the list opened. Until then the list
338
+ // is unfiltered and (single mode) the input displays the selected label.
339
+ const [dirty, setDirty] = useState(false)
340
+
341
+ // ---- open state ---------------------------------------------------------
342
+ const [uncontrolledOpen, setUncontrolledOpen] = useState(false)
343
+ const isControlledOpen = openProp !== undefined
344
+ const open = isControlledOpen ? Boolean(openProp) : uncontrolledOpen
345
+ const setOpen = useCallback(
346
+ (next: boolean) => {
347
+ if (!isControlledOpen) setUncontrolledOpen(next)
348
+ onOpenChange?.(next)
349
+ },
350
+ [isControlledOpen, onOpenChange],
351
+ )
352
+ // Mirrors `open` for handlers that may run twice for one gesture (outside
353
+ // pointerdown, then the blur it causes) — the second call is a no-op.
354
+ const openRef = useRef(open)
355
+ useLayoutEffect(() => {
356
+ openRef.current = open
357
+ })
358
+
359
+ const shellRef = useRef<HTMLDivElement | null>(null)
360
+ const inputRef = useRef<HTMLInputElement | null>(null)
361
+ const listboxRef = useRef<HTMLDivElement | null>(null)
362
+
363
+ const baseId = sanitizeId(useId())
364
+ const inputId = id ?? `combobox-${baseId}`
365
+ const listboxId = `combobox-listbox-${baseId}`
366
+ const optionId = (index: number) => `${listboxId}-opt-${index}`
367
+
368
+ const byValue = useMemo(() => {
369
+ const map = new Map<string, ComboboxOption<string>>()
370
+ for (const option of options) map.set(option.value, option)
371
+ return map
372
+ }, [options])
373
+ const selectedSingle = isMultiple ? undefined : byValue.get(selection[0] ?? '')
374
+
375
+ // ---- filtering + grouping ----------------------------------------------
376
+ const filterQuery = dirty ? inputValue.trim() : ''
377
+ const { sections, flat } = useMemo(() => {
378
+ const matches = filter ?? defaultFilter
379
+ const visible =
380
+ shouldFilter && filterQuery
381
+ ? options.filter((option) => matches(option, filterQuery))
382
+ : options
383
+
384
+ const byGroup = new Map<string | null, ComboboxOption<string>[]>()
385
+ for (const option of visible) {
386
+ const key = option.group ?? null
387
+ const list = byGroup.get(key) ?? []
388
+ list.push(option)
389
+ byGroup.set(key, list)
390
+ }
391
+
392
+ const order: Array<{ key: string | null; label: string | null }> = [{ key: null, label: null }]
393
+ if (groups) {
394
+ for (const group of groups) order.push({ key: group.id, label: group.label })
395
+ for (const key of byGroup.keys()) {
396
+ if (key !== null && !groups.some((group) => group.id === key)) {
397
+ order.push({ key, label: key })
398
+ }
399
+ }
400
+ } else {
401
+ for (const key of byGroup.keys()) if (key !== null) order.push({ key, label: key })
402
+ }
403
+
404
+ const flatList: ComboboxOption<string>[] = []
405
+ const sectionList: Section[] = []
406
+ for (const { key, label } of order) {
407
+ const members = byGroup.get(key)
408
+ if (!members || members.length === 0) continue
409
+ sectionList.push({
410
+ key: key ?? '__ungrouped',
411
+ label,
412
+ items: members.map((option) => ({ option, index: flatList.push(option) - 1 })),
413
+ })
414
+ }
415
+ return { sections: sectionList, flat: flatList }
416
+ }, [options, groups, filter, shouldFilter, filterQuery])
417
+
418
+ // ---- highlight ----------------------------------------------------------
419
+ const firstEnabled = flat.findIndex((option) => !option.disabled)
420
+ const selectedFlatIndex =
421
+ !isMultiple && !dirty && selectedSingle
422
+ ? flat.findIndex((option) => option.value === selectedSingle.value)
423
+ : -1
424
+ const [highlight, setHighlight] = useState<number | null>(null)
425
+ const activeIndex =
426
+ highlight !== null && highlight < flat.length
427
+ ? highlight
428
+ : selectedFlatIndex >= 0
429
+ ? selectedFlatIndex
430
+ : firstEnabled
431
+
432
+ const step = (from: number, direction: 1 | -1): number => {
433
+ for (let i = from + direction; i >= 0 && i < flat.length; i += direction) {
434
+ if (!flat[i]?.disabled) return i
435
+ }
436
+ return from
437
+ }
438
+
439
+ // ---- open / close / choose ---------------------------------------------
440
+ const openList = () => {
441
+ if (openRef.current) return
442
+ setHighlight(null)
443
+ setOpen(true)
444
+ }
445
+
446
+ /**
447
+ * `clearIfEmpty` — the user LEFT the field (blur / outside click): an
448
+ * emptied single-select clears its value. Escape and a pick pass false.
449
+ */
450
+ const closeList = (clearIfEmpty: boolean) => {
451
+ if (!openRef.current) return
452
+ openRef.current = false
453
+ if (!isMultiple && clearIfEmpty && dirty && inputValue.trim() === '' && selection.length > 0) {
454
+ emit([])
455
+ }
456
+ if (isMultiple && inputValue !== '') setInputValue('')
457
+ setDirty(false)
458
+ setHighlight(null)
459
+ setOpen(false)
460
+ }
461
+
462
+ const choose = (index: number) => {
463
+ const option = flat[index]
464
+ if (!option || option.disabled) return
465
+ if (isMultiple) {
466
+ const has = selection.includes(option.value)
467
+ emit(has ? selection.filter((value) => value !== option.value) : [...selection, option.value])
468
+ // Stays open; the query is kept so the user can keep toggling matches.
469
+ } else {
470
+ if (selection[0] !== option.value) emit([option.value])
471
+ closeList(false)
472
+ }
473
+ inputRef.current?.focus()
474
+ }
475
+
476
+ const removeChip = (value: string) => {
477
+ emit(selection.filter((candidate) => candidate !== value))
478
+ inputRef.current?.focus()
479
+ }
480
+
481
+ // ---- input events -------------------------------------------------------
482
+ const text = isMultiple || dirty ? inputValue : (selectedSingle?.label ?? '')
483
+
484
+ const handleChange = (next: string) => {
485
+ setInputValue(next)
486
+ setDirty(true)
487
+ setHighlight(null)
488
+ if (!openRef.current) setOpen(true)
489
+ }
490
+
491
+ const handleKeyDown = (event: ReactKeyboardEvent<HTMLInputElement>) => {
492
+ switch (event.key) {
493
+ case 'ArrowDown':
494
+ event.preventDefault()
495
+ if (!open) openList()
496
+ else setHighlight(step(activeIndex, 1))
497
+ return
498
+ case 'ArrowUp':
499
+ event.preventDefault()
500
+ if (!open) openList()
501
+ else setHighlight(step(activeIndex, -1))
502
+ return
503
+ case 'Enter':
504
+ if (open) {
505
+ // Also keeps an enclosing <form> from submitting on a pick.
506
+ event.preventDefault()
507
+ if (activeIndex >= 0) choose(activeIndex)
508
+ }
509
+ return
510
+ case 'Backspace':
511
+ if (isMultiple && text === '' && selection.length > 0) {
512
+ event.preventDefault()
513
+ emit(selection.slice(0, -1))
514
+ }
515
+ return
516
+ case 'Tab':
517
+ if (open) closeList(true)
518
+ return
519
+ default:
520
+ // Escape is answered by the shared layer stack (topmost layer only).
521
+ return
522
+ }
523
+ }
524
+
525
+ const handleBlur = (event: ReactFocusEvent<HTMLInputElement>) => {
526
+ // A chip's remove button lives inside the shell; moving to it is not leaving.
527
+ if (event.relatedTarget instanceof Node && shellRef.current?.contains(event.relatedTarget)) {
528
+ return
529
+ }
530
+ closeList(true)
531
+ }
532
+
533
+ // ---- floating list ------------------------------------------------------
534
+ const {
535
+ ref: positionRef,
536
+ style: positionStyle,
537
+ placement,
538
+ positioned,
539
+ } = useAnchoredPosition<HTMLDivElement>({
540
+ anchorRef: shellRef,
541
+ side,
542
+ align,
543
+ enabled: open,
544
+ })
545
+
546
+ const layer = useLayer({
547
+ enabled: open,
548
+ kind: 'popover',
549
+ elementRef: listboxRef,
550
+ onEscape: () => closeList(false),
551
+ })
552
+
553
+ useOutsideClick([shellRef, listboxRef], () => closeList(true), { enabled: open, layer })
554
+
555
+ const [shellWidth, setShellWidth] = useState<number | null>(null)
556
+ useLayoutEffect(() => {
557
+ if (!open) return
558
+ setShellWidth(shellRef.current?.offsetWidth ?? null)
559
+ }, [open])
560
+
561
+ useEffect(() => {
562
+ if (!open) return
563
+ listboxRef.current
564
+ ?.querySelector<HTMLElement>('[data-highlighted]')
565
+ ?.scrollIntoView({ block: 'nearest' })
566
+ }, [open, activeIndex])
567
+
568
+ useEffect(() => {
569
+ if (!autoFocus) return
570
+ const node = inputRef.current
571
+ if (!node) return
572
+ // Inside an anchored surface that is not positioned yet (a Popover's
573
+ // first frame is parked off-screen with `visibility: hidden`) a browser
574
+ // refuses the focus call outright. The surface focuses its
575
+ // `[data-autofocus]` control itself once positioned, so defer to it
576
+ // rather than fire a no-op that jsdom would wrongly honour.
577
+ if (node.closest('[data-positioned="false"]')) return
578
+ node.focus()
579
+ // Mount-only, like the DOM attribute it stands in for.
580
+ // eslint-disable-next-line react-hooks/exhaustive-deps
581
+ }, [])
582
+
583
+ const inputRefs = useMemo(
584
+ () => composeRefs<HTMLInputElement>(forwardedRef, inputRef),
585
+ [forwardedRef],
586
+ )
587
+ const listboxRefs = useMemo(
588
+ () => composeRefs<HTMLDivElement>(listboxRef, positionRef),
589
+ [positionRef],
590
+ )
591
+
592
+ const chips = isMultiple ? selection : []
593
+ const hasChips = chips.length > 0
594
+ const shellStyle: CSSProperties | undefined = isMultiple
595
+ ? hasChips
596
+ ? { ...MULTI_SHELL_STYLE[size], paddingLeft: 'var(--space-2)' }
597
+ : MULTI_SHELL_STYLE[size]
598
+ : undefined
599
+
600
+ const renderItem = ({ option, index }: FlatItem) => {
601
+ const selected = selection.includes(option.value)
602
+ const highlighted = index === activeIndex
603
+ return (
604
+ <ListboxOption
605
+ key={option.value}
606
+ id={optionId(index)}
607
+ label={option.label}
608
+ description={option.description}
609
+ icon={option.icon}
610
+ count={option.count}
611
+ selected={selected}
612
+ highlighted={highlighted}
613
+ disabled={option.disabled}
614
+ onSelect={() => choose(index)}
615
+ onHighlight={() => setHighlight(index)}
616
+ >
617
+ {renderOption ? renderOption(option, { selected, highlighted, query: filterQuery }) : undefined}
618
+ </ListboxOption>
619
+ )
620
+ }
621
+
622
+ return (
623
+ <>
624
+ <div
625
+ ref={shellRef}
626
+ data-slot="combobox"
627
+ data-state={open ? 'open' : 'closed'}
628
+ data-size={size}
629
+ className={cn(
630
+ 'input-shell has-trailing',
631
+ SHELL_SIZE_CLASS[size],
632
+ hasChips && 'has-leading',
633
+ error && 'is-error',
634
+ disabled && 'is-disabled',
635
+ className,
636
+ )}
637
+ style={shellStyle}
638
+ >
639
+ {chips.map((value) => {
640
+ const label = byValue.get(value)?.label ?? value
641
+ return (
642
+ <span
643
+ key={value}
644
+ data-slot="combobox-chip"
645
+ className="inline-flex items-center gap-1 rounded-[var(--radius-xs)] py-0.5 pl-2 pr-1 text-[12px] font-medium"
646
+ style={{
647
+ background: 'rgb(var(--surface-overlay))',
648
+ color: 'rgb(var(--foreground))',
649
+ }}
650
+ >
651
+ <span className="max-w-[12rem] truncate">{label}</span>
652
+ <button
653
+ type="button"
654
+ aria-label={`Remove ${label}`}
655
+ disabled={disabled}
656
+ onClick={() => removeChip(value)}
657
+ className="inline-flex h-4 w-4 items-center justify-center rounded-[var(--radius-xs)] transition-colors hover:bg-[rgb(var(--surface-overlay-strong))]"
658
+ style={{ color: 'rgb(var(--text-muted))' }}
659
+ >
660
+ <X className="h-3 w-3" aria-hidden="true" />
661
+ </button>
662
+ </span>
663
+ )
664
+ })}
665
+ <input
666
+ ref={inputRefs}
667
+ id={inputId}
668
+ type="text"
669
+ role="combobox"
670
+ aria-haspopup="listbox"
671
+ aria-expanded={open}
672
+ aria-controls={open ? listboxId : undefined}
673
+ aria-autocomplete="list"
674
+ aria-activedescendant={open && activeIndex >= 0 ? optionId(activeIndex) : undefined}
675
+ aria-label={ariaLabel}
676
+ aria-labelledby={ariaLabelledby}
677
+ aria-invalid={error ? true : undefined}
678
+ autoComplete="off"
679
+ spellCheck={false}
680
+ disabled={disabled}
681
+ placeholder={hasChips ? '' : placeholder}
682
+ value={text}
683
+ data-slot="combobox-input"
684
+ data-autofocus={autoFocus ? '' : undefined}
685
+ style={isMultiple ? MULTI_INPUT_STYLE : undefined}
686
+ onChange={(event) => handleChange(event.target.value)}
687
+ onKeyDown={handleKeyDown}
688
+ onClick={openList}
689
+ // Single mode shows the selected label here; select it so typing
690
+ // replaces it rather than appending to it.
691
+ onFocus={(event) => event.currentTarget.select()}
692
+ onBlur={handleBlur}
693
+ />
694
+ {loading ? (
695
+ <span className="affix" aria-hidden="true">
696
+ <Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
697
+ </span>
698
+ ) : null}
699
+ <button
700
+ type="button"
701
+ tabIndex={-1}
702
+ aria-label={open ? 'Close options' : 'Open options'}
703
+ disabled={disabled}
704
+ className="affix action"
705
+ // Keep focus in the input across the click.
706
+ onMouseDown={(event) => event.preventDefault()}
707
+ onClick={() => {
708
+ if (open) closeList(false)
709
+ else openList()
710
+ inputRef.current?.focus()
711
+ }}
712
+ >
713
+ <ChevronDown
714
+ className={cn(
715
+ 'h-4 w-4 transition-transform duration-[var(--dur-fast)] ease-[var(--ease-out)] motion-reduce:transition-none',
716
+ open && 'rotate-180',
717
+ )}
718
+ aria-hidden="true"
719
+ />
720
+ </button>
721
+ </div>
722
+
723
+ {open && typeof document !== 'undefined'
724
+ ? createPortal(
725
+ <div
726
+ ref={listboxRefs}
727
+ id={listboxId}
728
+ role="listbox"
729
+ aria-multiselectable={isMultiple || undefined}
730
+ aria-busy={loading || undefined}
731
+ aria-label={ariaLabelledby ? undefined : ariaLabel}
732
+ aria-labelledby={ariaLabelledby}
733
+ tabIndex={-1}
734
+ data-slot="combobox-listbox"
735
+ data-state="open"
736
+ data-side={placement?.side}
737
+ data-align={placement?.align}
738
+ data-positioned={positioned ? 'true' : 'false'}
739
+ className={cn(LISTBOX_CLASS, positioned && 'ds-enter-pop', listboxClassName)}
740
+ style={{
741
+ ...positionStyle,
742
+ ...POPOVER_SURFACE_STYLE,
743
+ ...(shellWidth ? { minWidth: shellWidth } : undefined),
744
+ }}
745
+ // Keep DOM focus (and the caret) in the input across a click.
746
+ onMouseDown={(event) => event.preventDefault()}
747
+ >
748
+ {flat.length === 0 ? (
749
+ <div
750
+ data-slot="combobox-empty"
751
+ className="px-2.5 py-2 text-[12.5px]"
752
+ style={{ color: 'rgb(var(--text-tertiary))' }}
753
+ >
754
+ {loading ? loadingMessage : emptyMessage}
755
+ </div>
756
+ ) : (
757
+ sections.map((section) =>
758
+ section.label === null ? (
759
+ section.items.map(renderItem)
760
+ ) : (
761
+ <div
762
+ key={section.key}
763
+ role="group"
764
+ aria-labelledby={`${listboxId}-group-${sanitizeId(section.key)}`}
765
+ data-slot="combobox-group"
766
+ >
767
+ <div
768
+ id={`${listboxId}-group-${sanitizeId(section.key)}`}
769
+ data-slot="combobox-group-label"
770
+ className="px-2.5 pb-1 pt-2 text-[11px] font-semibold uppercase tracking-[0.4px]"
771
+ style={{ color: 'rgb(var(--text-muted))' }}
772
+ >
773
+ {section.label}
774
+ </div>
775
+ {section.items.map(renderItem)}
776
+ </div>
777
+ ),
778
+ )
779
+ )}
780
+ </div>,
781
+ document.body,
782
+ )
783
+ : null}
784
+ </>
785
+ )
786
+ })
787
+
788
+ /**
789
+ * `forwardRef` erases the component's type parameter (it is instantiated at
790
+ * `string` above). This assertion restores the generic signature so
791
+ * `onValueChange` narrows to the union of the option values a caller
792
+ * passed; it changes nothing at runtime.
793
+ */
794
+ export const Combobox = ComboboxImpl as <T extends string = string>(
795
+ props: ComboboxProps<T> & RefAttributes<HTMLInputElement>,
796
+ ) => ReactElement | null