@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/select.tsx
ADDED
|
@@ -0,0 +1,646 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Select — a single-value picker: a button that opens a real listbox.
|
|
3
|
+
*
|
|
4
|
+
* WAI-ARIA APG "select-only combobox": the trigger is `role="combobox"`
|
|
5
|
+
* with `aria-haspopup="listbox"`, the popup is `role="listbox"` of
|
|
6
|
+
* `role="option"` rows with `aria-selected`. DOM focus STAYS on the trigger
|
|
7
|
+
* while the list is open; the highlighted row is announced through the
|
|
8
|
+
* trigger's `aria-activedescendant`, and a mousedown inside the listbox is
|
|
9
|
+
* prevented so a click never pulls focus out of the trigger. The trigger
|
|
10
|
+
* wears the `.input-shell` chrome at the `sm` / `md` control heights, so a
|
|
11
|
+
* Select sits flush beside an `<Input>` in the same form row.
|
|
12
|
+
*
|
|
13
|
+
* Keyboard, closed: ArrowDown / ArrowUp / Enter / Space open with the
|
|
14
|
+
* selected option highlighted; Home / End open on the first / last option;
|
|
15
|
+
* a printable character opens and type-ahead jumps to the first label that
|
|
16
|
+
* starts with it. Open: ArrowDown / ArrowUp move the highlight (no wrap;
|
|
17
|
+
* disabled options are skipped), Home / End jump, Enter / Space commit,
|
|
18
|
+
* type-ahead (a repeated letter cycles through its matches, a typed prefix
|
|
19
|
+
* narrows), Escape closes without changing the value. Tab closes WITHOUT
|
|
20
|
+
* committing — a deliberate departure from the APG example, because in a
|
|
21
|
+
* data-editing table an accidental write on Tab is the worse failure.
|
|
22
|
+
*
|
|
23
|
+
* Promoted per ADR-0030 Decision G (meta-ads-audit-dashboard task manager:
|
|
24
|
+
* status / priority / role pickers in the table, sheet and modal; that
|
|
25
|
+
* repo's `client/src/pages/Users.tsx` hand-rolled a native `<select>` with a
|
|
26
|
+
* comment saying the package ships none — this replaces it). Second
|
|
27
|
+
* consumer is the workspace app, which has 26 native `<select>`s today,
|
|
28
|
+
* among them `lenses/products/product-form.tsx`, whose header says the same
|
|
29
|
+
* thing. FilterDropdown stays a sibling: it is a pill-shaped FILTER chip
|
|
30
|
+
* ("Platform: Facebook") with a neutral value, not a form control.
|
|
31
|
+
*
|
|
32
|
+
* Token discipline: the trigger is `.input-shell` (styles.css owns the
|
|
33
|
+
* INTERNAL `--bg-input` / `--border-input`); the listbox shares
|
|
34
|
+
* `POPOVER_SURFACE_STYLE` with Popover and DropdownMenu; option hover is
|
|
35
|
+
* public `--surface-overlay`, the check mark public `--accent`, secondary
|
|
36
|
+
* text `--text-tertiary` / `--text-muted`. Geometry comes from
|
|
37
|
+
* `useAnchoredPosition` (the list flips above the trigger at the bottom of
|
|
38
|
+
* the viewport); motion is `.ds-enter-pop` gated on `positioned`, which
|
|
39
|
+
* honours prefers-reduced-motion in styles.css. Escape goes through the
|
|
40
|
+
* shared layer stack, so a Select inside a Popover inside a Modal peels one
|
|
41
|
+
* layer per keypress.
|
|
42
|
+
*
|
|
43
|
+
* TODO(tokens): the type literals (`text-[12.5px]` / `text-[13.5px]` value,
|
|
44
|
+
* `text-[13px]` / `text-[11.5px]` rows, `max-h-[min(60vh,20rem)]`) mirror
|
|
45
|
+
* `.input-shell` and dropdown-menu.tsx and sit off the spacing scale; snap
|
|
46
|
+
* them once a type-scale token exists (review 2026-09-05, MINOR).
|
|
47
|
+
*
|
|
48
|
+
* Usage:
|
|
49
|
+
*
|
|
50
|
+
* <Select
|
|
51
|
+
* aria-label="Status"
|
|
52
|
+
* value={status}
|
|
53
|
+
* onValueChange={setStatus}
|
|
54
|
+
* options={[
|
|
55
|
+
* { value: 'todo', label: 'To do', icon: <Circle className="h-3.5 w-3.5" /> },
|
|
56
|
+
* { value: 'doing', label: 'In progress', description: 'Actively being worked' },
|
|
57
|
+
* { value: 'done', label: 'Done', disabled: !canClose },
|
|
58
|
+
* ]}
|
|
59
|
+
* />
|
|
60
|
+
*
|
|
61
|
+
* <Select size="sm" placeholder="Assignee…" defaultValue={null} options={people} />
|
|
62
|
+
*/
|
|
63
|
+
|
|
64
|
+
import {
|
|
65
|
+
forwardRef,
|
|
66
|
+
useCallback,
|
|
67
|
+
useEffect,
|
|
68
|
+
useId,
|
|
69
|
+
useLayoutEffect,
|
|
70
|
+
useMemo,
|
|
71
|
+
useRef,
|
|
72
|
+
useState,
|
|
73
|
+
type ButtonHTMLAttributes,
|
|
74
|
+
type KeyboardEvent as ReactKeyboardEvent,
|
|
75
|
+
type ReactElement,
|
|
76
|
+
type ReactNode,
|
|
77
|
+
type RefAttributes,
|
|
78
|
+
} from 'react'
|
|
79
|
+
import { createPortal } from 'react-dom'
|
|
80
|
+
import { Check, ChevronDown } from 'lucide-react'
|
|
81
|
+
import { cn } from './lib/utils'
|
|
82
|
+
import { composeRefs } from './lib/refs'
|
|
83
|
+
import { useLayer } from './lib/layer-stack'
|
|
84
|
+
import {
|
|
85
|
+
useAnchoredPosition,
|
|
86
|
+
useOutsideClick,
|
|
87
|
+
type AnchorAlign,
|
|
88
|
+
type AnchorSide,
|
|
89
|
+
} from './lib/anchor'
|
|
90
|
+
import { POPOVER_SURFACE_STYLE } from './popover'
|
|
91
|
+
|
|
92
|
+
export type SelectSize = 'sm' | 'md'
|
|
93
|
+
|
|
94
|
+
export interface SelectOption<T extends string = string> {
|
|
95
|
+
value: T
|
|
96
|
+
label: string
|
|
97
|
+
/** Secondary line under the label. */
|
|
98
|
+
description?: string
|
|
99
|
+
/** Leading adornment — a Lucide icon, an avatar, a colour dot. */
|
|
100
|
+
icon?: ReactNode
|
|
101
|
+
/** Rendered but not selectable; skipped by the arrow keys and type-ahead. */
|
|
102
|
+
disabled?: boolean
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface SelectProps<T extends string = string>
|
|
106
|
+
extends Omit<
|
|
107
|
+
ButtonHTMLAttributes<HTMLButtonElement>,
|
|
108
|
+
'value' | 'defaultValue' | 'onChange' | 'role'
|
|
109
|
+
> {
|
|
110
|
+
options: ReadonlyArray<SelectOption<T>>
|
|
111
|
+
/** Controlled value; `null` = nothing selected. Omit for internal state. */
|
|
112
|
+
value?: T | null
|
|
113
|
+
defaultValue?: T | null
|
|
114
|
+
/** Fires when the user picks a DIFFERENT option. */
|
|
115
|
+
onValueChange?: (value: T) => void
|
|
116
|
+
/** Shown while nothing is selected. Default "Select…". */
|
|
117
|
+
placeholder?: string
|
|
118
|
+
/** Control height — matches `<Input inputSize>`. Default `md`. */
|
|
119
|
+
size?: SelectSize
|
|
120
|
+
/** Error chrome (`is-error`) + `aria-invalid` on the trigger. A caller's own `aria-invalid` wins. */
|
|
121
|
+
error?: boolean
|
|
122
|
+
/** Controlled open state. Omit for internal state. */
|
|
123
|
+
open?: boolean
|
|
124
|
+
defaultOpen?: boolean
|
|
125
|
+
onOpenChange?: (open: boolean) => void
|
|
126
|
+
/** Preferred side for the listbox. Default `bottom`; flips when it cannot fit. */
|
|
127
|
+
side?: AnchorSide
|
|
128
|
+
/** Alignment along the trigger's edge. Default `start`. */
|
|
129
|
+
align?: AnchorAlign
|
|
130
|
+
/** Extra classes on the listbox surface. */
|
|
131
|
+
listboxClassName?: string
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const SHELL_SIZE_CLASS: Record<SelectSize, string> = {
|
|
135
|
+
sm: 'size-sm',
|
|
136
|
+
md: '',
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Mirrors `.input-shell input` / `.input-shell.size-sm input` typography. */
|
|
140
|
+
const VALUE_SIZE_CLASS: Record<SelectSize, string> = {
|
|
141
|
+
sm: 'gap-2 pl-2.5 text-[12.5px]',
|
|
142
|
+
md: 'gap-2 pl-3 text-[13.5px]',
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The listbox popup chrome, shared with Combobox so the two lists read as
|
|
147
|
+
* one family (Popover / DropdownMenu share `POPOVER_SURFACE_STYLE` the same
|
|
148
|
+
* way).
|
|
149
|
+
*/
|
|
150
|
+
export const LISTBOX_CLASS =
|
|
151
|
+
'fixed z-[100] max-h-[min(60vh,20rem)] overflow-y-auto rounded-[var(--radius-lg)] p-1.5 outline-none'
|
|
152
|
+
|
|
153
|
+
/** Type-ahead buffer lifetime: a pause this long starts a fresh search. */
|
|
154
|
+
const TYPEAHEAD_RESET_MS = 500
|
|
155
|
+
|
|
156
|
+
function isPrintableKey(event: ReactKeyboardEvent): boolean {
|
|
157
|
+
return event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function sanitizeId(id: string): string {
|
|
161
|
+
return id.replace(/[^a-zA-Z0-9_-]/g, '')
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export interface ListboxOptionProps {
|
|
165
|
+
id: string
|
|
166
|
+
label: string
|
|
167
|
+
description?: string | undefined
|
|
168
|
+
icon?: ReactNode
|
|
169
|
+
/** Trailing count — tabular, muted. */
|
|
170
|
+
count?: number | undefined
|
|
171
|
+
selected: boolean
|
|
172
|
+
highlighted: boolean
|
|
173
|
+
disabled?: boolean | undefined
|
|
174
|
+
onSelect: () => void
|
|
175
|
+
onHighlight: () => void
|
|
176
|
+
/** Replaces the default body (icon / label / description / count). The check mark stays. */
|
|
177
|
+
children?: ReactNode
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* One row of a listbox — used by Select and Combobox. A `<button>` rather
|
|
182
|
+
* than a `<div role="option">` so it is clickable without a keyboard handler
|
|
183
|
+
* of its own; `tabIndex={-1}` keeps it out of the tab order (the trigger /
|
|
184
|
+
* input owns the keyboard, per `aria-activedescendant`). Disabled rows use
|
|
185
|
+
* `aria-disabled`, NOT the `disabled` attribute: a disabled control swallows
|
|
186
|
+
* the mousedown the listbox prevents to keep focus on the trigger / in the
|
|
187
|
+
* input, so clicking one would blur the Combobox and close the list.
|
|
188
|
+
*/
|
|
189
|
+
export function ListboxOption({
|
|
190
|
+
id,
|
|
191
|
+
label,
|
|
192
|
+
description,
|
|
193
|
+
icon,
|
|
194
|
+
count,
|
|
195
|
+
selected,
|
|
196
|
+
highlighted,
|
|
197
|
+
disabled,
|
|
198
|
+
onSelect,
|
|
199
|
+
onHighlight,
|
|
200
|
+
children,
|
|
201
|
+
}: ListboxOptionProps) {
|
|
202
|
+
return (
|
|
203
|
+
<button
|
|
204
|
+
type="button"
|
|
205
|
+
role="option"
|
|
206
|
+
id={id}
|
|
207
|
+
aria-selected={selected}
|
|
208
|
+
aria-disabled={disabled ? true : undefined}
|
|
209
|
+
tabIndex={-1}
|
|
210
|
+
data-slot="listbox-option"
|
|
211
|
+
data-highlighted={highlighted ? '' : undefined}
|
|
212
|
+
data-selected={selected ? '' : undefined}
|
|
213
|
+
data-disabled={disabled ? '' : undefined}
|
|
214
|
+
className={cn(
|
|
215
|
+
'flex w-full cursor-default items-center gap-2.5 text-left',
|
|
216
|
+
'rounded-[var(--radius-md)] px-2.5 py-2 text-[13px] font-medium',
|
|
217
|
+
'outline-none transition-colors',
|
|
218
|
+
'aria-disabled:pointer-events-none aria-disabled:opacity-50',
|
|
219
|
+
)}
|
|
220
|
+
style={{
|
|
221
|
+
background: highlighted ? 'rgb(var(--surface-overlay))' : 'transparent',
|
|
222
|
+
color: 'rgb(var(--foreground))',
|
|
223
|
+
}}
|
|
224
|
+
onClick={() => {
|
|
225
|
+
if (!disabled) onSelect()
|
|
226
|
+
}}
|
|
227
|
+
// pointermove, not mouseenter: when the arrow keys scroll the list
|
|
228
|
+
// under a resting pointer, mouseenter fires and steals the highlight.
|
|
229
|
+
onPointerMove={() => {
|
|
230
|
+
if (!disabled && !highlighted) onHighlight()
|
|
231
|
+
}}
|
|
232
|
+
>
|
|
233
|
+
{children ?? (
|
|
234
|
+
<>
|
|
235
|
+
{icon ? <span className="inline-flex shrink-0 items-center">{icon}</span> : null}
|
|
236
|
+
<span className="min-w-0 flex-1">
|
|
237
|
+
<span className="block truncate">{label}</span>
|
|
238
|
+
{description ? (
|
|
239
|
+
<span
|
|
240
|
+
className="block truncate text-[11.5px] font-normal"
|
|
241
|
+
style={{ color: 'rgb(var(--text-tertiary))' }}
|
|
242
|
+
>
|
|
243
|
+
{description}
|
|
244
|
+
</span>
|
|
245
|
+
) : null}
|
|
246
|
+
</span>
|
|
247
|
+
{typeof count === 'number' ? (
|
|
248
|
+
<span
|
|
249
|
+
className="shrink-0 text-[11px] font-medium tabular-nums"
|
|
250
|
+
style={{ color: 'rgb(var(--text-muted))' }}
|
|
251
|
+
>
|
|
252
|
+
{count.toLocaleString()}
|
|
253
|
+
</span>
|
|
254
|
+
) : null}
|
|
255
|
+
</>
|
|
256
|
+
)}
|
|
257
|
+
{/* Reserved slot, so rows do not shift when the selection moves. */}
|
|
258
|
+
<span className="inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center" aria-hidden="true">
|
|
259
|
+
{selected ? (
|
|
260
|
+
<Check className="h-3.5 w-3.5" style={{ color: 'rgb(var(--accent))' }} />
|
|
261
|
+
) : null}
|
|
262
|
+
</span>
|
|
263
|
+
</button>
|
|
264
|
+
)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const SelectImpl = forwardRef<HTMLButtonElement, SelectProps<string>>(function Select(
|
|
268
|
+
{
|
|
269
|
+
options,
|
|
270
|
+
value: valueProp,
|
|
271
|
+
defaultValue = null,
|
|
272
|
+
onValueChange,
|
|
273
|
+
placeholder = 'Select…',
|
|
274
|
+
size = 'md',
|
|
275
|
+
error,
|
|
276
|
+
open: openProp,
|
|
277
|
+
defaultOpen,
|
|
278
|
+
onOpenChange,
|
|
279
|
+
side = 'bottom',
|
|
280
|
+
align = 'start',
|
|
281
|
+
listboxClassName,
|
|
282
|
+
id,
|
|
283
|
+
className,
|
|
284
|
+
style,
|
|
285
|
+
disabled,
|
|
286
|
+
onClick,
|
|
287
|
+
onKeyDown,
|
|
288
|
+
'aria-label': ariaLabel,
|
|
289
|
+
'aria-labelledby': ariaLabelledby,
|
|
290
|
+
...rest
|
|
291
|
+
},
|
|
292
|
+
forwardedRef,
|
|
293
|
+
) {
|
|
294
|
+
const [uncontrolledValue, setUncontrolledValue] = useState<string | null>(defaultValue)
|
|
295
|
+
const isControlledValue = valueProp !== undefined
|
|
296
|
+
const value = isControlledValue ? valueProp : uncontrolledValue
|
|
297
|
+
|
|
298
|
+
const [uncontrolledOpen, setUncontrolledOpen] = useState(Boolean(defaultOpen))
|
|
299
|
+
const isControlledOpen = openProp !== undefined
|
|
300
|
+
const open = isControlledOpen ? Boolean(openProp) : uncontrolledOpen
|
|
301
|
+
const setOpen = useCallback(
|
|
302
|
+
(next: boolean) => {
|
|
303
|
+
if (!isControlledOpen) setUncontrolledOpen(next)
|
|
304
|
+
onOpenChange?.(next)
|
|
305
|
+
},
|
|
306
|
+
[isControlledOpen, onOpenChange],
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
const triggerRef = useRef<HTMLButtonElement | null>(null)
|
|
310
|
+
const listboxRef = useRef<HTMLDivElement | null>(null)
|
|
311
|
+
|
|
312
|
+
const baseId = sanitizeId(useId())
|
|
313
|
+
const triggerId = id ?? `select-${baseId}`
|
|
314
|
+
const listboxId = `select-listbox-${baseId}`
|
|
315
|
+
const optionId = (index: number) => `${listboxId}-opt-${index}`
|
|
316
|
+
|
|
317
|
+
const selectedIndex = useMemo(
|
|
318
|
+
() => options.findIndex((option) => option.value === value),
|
|
319
|
+
[options, value],
|
|
320
|
+
)
|
|
321
|
+
const selected = selectedIndex >= 0 ? options[selectedIndex] : undefined
|
|
322
|
+
const firstEnabled = options.findIndex((option) => !option.disabled)
|
|
323
|
+
let lastEnabled = -1
|
|
324
|
+
for (let i = options.length - 1; i >= 0; i--) {
|
|
325
|
+
if (!options[i]?.disabled) {
|
|
326
|
+
lastEnabled = i
|
|
327
|
+
break
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// `null` = "no explicit highlight yet": the selected option, else the
|
|
332
|
+
// first enabled one. Reset on every open so a stale index never survives
|
|
333
|
+
// a controlled close.
|
|
334
|
+
const [highlight, setHighlight] = useState<number | null>(null)
|
|
335
|
+
const activeIndex =
|
|
336
|
+
highlight !== null && highlight < options.length
|
|
337
|
+
? highlight
|
|
338
|
+
: selectedIndex >= 0
|
|
339
|
+
? selectedIndex
|
|
340
|
+
: firstEnabled
|
|
341
|
+
|
|
342
|
+
const openList = useCallback(
|
|
343
|
+
(initial: number | null) => {
|
|
344
|
+
setHighlight(initial)
|
|
345
|
+
setOpen(true)
|
|
346
|
+
},
|
|
347
|
+
[setOpen],
|
|
348
|
+
)
|
|
349
|
+
const closeList = useCallback(() => {
|
|
350
|
+
setOpen(false)
|
|
351
|
+
setHighlight(null)
|
|
352
|
+
}, [setOpen])
|
|
353
|
+
|
|
354
|
+
const commit = (index: number) => {
|
|
355
|
+
const option = options[index]
|
|
356
|
+
if (!option || option.disabled) return
|
|
357
|
+
if (option.value !== value) {
|
|
358
|
+
if (!isControlledValue) setUncontrolledValue(option.value)
|
|
359
|
+
onValueChange?.(option.value)
|
|
360
|
+
}
|
|
361
|
+
closeList()
|
|
362
|
+
triggerRef.current?.focus()
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** Next enabled index in `direction`, or `from` when there is none. */
|
|
366
|
+
const step = (from: number, direction: 1 | -1): number => {
|
|
367
|
+
for (let i = from + direction; i >= 0 && i < options.length; i += direction) {
|
|
368
|
+
if (!options[i]?.disabled) return i
|
|
369
|
+
}
|
|
370
|
+
return from
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const typeaheadRef = useRef<{ text: string; timer: number | null }>({ text: '', timer: null })
|
|
374
|
+
useEffect(() => {
|
|
375
|
+
const state = typeaheadRef.current
|
|
376
|
+
return () => {
|
|
377
|
+
if (state.timer !== null) window.clearTimeout(state.timer)
|
|
378
|
+
}
|
|
379
|
+
}, [])
|
|
380
|
+
|
|
381
|
+
/** APG type-ahead: repeated letters cycle through their matches, a prefix narrows. */
|
|
382
|
+
const typeahead = (char: string, from: number): number | null => {
|
|
383
|
+
const state = typeaheadRef.current
|
|
384
|
+
if (state.timer !== null) window.clearTimeout(state.timer)
|
|
385
|
+
state.text += char.toLowerCase()
|
|
386
|
+
state.timer = window.setTimeout(() => {
|
|
387
|
+
state.text = ''
|
|
388
|
+
state.timer = null
|
|
389
|
+
}, TYPEAHEAD_RESET_MS)
|
|
390
|
+
|
|
391
|
+
const typed = state.text
|
|
392
|
+
const first = typed.charAt(0)
|
|
393
|
+
const repeated = typed.length > 1 && typed.split('').every((c) => c === first)
|
|
394
|
+
const needle = repeated ? first : typed
|
|
395
|
+
const count = options.length
|
|
396
|
+
if (count === 0) return null
|
|
397
|
+
const startAt = needle.length === 1 ? from + 1 : Math.max(from, 0)
|
|
398
|
+
for (let k = 0; k < count; k++) {
|
|
399
|
+
const i = (startAt + k) % count
|
|
400
|
+
const option = options[i]
|
|
401
|
+
if (option && !option.disabled && option.label.toLowerCase().startsWith(needle)) return i
|
|
402
|
+
}
|
|
403
|
+
return null
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const handleKeyDown = (event: ReactKeyboardEvent<HTMLButtonElement>) => {
|
|
407
|
+
onKeyDown?.(event)
|
|
408
|
+
if (event.defaultPrevented) return
|
|
409
|
+
const { key } = event
|
|
410
|
+
|
|
411
|
+
if (!open) {
|
|
412
|
+
if (key === 'ArrowDown' || key === 'ArrowUp' || key === 'Enter' || key === ' ') {
|
|
413
|
+
event.preventDefault()
|
|
414
|
+
openList(null)
|
|
415
|
+
return
|
|
416
|
+
}
|
|
417
|
+
if (key === 'Home' || key === 'End') {
|
|
418
|
+
event.preventDefault()
|
|
419
|
+
openList(key === 'Home' ? firstEnabled : lastEnabled)
|
|
420
|
+
return
|
|
421
|
+
}
|
|
422
|
+
if (isPrintableKey(event)) {
|
|
423
|
+
event.preventDefault()
|
|
424
|
+
openList(typeahead(key, selectedIndex))
|
|
425
|
+
}
|
|
426
|
+
return
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
switch (key) {
|
|
430
|
+
case 'ArrowDown':
|
|
431
|
+
event.preventDefault()
|
|
432
|
+
setHighlight(step(activeIndex, 1))
|
|
433
|
+
return
|
|
434
|
+
case 'ArrowUp':
|
|
435
|
+
event.preventDefault()
|
|
436
|
+
setHighlight(step(activeIndex, -1))
|
|
437
|
+
return
|
|
438
|
+
case 'Home':
|
|
439
|
+
event.preventDefault()
|
|
440
|
+
setHighlight(firstEnabled)
|
|
441
|
+
return
|
|
442
|
+
case 'End':
|
|
443
|
+
event.preventDefault()
|
|
444
|
+
setHighlight(lastEnabled)
|
|
445
|
+
return
|
|
446
|
+
case 'Enter':
|
|
447
|
+
case ' ':
|
|
448
|
+
event.preventDefault()
|
|
449
|
+
commit(activeIndex)
|
|
450
|
+
return
|
|
451
|
+
case 'Tab':
|
|
452
|
+
// Close without committing; let focus move on.
|
|
453
|
+
closeList()
|
|
454
|
+
return
|
|
455
|
+
case 'Escape':
|
|
456
|
+
// The shared layer stack answers Escape (topmost layer only).
|
|
457
|
+
return
|
|
458
|
+
default:
|
|
459
|
+
if (isPrintableKey(event)) {
|
|
460
|
+
event.preventDefault()
|
|
461
|
+
const match = typeahead(key, activeIndex)
|
|
462
|
+
if (match !== null) setHighlight(match)
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const {
|
|
468
|
+
ref: positionRef,
|
|
469
|
+
style: positionStyle,
|
|
470
|
+
placement,
|
|
471
|
+
positioned,
|
|
472
|
+
} = useAnchoredPosition<HTMLDivElement>({
|
|
473
|
+
anchorRef: triggerRef,
|
|
474
|
+
side,
|
|
475
|
+
align,
|
|
476
|
+
enabled: open,
|
|
477
|
+
})
|
|
478
|
+
|
|
479
|
+
const layer = useLayer({
|
|
480
|
+
enabled: open,
|
|
481
|
+
kind: 'popover',
|
|
482
|
+
elementRef: listboxRef,
|
|
483
|
+
onEscape: () => {
|
|
484
|
+
closeList()
|
|
485
|
+
triggerRef.current?.focus()
|
|
486
|
+
},
|
|
487
|
+
})
|
|
488
|
+
|
|
489
|
+
useOutsideClick([triggerRef, listboxRef], closeList, { enabled: open, layer })
|
|
490
|
+
|
|
491
|
+
// The list is at least as wide as its trigger, like a native select.
|
|
492
|
+
const [triggerWidth, setTriggerWidth] = useState<number | null>(null)
|
|
493
|
+
useLayoutEffect(() => {
|
|
494
|
+
if (!open) return
|
|
495
|
+
setTriggerWidth(triggerRef.current?.offsetWidth ?? null)
|
|
496
|
+
}, [open])
|
|
497
|
+
|
|
498
|
+
useEffect(() => {
|
|
499
|
+
if (!open) return
|
|
500
|
+
listboxRef.current
|
|
501
|
+
?.querySelector<HTMLElement>('[data-highlighted]')
|
|
502
|
+
?.scrollIntoView({ block: 'nearest' })
|
|
503
|
+
}, [open, activeIndex])
|
|
504
|
+
|
|
505
|
+
const triggerRefs = useMemo(
|
|
506
|
+
() => composeRefs<HTMLButtonElement>(forwardedRef, triggerRef),
|
|
507
|
+
[forwardedRef],
|
|
508
|
+
)
|
|
509
|
+
const listboxRefs = useMemo(
|
|
510
|
+
() => composeRefs<HTMLDivElement>(listboxRef, positionRef),
|
|
511
|
+
[positionRef],
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
return (
|
|
515
|
+
<>
|
|
516
|
+
<button
|
|
517
|
+
ref={triggerRefs}
|
|
518
|
+
id={triggerId}
|
|
519
|
+
type="button"
|
|
520
|
+
role="combobox"
|
|
521
|
+
aria-haspopup="listbox"
|
|
522
|
+
aria-expanded={open}
|
|
523
|
+
aria-controls={open ? listboxId : undefined}
|
|
524
|
+
aria-activedescendant={open && activeIndex >= 0 ? optionId(activeIndex) : undefined}
|
|
525
|
+
aria-label={ariaLabel}
|
|
526
|
+
aria-labelledby={ariaLabelledby}
|
|
527
|
+
aria-invalid={error ? true : undefined}
|
|
528
|
+
disabled={disabled}
|
|
529
|
+
data-slot="select-trigger"
|
|
530
|
+
data-state={open ? 'open' : 'closed'}
|
|
531
|
+
data-size={size}
|
|
532
|
+
data-placeholder={selected ? undefined : ''}
|
|
533
|
+
className={cn(
|
|
534
|
+
'input-shell appearance-none p-0 text-left',
|
|
535
|
+
SHELL_SIZE_CLASS[size],
|
|
536
|
+
error && 'is-error',
|
|
537
|
+
disabled && 'is-disabled',
|
|
538
|
+
className,
|
|
539
|
+
)}
|
|
540
|
+
// `.input-shell` is unlayered CSS and sets `align-items: stretch`;
|
|
541
|
+
// a (layered) Tailwind utility cannot override it, so the one
|
|
542
|
+
// override is inline. Caller style spreads last.
|
|
543
|
+
style={{ alignItems: 'center', ...style }}
|
|
544
|
+
onClick={(event) => {
|
|
545
|
+
onClick?.(event)
|
|
546
|
+
if (event.defaultPrevented) return
|
|
547
|
+
if (open) closeList()
|
|
548
|
+
else openList(null)
|
|
549
|
+
}}
|
|
550
|
+
onKeyDown={handleKeyDown}
|
|
551
|
+
{...rest}
|
|
552
|
+
>
|
|
553
|
+
<span
|
|
554
|
+
data-slot="select-value"
|
|
555
|
+
className={cn(
|
|
556
|
+
'flex min-w-0 flex-1 items-center font-medium leading-[1.4]',
|
|
557
|
+
VALUE_SIZE_CLASS[size],
|
|
558
|
+
)}
|
|
559
|
+
>
|
|
560
|
+
{selected?.icon ? (
|
|
561
|
+
<span className="inline-flex shrink-0 items-center">{selected.icon}</span>
|
|
562
|
+
) : null}
|
|
563
|
+
<span
|
|
564
|
+
className="truncate"
|
|
565
|
+
style={{
|
|
566
|
+
color: selected ? 'rgb(var(--foreground))' : 'rgb(var(--text-tertiary))',
|
|
567
|
+
}}
|
|
568
|
+
>
|
|
569
|
+
{selected ? selected.label : placeholder}
|
|
570
|
+
</span>
|
|
571
|
+
</span>
|
|
572
|
+
<span className="affix" aria-hidden="true">
|
|
573
|
+
<ChevronDown
|
|
574
|
+
className={cn(
|
|
575
|
+
'h-4 w-4 transition-transform duration-[var(--dur-fast)] ease-[var(--ease-out)] motion-reduce:transition-none',
|
|
576
|
+
open && 'rotate-180',
|
|
577
|
+
)}
|
|
578
|
+
/>
|
|
579
|
+
</span>
|
|
580
|
+
</button>
|
|
581
|
+
|
|
582
|
+
{open && typeof document !== 'undefined'
|
|
583
|
+
? createPortal(
|
|
584
|
+
<div
|
|
585
|
+
ref={listboxRefs}
|
|
586
|
+
id={listboxId}
|
|
587
|
+
role="listbox"
|
|
588
|
+
aria-label={ariaLabelledby ? undefined : ariaLabel}
|
|
589
|
+
aria-labelledby={ariaLabelledby ?? (ariaLabel ? undefined : triggerId)}
|
|
590
|
+
tabIndex={-1}
|
|
591
|
+
data-slot="select-listbox"
|
|
592
|
+
data-state="open"
|
|
593
|
+
data-side={placement?.side}
|
|
594
|
+
data-align={placement?.align}
|
|
595
|
+
data-positioned={positioned ? 'true' : 'false'}
|
|
596
|
+
className={cn(LISTBOX_CLASS, positioned && 'ds-enter-pop', listboxClassName)}
|
|
597
|
+
style={{
|
|
598
|
+
...positionStyle,
|
|
599
|
+
...POPOVER_SURFACE_STYLE,
|
|
600
|
+
...(triggerWidth ? { minWidth: triggerWidth } : undefined),
|
|
601
|
+
}}
|
|
602
|
+
// Keep DOM focus on the trigger: a click in the list must not
|
|
603
|
+
// blur it (the keyboard model lives there).
|
|
604
|
+
onMouseDown={(event) => event.preventDefault()}
|
|
605
|
+
>
|
|
606
|
+
{options.length === 0 ? (
|
|
607
|
+
<div
|
|
608
|
+
data-slot="select-empty"
|
|
609
|
+
className="px-2.5 py-2 text-[12.5px]"
|
|
610
|
+
style={{ color: 'rgb(var(--text-tertiary))' }}
|
|
611
|
+
>
|
|
612
|
+
No options
|
|
613
|
+
</div>
|
|
614
|
+
) : (
|
|
615
|
+
options.map((option, index) => (
|
|
616
|
+
<ListboxOption
|
|
617
|
+
key={option.value}
|
|
618
|
+
id={optionId(index)}
|
|
619
|
+
label={option.label}
|
|
620
|
+
description={option.description}
|
|
621
|
+
icon={option.icon}
|
|
622
|
+
selected={option.value === value}
|
|
623
|
+
highlighted={index === activeIndex}
|
|
624
|
+
disabled={option.disabled}
|
|
625
|
+
onSelect={() => commit(index)}
|
|
626
|
+
onHighlight={() => setHighlight(index)}
|
|
627
|
+
/>
|
|
628
|
+
))
|
|
629
|
+
)}
|
|
630
|
+
</div>,
|
|
631
|
+
document.body,
|
|
632
|
+
)
|
|
633
|
+
: null}
|
|
634
|
+
</>
|
|
635
|
+
)
|
|
636
|
+
})
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* `forwardRef` erases the component's type parameter (it is instantiated at
|
|
640
|
+
* `string` above). This assertion restores the generic signature so
|
|
641
|
+
* `onValueChange` narrows to the union of the option values a caller
|
|
642
|
+
* passed; it changes nothing at runtime.
|
|
643
|
+
*/
|
|
644
|
+
export const Select = SelectImpl as <T extends string = string>(
|
|
645
|
+
props: SelectProps<T> & RefAttributes<HTMLButtonElement>,
|
|
646
|
+
) => ReactElement | null
|