@estiva-app/ui 0.14.0 → 0.16.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 (56) hide show
  1. package/README.md +28 -0
  2. package/dist/Breadcrumb.d.ts.map +1 -1
  3. package/dist/ChipInput.d.ts +14 -1
  4. package/dist/ChipInput.d.ts.map +1 -1
  5. package/dist/CommandPalette.d.ts +114 -0
  6. package/dist/CommandPalette.d.ts.map +1 -0
  7. package/dist/Menu.d.ts +4 -0
  8. package/dist/Menu.d.ts.map +1 -1
  9. package/dist/Toast.d.ts.map +1 -1
  10. package/dist/eslint/escape.d.ts +61 -0
  11. package/dist/eslint/escape.d.ts.map +1 -0
  12. package/dist/eslint/has-a-page-and-a-story.d.ts +4 -0
  13. package/dist/eslint/has-a-page-and-a-story.d.ts.map +1 -0
  14. package/dist/eslint/index.d.ts +59 -0
  15. package/dist/eslint/index.d.ts.map +1 -0
  16. package/dist/eslint/index.js +286 -0
  17. package/dist/eslint/index.js.map +7 -0
  18. package/dist/eslint/no-hand-rolled-behaviour.d.ts +3 -0
  19. package/dist/eslint/no-hand-rolled-behaviour.d.ts.map +1 -0
  20. package/dist/eslint/no-raw-button.d.ts +11 -0
  21. package/dist/eslint/no-raw-button.d.ts.map +1 -0
  22. package/dist/eslint/raw-element-outside-a-wrapper.d.ts +29 -0
  23. package/dist/eslint/raw-element-outside-a-wrapper.d.ts.map +1 -0
  24. package/dist/index.d.ts +1 -0
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +614 -292
  27. package/dist/index.js.map +4 -4
  28. package/package.json +9 -1
  29. package/src/Avatar.tsx +1 -1
  30. package/src/AvatarGroup.tsx +1 -1
  31. package/src/Breadcrumb.tsx +6 -2
  32. package/src/ChipInput.mdx +9 -0
  33. package/src/ChipInput.stories.tsx +18 -0
  34. package/src/ChipInput.test.tsx +18 -0
  35. package/src/ChipInput.tsx +18 -4
  36. package/src/CommandPalette.mdx +133 -0
  37. package/src/CommandPalette.stories.tsx +416 -0
  38. package/src/CommandPalette.test.tsx +392 -0
  39. package/src/CommandPalette.tsx +643 -0
  40. package/src/Menu.tsx +4 -2
  41. package/src/Select.test.tsx +20 -3
  42. package/src/Toast.tsx +12 -24
  43. package/src/eslint/escape.ts +112 -0
  44. package/src/eslint/has-a-page-and-a-story.test.ts +57 -0
  45. package/src/eslint/has-a-page-and-a-story.ts +97 -0
  46. package/src/eslint/index.test.ts +114 -0
  47. package/src/eslint/index.ts +145 -0
  48. package/src/eslint/no-hand-rolled-behaviour.test.ts +70 -0
  49. package/src/eslint/no-hand-rolled-behaviour.ts +116 -0
  50. package/src/eslint/no-raw-button.test.ts +118 -0
  51. package/src/eslint/no-raw-button.ts +39 -0
  52. package/src/eslint/raw-element-outside-a-wrapper.test.ts +82 -0
  53. package/src/eslint/raw-element-outside-a-wrapper.ts +85 -0
  54. package/src/index.ts +17 -0
  55. package/stories/Choosing.mdx +1 -0
  56. package/stories/TokensPage.tsx +1 -1
