@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
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