@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.
- package/dist/chunk-RBYWGBQ2.js +2752 -0
- package/dist/chunk-RBYWGBQ2.js.map +1 -0
- package/dist/index.d.ts +6064 -80
- package/dist/index.js +21561 -9696
- package/dist/index.js.map +1 -1
- package/dist/rich-composer-impl-5NO443A6.js +1859 -0
- package/dist/rich-composer-impl-5NO443A6.js.map +1 -0
- package/dist/styles.css +1586 -2
- package/dist/theme-v2.css +228 -0
- package/dist/tokens.css +121 -0
- package/package.json +8 -1
- package/src/__tests__/anchor.test.tsx +422 -0
- package/src/__tests__/avatar.test.tsx +272 -0
- package/src/__tests__/bar-chart.test.tsx +809 -0
- package/src/__tests__/board.test.tsx +420 -0
- package/src/__tests__/chart-math.test.ts +922 -0
- package/src/__tests__/chart-series.test.ts +339 -0
- package/src/__tests__/code-block.test.tsx +134 -0
- package/src/__tests__/combobox.test.tsx +677 -0
- package/src/__tests__/display-popover.test.tsx +195 -0
- package/src/__tests__/display-store.test.tsx +208 -0
- package/src/__tests__/donut-chart.test.tsx +397 -0
- package/src/__tests__/dropdown-menu.test.tsx +572 -0
- package/src/__tests__/filter-menu.test.tsx +175 -0
- package/src/__tests__/gauge-ring.test.tsx +233 -0
- package/src/__tests__/helpers/geometry.ts +58 -0
- package/src/__tests__/layer-stack.test.tsx +228 -0
- package/src/__tests__/line-chart.test.tsx +612 -0
- package/src/__tests__/popover.test.tsx +460 -0
- package/src/__tests__/ranked-bars.test.tsx +343 -0
- package/src/__tests__/remark-underline.test.ts +194 -0
- package/src/__tests__/select.test.tsx +543 -0
- package/src/__tests__/sparkline.test.tsx +368 -0
- package/src/__tests__/stat-layer.test.tsx +271 -0
- package/src/__tests__/stat-strip.test.tsx +175 -0
- package/src/__tests__/status.test.tsx +293 -0
- package/src/__tests__/tabs.test.tsx +303 -0
- package/src/__tests__/token-shape.test.ts +132 -2
- package/src/__tests__/tooltip.test.tsx +355 -0
- package/src/avatar.tsx +352 -0
- package/src/bar-chart.tsx +1214 -0
- package/src/board.tsx +658 -0
- package/src/calculator-shell-v2.tsx +19 -39
- package/src/chart-frame.tsx +960 -0
- package/src/chart-legend.tsx +304 -0
- package/src/chart-tooltip.tsx +267 -0
- package/src/code-block.tsx +62 -8
- package/src/combobox.tsx +796 -0
- package/src/delta-chip.tsx +263 -0
- package/src/detail/__tests__/activity-pane.test.tsx +186 -0
- package/src/detail/__tests__/detail-chrome.test.tsx +134 -0
- package/src/detail/__tests__/detail-surface.test.tsx +529 -0
- package/src/detail/__tests__/field-row.test.tsx +357 -0
- package/src/detail/activity-pane.tsx +507 -0
- package/src/detail/calendar.tsx +355 -0
- package/src/detail/detail-divider.tsx +261 -0
- package/src/detail/detail-header.tsx +287 -0
- package/src/detail/detail-menu.tsx +254 -0
- package/src/detail/detail-surface.tsx +1110 -0
- package/src/detail/field-list.tsx +196 -0
- package/src/detail/field-row.tsx +1131 -0
- package/src/detail/index.ts +58 -0
- package/src/detail/segmented-choice.tsx +94 -0
- package/src/detail/types.ts +129 -0
- package/src/display-popover.tsx +487 -0
- package/src/display-store.tsx +242 -0
- package/src/donut-chart.tsx +988 -0
- package/src/dropdown-menu.tsx +412 -151
- package/src/filter-core/EXPORTS.md +85 -0
- package/src/filter-core/__tests__/columns.test.ts +159 -0
- package/src/filter-core/__tests__/faceting.test.ts +193 -0
- package/src/filter-core/__tests__/filter-fns.test.ts +519 -0
- package/src/filter-core/__tests__/operators.test.ts +235 -0
- package/src/filter-core/__tests__/state.test.ts +268 -0
- package/src/filter-core/__tests__/url.test.ts +350 -0
- package/src/filter-core/columns.ts +134 -0
- package/src/filter-core/date-utils.ts +38 -0
- package/src/filter-core/examples/task-filter-columns.ts +121 -0
- package/src/filter-core/faceting.ts +120 -0
- package/src/filter-core/filter-fns.ts +335 -0
- package/src/filter-core/index.ts +105 -0
- package/src/filter-core/operators.ts +433 -0
- package/src/filter-core/state.ts +280 -0
- package/src/filter-core/types.ts +247 -0
- package/src/filter-core/url.ts +261 -0
- package/src/filter-dropdown.tsx +12 -0
- package/src/filter-menu.tsx +649 -0
- package/src/floating-drawer.tsx +19 -1
- package/src/gauge-ring.tsx +435 -0
- package/src/hue.ts +52 -0
- package/src/index.ts +402 -0
- package/src/kbd.tsx +27 -4
- package/src/lib/anchor.ts +427 -0
- package/src/lib/chart.ts +866 -0
- package/src/lib/focus.ts +74 -0
- package/src/lib/layer-stack.ts +215 -0
- package/src/lib/refs.ts +31 -0
- package/src/lib/remark-underline.ts +443 -0
- package/src/lib/series.ts +169 -0
- package/src/line-chart.tsx +1176 -0
- package/src/markdown.tsx +26 -7
- package/src/metric-card.tsx +57 -22
- package/src/modal.tsx +73 -66
- package/src/popover.tsx +407 -0
- package/src/progress-ledger.tsx +304 -0
- package/src/ranked-bars.tsx +386 -0
- package/src/segmented-pill.tsx +32 -9
- package/src/select.tsx +646 -0
- package/src/sortable.tsx +315 -1
- package/src/sparkline.tsx +416 -0
- package/src/stat-card.tsx +376 -0
- package/src/stat-row.tsx +108 -70
- package/src/stat-strip.tsx +327 -0
- package/src/status.tsx +215 -0
- package/src/styles.css +1586 -2
- package/src/tabs.tsx +206 -25
- package/src/task-card.tsx +610 -0
- package/src/thread/__tests__/comment-body-hostile.test.tsx +331 -0
- package/src/thread/__tests__/comment-tree.test.ts +151 -0
- package/src/thread/__tests__/emoji.test.ts +187 -0
- package/src/thread/__tests__/fixtures/thread-fixture.ts +235 -0
- package/src/thread/__tests__/link-preview-source.test.ts +120 -0
- package/src/thread/__tests__/link-preview.test.tsx +600 -0
- package/src/thread/__tests__/markdown-format.test.ts +82 -0
- package/src/thread/__tests__/markdown-spec.test.ts +469 -0
- package/src/thread/__tests__/relative-time.test.ts +71 -0
- package/src/thread/__tests__/rich-composer.test.tsx +799 -0
- package/src/thread/__tests__/scroll-caret.test.ts +58 -0
- package/src/thread/__tests__/suggestion-list.test.tsx +648 -0
- package/src/thread/__tests__/thread-scroll-ownership.test.tsx +88 -0
- package/src/thread/__tests__/thread.test.tsx +662 -0
- package/src/thread/__tests__/use-attachments.test.tsx +679 -0
- package/src/thread/actions.tsx +196 -0
- package/src/thread/attachments.tsx +1071 -0
- package/src/thread/comment-body.tsx +148 -0
- package/src/thread/comment-tree.ts +182 -0
- package/src/thread/comment.tsx +915 -0
- package/src/thread/composer-footer.tsx +125 -0
- package/src/thread/composer.tsx +319 -0
- package/src/thread/emoji.ts +283 -0
- package/src/thread/index.ts +153 -0
- package/src/thread/link-preview.tsx +341 -0
- package/src/thread/markdown-format.ts +155 -0
- package/src/thread/markdown-spec.ts +754 -0
- package/src/thread/rail.tsx +372 -0
- package/src/thread/reactions.tsx +415 -0
- package/src/thread/relative-time.tsx +94 -0
- package/src/thread/rich-composer-impl.tsx +1601 -0
- package/src/thread/rich-composer.tsx +195 -0
- package/src/thread/scroll-caret.ts +37 -0
- package/src/thread/suggestion-list.tsx +182 -0
- package/src/thread/thread.tsx +718 -0
- package/src/thread/types.ts +218 -0
- package/src/thread/use-attachments.ts +598 -0
- package/src/thread/use-now.ts +73 -0
- package/src/thread/use-thread.ts +316 -0
- package/src/tokens.css +121 -0
- package/src/tooltip.tsx +297 -0
package/src/combobox.tsx
ADDED
|
@@ -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
|