@@ -0,0 +1,643 @@
1
+ import {
2
+ createContext,
3
+ useContext,
4
+ useEffect,
5
+ useLayoutEffect,
6
+ useMemo,
7
+ useRef,
8
+ useState,
9
+ type KeyboardEvent,
10
+ type ReactNode,
11
+ type RefObject,
12
+ } from 'react'
13
+ import { Dialog } from '@base-ui/react/dialog'
14
+ import { Autocomplete } from '@base-ui/react/autocomplete'
15
+ import type { BaseUIEvent } from '@base-ui/react/types'
16
+ import { IconLoader2, IconSearch } from '@tabler/icons-react'
17
+ import { cn } from './cn'
18
+ import { Button } from './Button'
19
+ import { InputChip } from './ChipInput'
20
+ import { EmptyState } from './EmptyState'
21
+ import { FieldLine } from './Field'
22
+ import { Kbd } from './Kbd'
23
+ import { EnterHint, MenuItemBody, menuItemClassName } from './Menu'
24
+ import { ScrollArea } from './ScrollArea'
25
+ import { SectionLabel } from './SectionLabel'
26
+ import { SkeletonBar } from './Skeleton'
27
+
28
+ /**
29
+ * A window that searches and runs things: a field, rows in groups, and a
30
+ * footer naming the keys that work right now. Built the way Base UI's own
31
+ * command palette example is — `Dialog` with an `Autocomplete` inside it,
32
+ * the list drawn inline — which is migration decision D8, brought forward
33
+ * by UIG-29 (Katerina, 15–16 September 2026).
34
+ *
35
+ * **Why not `DialogShell`.** A palette has no title bar, no close button and
36
+ * no button row; its top is a field and its bottom is a key footer. Built on
37
+ * the shell it would be the shell with every slot switched off and a second
38
+ * card drawn inside it. `SearchInput` and `ChipInput` do not fit either: the
39
+ * field here drives a list that is always open and never a popup.
40
+ *
41
+ * **What it owns, and what the caller owns** (Katerina, 16 September, P1 and
42
+ * P2). Every key is this component's: the arrows and Enter are Base UI's,
43
+ * and Tab to go in, Ctrl+Backspace to forget, Backspace to go back, Ctrl+Enter
44
+ * to submit and where focus goes are written here, once, so the footer can be
45
+ * written from the same state and never name a key that does nothing. The
46
+ * caller owns the words, which rows exist, what each one does, and the levels
47
+ * — a palette shows one level at a time, a `CommandPaletteSearch` or a
48
+ * `CommandPaletteForm`, and the caller decides which.
49
+ */
50
+
51
+ /* ── The window ─────────────────────────────────────────────────────────── */
52
+
53
+ interface PaletteContextValue {
54
+ where?: string
55
+ modKey: string
56
+ popupRef: RefObject<HTMLDivElement | null>
57
+ }
58
+
59
+ const PaletteContext = createContext<PaletteContextValue | null>(null)
60
+
61
+ function usePalette(part: string) {
62
+ const palette = useContext(PaletteContext)
63
+ if (!palette) throw new Error(`[@estiva-app/ui] ${part} must be inside a CommandPalette.`)
64
+ return palette
65
+ }
66
+
67
+ export interface CommandPaletteProps {
68
+ open: boolean
69
+ onOpenChange: (open: boolean) => void
70
+ /** The window's name for a screen reader — there is no title on screen. */
71
+ label: string
72
+ /** The footer's left side: where the palette was opened from. */
73
+ where?: string
74
+ /**
75
+ * How the footer spells the modifier in `Ctrl+Backspace` and `Ctrl+Enter` —
76
+ * `Cmd` on a Mac. The keys answer to Ctrl and Cmd either way; the package
77
+ * does not guess the platform, the app says it.
78
+ */
79
+ modKey?: string
80
+ /** One level: a `CommandPaletteSearch` or a `CommandPaletteForm`. */
81
+ children: ReactNode
82
+ }
83
+
84
+ /** The field a level starts on: the search field, or the form's first control. */
85
+ const CONTROL = 'input:not([type="hidden"]):not([tabindex="-1"]), textarea, button:not([tabindex="-1"]), [role="combobox"]'
86
+
87
+ function firstControl(popup: HTMLElement | null): HTMLElement | null {
88
+ if (!popup) return null
89
+ const field = popup.querySelector<HTMLElement>('[data-command-palette-field]')
90
+ if (field) return field
91
+ return popup.querySelector<HTMLElement>('[data-command-palette-fields]')?.querySelector<HTMLElement>(CONTROL) ?? null
92
+ }
93
+
94
+ export function CommandPalette({ open, onOpenChange, label, where, modKey = 'Ctrl', children }: CommandPaletteProps) {
95
+ const popupRef = useRef<HTMLDivElement>(null)
96
+
97
+ /*
98
+ Focus never falls out of a level.
99
+
100
+ Measured in the prototype: when Enter picks a row that then leaves the
101
+ list, or a level is swapped for another, the element that had focus is
102
+ gone, and focus lands on the page or on the dialog's own box. Every key
103
+ after that goes nowhere — Esc still closes, and nothing else works. So
104
+ after every render, a frame later, focus that is nowhere goes to the
105
+ level's first control. Focus that is somewhere — a list opened from a
106
+ field, which is portalled out of this box — is left alone.
107
+ */
108
+ useEffect(() => {
109
+ if (!open) return
110
+ const frame = requestAnimationFrame(() => {
111
+ const popup = popupRef.current
112
+ const active = document.activeElement
113
+ if (popup && (!active || active === document.body || active === popup)) firstControl(popup)?.focus()
114
+ })
115
+ return () => cancelAnimationFrame(frame)
116
+ })
117
+
118
+ const context = useMemo(() => ({ where, modKey, popupRef }), [where, modKey])
119
+
120
+ return (
121
+ <Dialog.Root open={open} onOpenChange={(next) => onOpenChange(next)}>
122
+ <Dialog.Portal>
123
+ <Dialog.Backdrop className="fixed inset-0 z-40 bg-scrim" />
124
+ <Dialog.Viewport className="fixed inset-0 z-50 flex items-start justify-center pt-[16vh]">
125
+ <Dialog.Popup
126
+ ref={popupRef}
127
+ aria-label={label}
128
+ initialFocus={() => firstControl(popupRef.current) ?? true}
129
+ onFocus={(e) => {
130
+ if (e.target === popupRef.current) firstControl(popupRef.current)?.focus()
131
+ }}
132
+ /* `outline-none`: the box is a programmatic focus target, not a
133
+ Tab stop — the same reason as DialogShell's card. */
134
+ className="flex w-[658px] max-w-[calc(100vw-32px)] flex-col overflow-hidden rounded-lg border border-border-default bg-bg-elevated shadow-lg outline-none"
135
+ >
136
+ <PaletteContext.Provider value={context}>{children}</PaletteContext.Provider>
137
+ </Dialog.Popup>
138
+ </Dialog.Viewport>
139
+ </Dialog.Portal>
140
+ </Dialog.Root>
141
+ )
142
+ }
143
+
144
+ /* ── The parts every level shares ───────────────────────────────────────── */
145
+
146
+ /**
147
+ * The level you are in, drawn as a chip before the field. Removing it goes
148
+ * back, and so does Backspace at the start of the field — one meaning for the
149
+ * chip, whichever way you reach it.
150
+ */
151
+ export interface CommandPaletteChip {
152
+ label: string
153
+ /** Before the label, 16px — an icon, a mark. */
154
+ leading?: ReactNode
155
+ onBack: () => void
156
+ }
157
+
158
+ function LevelChip({ chip, onBack }: { chip: CommandPaletteChip; onBack: () => void }) {
159
+ return <InputChip label={chip.label} leading={chip.leading} onRemove={onBack} removeLabel={`Leave ${chip.label}`} truncate className="max-w-[272px] shrink-0" />
160
+ }
161
+
162
+ /** Going back leaves focus in the level you land on, whichever way you went. */
163
+ function useBack(chip: CommandPaletteChip | undefined, popupRef: RefObject<HTMLDivElement | null>) {
164
+ return () => {
165
+ if (!chip) return
166
+ chip.onBack()
167
+ requestAnimationFrame(() => firstControl(popupRef.current)?.focus())
168
+ }
169
+ }
170
+
171
+ type Key = [key: string, word: string]
172
+
173
+ function Footer({ keys }: { keys: Key[] }) {
174
+ const { where } = usePalette('The footer')
175
+ return (
176
+ <div className="flex h-9 shrink-0 items-center gap-4 border-t border-border-subtle px-5 text-caption text-text-secondary signal:font-mono signal:text-small signal:text-text-muted">
177
+ <span className="min-w-0 flex-1 truncate">{where}</span>
178
+ {keys.map(([key, word]) => (
179
+ <span key={key} className="flex shrink-0 items-center gap-1.5">
180
+ <Kbd>{key}</Kbd> {word}
181
+ </span>
182
+ ))}
183
+ </div>
184
+ )
185
+ }
186
+
187
+ const lowerFirst = (s: string) => s.charAt(0).toLowerCase() + s.slice(1)
188
+
189
+ /* ── A level of rows ────────────────────────────────────────────────────── */
190
+
191
+ export interface CommandPaletteRow {
192
+ /** Unique in the level. The footer follows the lit row by it. */
193
+ id: string
194
+ label: string
195
+ /** The second line — where it is, who said it, when. */
196
+ description?: string
197
+ /** A 16px icon, drawn on the row's 32px tile. */
198
+ icon?: ReactNode
199
+ /** Instead of `icon`: something with a look of its own — a face, a status mark — in the same 32px space, with no tile. */
200
+ leading?: ReactNode
201
+ /** Enter, or a click. */
202
+ onSelect: () => void
203
+ /** The row leads to more rows: Tab, or → at the end of the text, goes in, and the row shows a chevron. */
204
+ onGoIn?: () => void
205
+ /** The row is the person's own history: Ctrl+Backspace forgets it. */
206
+ onForget?: () => void
207
+ }
208
+
209
+ export interface CommandPaletteGroup {
210
+ /** The heading over the rows. A group with no rows is not drawn. */
211
+ label: string
212
+ rows: CommandPaletteRow[]
213
+ }
214
+
215
+ export interface CommandPaletteSearchProps {
216
+ query: string
217
+ onQueryChange: (query: string) => void
218
+ placeholder: string
219
+ groups: CommandPaletteGroup[]
220
+ /** The level you are in. Without one, this is the first level and there is nowhere to go back to. */
221
+ chip?: CommandPaletteChip
222
+ /** A line under the rows while more are on their way — "Searching…". */
223
+ pending?: string
224
+ /** The line when there are no rows. Leave it out while rows are still on their way. */
225
+ empty?: string
226
+ /** Above the rows: a `CommandPaletteWorking`, `CommandPaletteAnswer` or `CommandPaletteQuote`. */
227
+ children?: ReactNode
228
+ }
229
+
230
+ type ListGroup = { value: string; items: CommandPaletteRow[] }
231
+
232
+ export function CommandPaletteSearch({ query, onQueryChange, placeholder, groups, chip, pending, empty, children }: CommandPaletteSearchProps) {
233
+ const { modKey, popupRef } = usePalette('CommandPaletteSearch')
234
+ const back = useBack(chip, popupRef)
235
+ const items = useMemo<ListGroup[]>(() => groups.filter((g) => g.rows.length > 0).map((g) => ({ value: g.label, items: g.rows })), [groups])
236
+ const rows = items.flatMap((g) => g.items)
237
+
238
+ /*
239
+ The lit row, followed by id rather than held as an object: a caller that
240
+ builds its rows again on every render hands Base UI new objects with the
241
+ same ids, and a key must run the handler of the row on screen now, not
242
+ of the one that was lit a render ago.
243
+ */
244
+ const [litId, setLitId] = useState<string | undefined>()
245
+ const lit = litId === undefined ? undefined : rows.find((r) => r.id === litId)
246
+
247
+ /*
248
+ The lit row stays lit when rows arrive above it (F7).
249
+
250
+ Base UI keeps the highlight's *position*, not its row: measured, with the
251
+ second row lit, two rows arriving above it handed the highlight to
252
+ whatever now sat second, and Enter would have opened that. Base UI has no
253
+ public way to set the highlight, so the palette walks it back with the
254
+ arrow keys, exactly as a person would. It does so only when Base UI moved
255
+ the highlight by itself ("none") — never after an arrow, the pointer, or
256
+ typing, which starts again from the first row.
257
+ */
258
+ const inputRef = useRef<HTMLInputElement>(null)
259
+ const reported = useRef<{ id?: string; reason?: string }>({})
260
+ const settled = useRef<{ id?: string; query: string; level?: string } | null>(null)
261
+ useLayoutEffect(() => {
262
+ const before = settled.current
263
+ const now = reported.current
264
+ if (before?.id && now.reason === 'none' && now.id !== before.id && before.query === query && before.level === chip?.label) {
265
+ const ids = rows.map((r) => r.id)
266
+ const want = ids.indexOf(before.id)
267
+ const at = now.id === undefined ? -1 : ids.indexOf(now.id)
268
+ const input = inputRef.current
269
+ if (input && want !== -1 && at !== -1) {
270
+ const key = want > at ? 'ArrowDown' : 'ArrowUp'
271
+ for (let step = 0; step < Math.abs(want - at); step++) {
272
+ input.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }))
273
+ }
274
+ }
275
+ }
276
+ settled.current = { id: reported.current.id, query, level: chip?.label }
277
+ })
278
+
279
+ const onKeyDown = (e: BaseUIEvent<KeyboardEvent<HTMLInputElement>>) => {
280
+ if (e.key === 'Home' || e.key === 'End') {
281
+ // Home and End move the text cursor and nothing else (the key list).
282
+ // Base UI also sends the highlight to the first or last row (measured),
283
+ // so its handling stops here and the field's own takes over.
284
+ e.preventBaseUIHandler()
285
+ return
286
+ }
287
+ const field = e.currentTarget
288
+ const collapsed = field.selectionStart === field.selectionEnd
289
+ const atStart = collapsed && field.selectionStart === 0
290
+ const atEnd = collapsed && field.selectionEnd === field.value.length
291
+ if (e.key === 'Backspace' && (e.ctrlKey || e.metaKey)) {
292
+ // Forgetting is for a row that can be forgotten; anywhere else the key
293
+ // deletes a word, as it does in any field.
294
+ if (lit?.onForget) {
295
+ e.preventDefault()
296
+ lit.onForget()
297
+ }
298
+ return
299
+ }
300
+ if (e.key === 'Backspace' && atStart && chip && !e.shiftKey && !e.altKey) {
301
+ e.preventDefault()
302
+ back()
303
+ return
304
+ }
305
+ if (e.key === 'Tab' && !e.shiftKey) {
306
+ // Tab never walks out of the field at a level of rows: it goes in, or
307
+ // it does nothing (the key list).
308
+ e.preventDefault()
309
+ lit?.onGoIn?.()
310
+ return
311
+ }
312
+ if (e.key === 'ArrowRight' && atEnd && lit?.onGoIn && !e.shiftKey) {
313
+ e.preventDefault()
314
+ lit.onGoIn()
315
+ }
316
+ }
317
+
318
+ const keys: Key[] = [
319
+ ...(rows.length > 1 ? ([['↑↓', 'move']] as Key[]) : []),
320
+ ...(lit ? ([lit.onGoIn ? ['Tab', 'go in'] : ['Enter', 'open']] as Key[]) : []),
321
+ ...(lit?.onForget ? ([[`${modKey}+Backspace`, 'forget']] as Key[]) : []),
322
+ // Backspace goes back from the start of the field; it is named while the
323
+ // field is empty, when that is what it will do.
324
+ ...(chip && query === '' ? ([['Backspace', 'back']] as Key[]) : []),
325
+ ['Esc', 'close'],
326
+ ]
327
+
328
+ return (
329
+ <Autocomplete.Root
330
+ items={items}
331
+ mode="none"
332
+ inline
333
+ open
334
+ value={query}
335
+ onValueChange={(value, details) => {
336
+ // Pressing a row would write the row's name into the field.
337
+ if (details.reason === 'item-press') return
338
+ onQueryChange(value)
339
+ }}
340
+ itemToStringValue={(row) => (row as CommandPaletteRow).label}
341
+ /* The first row is always lit, so Enter always has something to do;
342
+ the highlight stays where it is when the pointer leaves; and the
343
+ arrows stop at the ends rather than wrapping (the key list). */
344
+ autoHighlight="always"
345
+ keepHighlight
346
+ loopFocus={false}
347
+ onItemHighlighted={(row, details) => {
348
+ const id = (row as CommandPaletteRow | undefined)?.id
349
+ reported.current = { id, reason: details.reason }
350
+ setLitId(id)
351
+ }}
352
+ >
353
+ <div className="flex h-12 shrink-0 items-center gap-3 border-b border-border-subtle px-5">
354
+ <IconSearch size={16} stroke={1.5} className="shrink-0 text-text-secondary" />
355
+ {chip && <LevelChip chip={chip} onBack={back} />}
356
+ <Autocomplete.Input
357
+ ref={inputRef}
358
+ data-command-palette-field=""
359
+ placeholder={placeholder}
360
+ /* Named by what it asks for. Chrome would fall back to the
361
+ placeholder by itself; said outright, the name does not depend
362
+ on a fallback (ChipInput's field lost its name to one, Finding 6). */
363
+ aria-label={placeholder}
364
+ onKeyDown={onKeyDown}
365
+ className="min-w-0 flex-1 bg-transparent text-input-value text-text-primary outline-none placeholder:text-text-muted"
366
+ />
367
+ </div>
368
+
369
+ {/* The rows' box is a menu's (P4, Katerina 16 September): 8px in from
370
+ the edge, so the lit row's fill sits where it sits in every menu,
371
+ and the tiles line up with the magnifier at 21px. */}
372
+ <ScrollArea viewportClassName="max-h-[420px]" contentClassName="flex flex-col p-2">
373
+ {children != null && <div className="flex flex-col gap-2 px-3 pb-2 pt-3">{children}</div>}
374
+
375
+ <Autocomplete.List className="flex flex-col">
376
+ {(group: ListGroup) => (
377
+ <Autocomplete.Group key={group.value} items={group.items} className="flex flex-col">
378
+ {/* A heading labels the rows, it is not one of them: read
379
+ secondary, as in a menu (MenuSection). */}
380
+ <Autocomplete.GroupLabel className="flex h-7 shrink-0 items-center px-3">
381
+ <SectionLabel className="text-text-secondary">{group.value}</SectionLabel>
382
+ </Autocomplete.GroupLabel>
383
+ <Autocomplete.Collection>
384
+ {(row: CommandPaletteRow) => (
385
+ /* The menu row, as the list's own option — the way Select
386
+ puts it on `Select.Item`. One row, two parts. */
387
+ <Autocomplete.Item key={row.id} value={row} onClick={() => row.onSelect()} className={menuItemClassName({ size: 'tall' })}>
388
+ <MenuItemBody
389
+ size="tall"
390
+ label={row.label}
391
+ description={row.description}
392
+ leading={<RowLeading row={row} />}
393
+ submenu={!!row.onGoIn}
394
+ hint={row.onGoIn ? <Kbd>Tab</Kbd> : <EnterHint />}
395
+ />
396
+ </Autocomplete.Item>
397
+ )}
398
+ </Autocomplete.Collection>
399
+ </Autocomplete.Group>
400
+ )}
401
+ </Autocomplete.List>
402
+
403
+ {/* Both lines stay mounted, as Base UI asks, so a screen reader hears
404
+ them change; they take no room while they say nothing. */}
405
+ <Autocomplete.Status className="flex items-center gap-2 px-3 [&:not(:empty)]:h-8">
406
+ {pending && (
407
+ <>
408
+ <IconLoader2 size={12} stroke={1.5} className="shrink-0 animate-spin text-text-muted" />
409
+ <span className="text-caption text-text-muted">{pending}</span>
410
+ </>
411
+ )}
412
+ </Autocomplete.Status>
413
+ <Autocomplete.Empty className="flex items-center px-3 [&:not(:empty)]:min-h-10">
414
+ {empty && <EmptyState scope="section" message={empty} />}
415
+ </Autocomplete.Empty>
416
+ </ScrollArea>
417
+
418
+ <Footer keys={keys} />
419
+ </Autocomplete.Root>
420
+ )
421
+ }
422
+
423
+ /** Every row leads with the same 32px space, so labels line up whatever leads them. */
424
+ function RowLeading({ row }: { row: CommandPaletteRow }) {
425
+ if (row.icon != null) {
426
+ return <span className="flex size-8 items-center justify-center rounded-sm bg-bg-inset text-text-secondary">{row.icon}</span>
427
+ }
428
+ return <span className="flex size-8 items-center justify-center">{row.leading}</span>
429
+ }
430
+
431
+ /* ── A level that is a form ─────────────────────────────────────────────── */
432
+
433
+ export interface CommandPaletteFormProps {
434
+ /** Names what the form makes, and goes back when removed. */
435
+ chip: CommandPaletteChip
436
+ /** Before the chip, 16px — the mark of whatever the form writes to. */
437
+ icon?: ReactNode
438
+ /** The button's word, and the footer's beside Ctrl+Enter. */
439
+ submitLabel: string
440
+ onSubmit: () => void
441
+ /** Why submitting has to wait — nothing changed yet. The button says it on hover, and Ctrl+Enter does nothing. */
442
+ submitWaits?: string
443
+ /** While the thing is being made: the fields lock, the button reads `button`, and `line` says what is happening. */
444
+ working?: { button: string; line: string }
445
+ /** What went wrong, beside the button. */
446
+ error?: string
447
+ /** The fields, each in a `Field`. */
448
+ children: ReactNode
449
+ }
450
+
451
+ const TEXT_TYPES = new Set(['', 'text', 'search', 'email', 'url', 'tel', 'password', 'number'])
452
+
453
+ /** A field you type into — not a list, and not a chip field, whose Backspace takes a chip. */
454
+ function isTextField(el: Element | null): el is HTMLInputElement | HTMLTextAreaElement {
455
+ if (el instanceof HTMLTextAreaElement) return true
456
+ return el instanceof HTMLInputElement && TEXT_TYPES.has(el.getAttribute('type') ?? '') && el.getAttribute('role') !== 'combobox' && el.tabIndex >= 0
457
+ }
458
+
459
+ export function CommandPaletteForm({ chip, icon, submitLabel, onSubmit, submitWaits, working, error, children }: CommandPaletteFormProps) {
460
+ const { modKey, popupRef } = usePalette('CommandPaletteForm')
461
+ const back = useBack(chip, popupRef)
462
+ const frameRef = useRef<HTMLDivElement>(null)
463
+ const busy = !!working
464
+ const busyRef = useRef(busy)
465
+ busyRef.current = busy
466
+ const returnTo = useRef<HTMLElement | null>(null)
467
+
468
+ /** A field that says it needs something: Base UI marks the `Field` itself. */
469
+ const firstInvalid = () => frameRef.current?.querySelector<HTMLElement>('[data-invalid]')?.querySelector<HTMLElement>(CONTROL) ?? null
470
+
471
+ const submit = () => {
472
+ if (busyRef.current || submitWaits) return
473
+ const active = document.activeElement
474
+ returnTo.current = active instanceof HTMLElement && frameRef.current?.contains(active) ? active : null
475
+ onSubmit()
476
+ // If the caller marked fields instead of starting, the first of them
477
+ // takes focus: the key list's "focus goes to the first".
478
+ requestAnimationFrame(() => {
479
+ if (!busyRef.current) firstInvalid()?.focus()
480
+ })
481
+ }
482
+
483
+ /*
484
+ The lock must not lose focus.
485
+
486
+ Locking disables the fields, and a disabled field drops focus to the page
487
+ — measured in the prototype, where after Ctrl+Enter no key but Esc ever
488
+ worked again, even after the error came back. So while the form works,
489
+ focus sits on the form's own box, where its keys still arrive; when it
490
+ stops, focus goes to the first field that needs something, or back where
491
+ it was, or to the first field.
492
+ */
493
+ useLayoutEffect(() => {
494
+ if (busy) {
495
+ frameRef.current?.focus()
496
+ return
497
+ }
498
+ if (document.activeElement !== frameRef.current) return
499
+ const was = returnTo.current
500
+ const target = firstInvalid() ?? (was?.isConnected && !(was as HTMLButtonElement).disabled ? was : null) ?? firstControl(popupRef.current)
501
+ target?.focus()
502
+ // Runs when the lock changes, and reads the DOM it leaves behind.
503
+ }, [busy])
504
+
505
+ /*
506
+ Backspace goes back only where nothing typed is lost: from an empty text
507
+ field, or from anywhere in a form that has no text field at all (a form
508
+ that is one list). In a list inside a form with text fields it does
509
+ nothing, so walking the form with Tab never throws the draft away.
510
+ */
511
+ const backWorksFrom = (el: Element | null) => {
512
+ const frame = frameRef.current
513
+ if (!frame || busy || !el || !frame.contains(el)) return false
514
+ if (isTextField(el)) return el.value === ''
515
+ return ![...frame.querySelectorAll('input, textarea')].some(isTextField)
516
+ }
517
+
518
+ // The footer names Backspace only where it works, so it follows focus and typing.
519
+ const [backNamed, setBackNamed] = useState(false)
520
+ const measure = () => setBackNamed(backWorksFrom(document.activeElement))
521
+ useLayoutEffect(measure)
522
+
523
+ const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
524
+ if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
525
+ e.preventDefault()
526
+ submit()
527
+ return
528
+ }
529
+ if (e.key === 'Backspace' && !e.ctrlKey && !e.metaKey && !e.altKey && backWorksFrom(e.target as Element)) {
530
+ e.preventDefault()
531
+ back()
532
+ }
533
+ }
534
+
535
+ const keys: Key[] = [
536
+ ...(!busy && !submitWaits ? ([[`${modKey}+Enter`, lowerFirst(submitLabel)]] as Key[]) : []),
537
+ ...(backNamed ? ([['Backspace', 'back']] as Key[]) : []),
538
+ ['Esc', 'close'],
539
+ ]
540
+
541
+ return (
542
+ <div ref={frameRef} tabIndex={-1} onKeyDown={onKeyDown} onFocus={measure} onBlur={measure} onInput={measure} className="flex min-h-0 flex-col outline-none">
543
+ <div className="flex h-12 shrink-0 items-center gap-3 border-b border-border-subtle px-5">
544
+ {icon != null && <span className="flex shrink-0 items-center text-text-secondary">{icon}</span>}
545
+ <LevelChip chip={chip} onBack={back} />
546
+ </div>
547
+ <ScrollArea viewportClassName="max-h-[420px]" contentClassName="flex flex-col px-5 py-4">
548
+ {/* `contents`: the fieldset only locks; the fields lay out as if it
549
+ were not there. */}
550
+ <fieldset data-command-palette-fields="" disabled={busy} className="contents">
551
+ <div className="flex flex-col gap-4">{children}</div>
552
+ </fieldset>
553
+ </ScrollArea>
554
+ <div className="flex shrink-0 items-center gap-3 px-5 pb-4">
555
+ <div className="min-w-0 flex-1">
556
+ {working ? <FieldLine>{working.line}</FieldLine> : error ? <FieldLine tone="error">{error}</FieldLine> : null}
557
+ </div>
558
+ <Button
559
+ variant="primary"
560
+ onClick={submit}
561
+ disabled={busy}
562
+ disabledReason={busy ? undefined : submitWaits}
563
+ leadingIcon={busy ? <IconLoader2 size={16} stroke={1.5} className="animate-spin" /> : undefined}
564
+ >
565
+ {working ? working.button : submitLabel}
566
+ </Button>
567
+ </div>
568
+ <Footer keys={keys} />
569
+ </div>
570
+ )
571
+ }
572
+
573
+ /* ── What sits above the rows ───────────────────────────────────────────── */
574
+
575
+ const BAR_WIDTHS = ['w-4/5', 'w-3/5', 'w-2/3']
576
+
577
+ export interface CommandPaletteWorkingProps {
578
+ /** What is happening — "Reading 12 messages…". */
579
+ children: ReactNode
580
+ /** Grey bars under the line, where the result will be. Default 2. */
581
+ bars?: 0 | 1 | 2 | 3
582
+ }
583
+
584
+ /** A line saying what is being worked on, with grey bars where the result will land. */
585
+ export function CommandPaletteWorking({ children, bars = 2 }: CommandPaletteWorkingProps) {
586
+ return (
587
+ <div role="status" className="flex flex-col gap-2">
588
+ <span className="flex items-center gap-2 text-caption text-text-secondary">
589
+ <IconLoader2 size={14} stroke={1.5} className="shrink-0 animate-spin" />
590
+ {children}
591
+ </span>
592
+ {BAR_WIDTHS.slice(0, bars).map((width) => (
593
+ <SkeletonBar key={width} className={cn('h-3', width)} />
594
+ ))}
595
+ </div>
596
+ )
597
+ }
598
+
599
+ export interface CommandPaletteAnswerProps {
600
+ /**
601
+ * The answer's text. A blank line starts a new paragraph, and a number in
602
+ * square brackets — `[1]` — is drawn as a small mark pointing at the row of
603
+ * the same number below.
604
+ */
605
+ children: string
606
+ /** A quiet line under the answer — what a key will do now. */
607
+ note?: string
608
+ }
609
+
610
+ /** A written answer, with marks that point at the rows it came from. */
611
+ export function CommandPaletteAnswer({ children, note }: CommandPaletteAnswerProps) {
612
+ const paragraphs = children.split(/\n\s*\n/).filter((p) => p.trim() !== '')
613
+ return (
614
+ <div className="flex flex-col gap-2">
615
+ {paragraphs.map((paragraph, i) => (
616
+ <p key={i} className="whitespace-pre-wrap text-body-2 text-text-primary">
617
+ {/* A mark belongs to the word before it: the space a writer leaves
618
+ before "[1]" would stand between them as a gap. */}
619
+ {paragraph.split(/\s*(\[\d+\])/).map((part, j) =>
620
+ /^\[\d+\]$/.test(part) ? (
621
+ <sup key={j} className="ml-0.5 font-mono text-small text-accent-primary">
622
+ {part.slice(1, -1)}
623
+ </sup>
624
+ ) : (
625
+ part
626
+ ),
627
+ )}
628
+ </p>
629
+ ))}
630
+ {note && <FieldLine>{note}</FieldLine>}
631
+ </div>
632
+ )
633
+ }
634
+
635
+ export interface CommandPaletteQuoteProps {
636
+ /** Text written for someone to use — shown as it will be used, line breaks kept. */
637
+ children: string
638
+ }
639
+
640
+ /** Text written to be used elsewhere, set off by a rule at its left edge. */
641
+ export function CommandPaletteQuote({ children }: CommandPaletteQuoteProps) {
642
+ return <p className="whitespace-pre-wrap border-l-2 border-border-default pl-3 text-body-2 text-text-primary">{children}</p>
643
+ }
package/src/Menu.tsx CHANGED
@@ -409,8 +409,10 @@ export function menuItemClassName({ size, selected, className }: { size: 'defaul
409
409
  )
410
410
  }
411
411
 
412
- /** Everything inside the row — written once, for the same reason. */
413
- function MenuItemBody({ label, children, size = 'default', description, leading, trailing, hint, shortcut, submenu, destructive, selected }: Pick<MenuItemProps, 'label' | 'children' | 'size' | 'description' | 'leading' | 'trailing' | 'hint' | 'shortcut' | 'submenu' | 'destructive' | 'selected'>) {
412
+ /** Everything inside the row — written once, for the same reason. Shared
413
+ * inside the package like `menuItemClassName`: `CommandPalette` draws it
414
+ * inside Base UI's `Autocomplete.Item`. Not exported from the index. */
415
+ export function MenuItemBody({ label, children, size = 'default', description, leading, trailing, hint, shortcut, submenu, destructive, selected }: Pick<MenuItemProps, 'label' | 'children' | 'size' | 'description' | 'leading' | 'trailing' | 'hint' | 'shortcut' | 'submenu' | 'destructive' | 'selected'>) {
414
416
  const edge =
415
417
  trailing ??
416
418
  (shortcut ? (