@estiva-app/ui 0.15.0 → 0.16.1
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/Breadcrumb.d.ts.map +1 -1
- package/dist/CommandPalette.d.ts +121 -0
- package/dist/CommandPalette.d.ts.map +1 -0
- package/dist/Menu.d.ts +4 -0
- package/dist/Menu.d.ts.map +1 -1
- package/dist/Toast.d.ts.map +1 -1
- package/dist/eslint/has-a-page-and-a-story.d.ts +4 -0
- package/dist/eslint/has-a-page-and-a-story.d.ts.map +1 -0
- package/dist/eslint/index.d.ts +15 -1
- package/dist/eslint/index.d.ts.map +1 -1
- package/dist/eslint/index.js +176 -8
- package/dist/eslint/index.js.map +3 -3
- package/dist/eslint/no-hand-rolled-behaviour.d.ts +3 -0
- package/dist/eslint/no-hand-rolled-behaviour.d.ts.map +1 -0
- package/dist/eslint/raw-element-outside-a-wrapper.d.ts +29 -0
- package/dist/eslint/raw-element-outside-a-wrapper.d.ts.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +614 -287
- package/dist/index.js.map +4 -4
- package/package.json +5 -1
- package/src/Breadcrumb.tsx +6 -2
- package/src/CommandPalette.mdx +139 -0
- package/src/CommandPalette.stories.tsx +418 -0
- package/src/CommandPalette.test.tsx +415 -0
- package/src/CommandPalette.tsx +664 -0
- package/src/Menu.tsx +4 -2
- package/src/Toast.tsx +12 -24
- package/src/eslint/has-a-page-and-a-story.test.ts +57 -0
- package/src/eslint/has-a-page-and-a-story.ts +97 -0
- package/src/eslint/index.test.ts +35 -3
- package/src/eslint/index.ts +58 -13
- package/src/eslint/no-hand-rolled-behaviour.test.ts +70 -0
- package/src/eslint/no-hand-rolled-behaviour.ts +116 -0
- package/src/eslint/raw-element-outside-a-wrapper.test.ts +82 -0
- package/src/eslint/raw-element-outside-a-wrapper.ts +85 -0
- package/src/index.ts +17 -0
- package/stories/Choosing.mdx +1 -0
|
@@ -0,0 +1,664 @@
|
|
|
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
|
+
/**
|
|
225
|
+
* Quiet lines under the rows, for what a person should know about them and
|
|
226
|
+
* cannot act on — "2 more where you cannot open them", "the search was
|
|
227
|
+
* refused, so this list is incomplete". Plain lines, never rows: the arrows
|
|
228
|
+
* do not stop on them.
|
|
229
|
+
*/
|
|
230
|
+
notes?: string[]
|
|
231
|
+
/** The line when there are no rows. Leave it out while rows are still on their way. */
|
|
232
|
+
empty?: string
|
|
233
|
+
/** Above the rows: a `CommandPaletteWorking`, `CommandPaletteAnswer` or `CommandPaletteQuote`. */
|
|
234
|
+
children?: ReactNode
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
type ListGroup = { value: string; items: CommandPaletteRow[] }
|
|
238
|
+
|
|
239
|
+
export function CommandPaletteSearch({ query, onQueryChange, placeholder, groups, chip, pending, notes = [], empty, children }: CommandPaletteSearchProps) {
|
|
240
|
+
const { modKey, popupRef } = usePalette('CommandPaletteSearch')
|
|
241
|
+
const back = useBack(chip, popupRef)
|
|
242
|
+
const items = useMemo<ListGroup[]>(() => groups.filter((g) => g.rows.length > 0).map((g) => ({ value: g.label, items: g.rows })), [groups])
|
|
243
|
+
const rows = items.flatMap((g) => g.items)
|
|
244
|
+
|
|
245
|
+
/*
|
|
246
|
+
The lit row, followed by id rather than held as an object: a caller that
|
|
247
|
+
builds its rows again on every render hands Base UI new objects with the
|
|
248
|
+
same ids, and a key must run the handler of the row on screen now, not
|
|
249
|
+
of the one that was lit a render ago.
|
|
250
|
+
*/
|
|
251
|
+
const [litId, setLitId] = useState<string | undefined>()
|
|
252
|
+
const lit = litId === undefined ? undefined : rows.find((r) => r.id === litId)
|
|
253
|
+
|
|
254
|
+
/*
|
|
255
|
+
The lit row stays lit when rows arrive above it (F7).
|
|
256
|
+
|
|
257
|
+
Base UI keeps the highlight's *position*, not its row: measured, with the
|
|
258
|
+
second row lit, two rows arriving above it handed the highlight to
|
|
259
|
+
whatever now sat second, and Enter would have opened that. Base UI has no
|
|
260
|
+
public way to set the highlight, so the palette walks it back with the
|
|
261
|
+
arrow keys, exactly as a person would. It does so only when Base UI moved
|
|
262
|
+
the highlight by itself ("none") — never after an arrow, the pointer, or
|
|
263
|
+
typing, which starts again from the first row.
|
|
264
|
+
|
|
265
|
+
And only once the person has moved the highlight on this level. Until then
|
|
266
|
+
the lit row is simply the first one, and a row that arrives above it — a
|
|
267
|
+
thread's own rows, a moment after opening — becomes the first one.
|
|
268
|
+
*/
|
|
269
|
+
const inputRef = useRef<HTMLInputElement>(null)
|
|
270
|
+
const reported = useRef<{ id?: string; reason?: string }>({})
|
|
271
|
+
const settled = useRef<{ id?: string; query: string; level?: string } | null>(null)
|
|
272
|
+
/** The person has moved the highlight since the text or the level last changed. */
|
|
273
|
+
const moved = useRef(false)
|
|
274
|
+
useLayoutEffect(() => {
|
|
275
|
+
const before = settled.current
|
|
276
|
+
const now = reported.current
|
|
277
|
+
const sameLevel = before?.query === query && before?.level === chip?.label
|
|
278
|
+
if (!sameLevel) moved.current = false
|
|
279
|
+
if (moved.current && before?.id && now.reason === 'none' && now.id !== before.id && sameLevel) {
|
|
280
|
+
const ids = rows.map((r) => r.id)
|
|
281
|
+
const want = ids.indexOf(before.id)
|
|
282
|
+
const at = now.id === undefined ? -1 : ids.indexOf(now.id)
|
|
283
|
+
const input = inputRef.current
|
|
284
|
+
if (input && want !== -1 && at !== -1) {
|
|
285
|
+
const key = want > at ? 'ArrowDown' : 'ArrowUp'
|
|
286
|
+
for (let step = 0; step < Math.abs(want - at); step++) {
|
|
287
|
+
input.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }))
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
settled.current = { id: reported.current.id, query, level: chip?.label }
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
const onKeyDown = (e: BaseUIEvent<KeyboardEvent<HTMLInputElement>>) => {
|
|
295
|
+
if (e.key === 'Home' || e.key === 'End') {
|
|
296
|
+
// Home and End move the text cursor and nothing else (the key list).
|
|
297
|
+
// Base UI also sends the highlight to the first or last row (measured),
|
|
298
|
+
// so its handling stops here and the field's own takes over.
|
|
299
|
+
e.preventBaseUIHandler()
|
|
300
|
+
return
|
|
301
|
+
}
|
|
302
|
+
const field = e.currentTarget
|
|
303
|
+
const collapsed = field.selectionStart === field.selectionEnd
|
|
304
|
+
const atStart = collapsed && field.selectionStart === 0
|
|
305
|
+
const atEnd = collapsed && field.selectionEnd === field.value.length
|
|
306
|
+
if (e.key === 'Backspace' && (e.ctrlKey || e.metaKey)) {
|
|
307
|
+
// Forgetting is for a row that can be forgotten; anywhere else the key
|
|
308
|
+
// deletes a word, as it does in any field.
|
|
309
|
+
if (lit?.onForget) {
|
|
310
|
+
e.preventDefault()
|
|
311
|
+
lit.onForget()
|
|
312
|
+
}
|
|
313
|
+
return
|
|
314
|
+
}
|
|
315
|
+
if (e.key === 'Backspace' && atStart && chip && !e.shiftKey && !e.altKey) {
|
|
316
|
+
e.preventDefault()
|
|
317
|
+
back()
|
|
318
|
+
return
|
|
319
|
+
}
|
|
320
|
+
if (e.key === 'Tab' && !e.shiftKey) {
|
|
321
|
+
// Tab never walks out of the field at a level of rows: it goes in, or
|
|
322
|
+
// it does nothing (the key list).
|
|
323
|
+
e.preventDefault()
|
|
324
|
+
lit?.onGoIn?.()
|
|
325
|
+
return
|
|
326
|
+
}
|
|
327
|
+
if (e.key === 'ArrowRight' && atEnd && lit?.onGoIn && !e.shiftKey) {
|
|
328
|
+
e.preventDefault()
|
|
329
|
+
lit.onGoIn()
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const keys: Key[] = [
|
|
334
|
+
...(rows.length > 1 ? ([['↑↓', 'move']] as Key[]) : []),
|
|
335
|
+
...(lit ? ([lit.onGoIn ? ['Tab', 'go in'] : ['Enter', 'open']] as Key[]) : []),
|
|
336
|
+
...(lit?.onForget ? ([[`${modKey}+Backspace`, 'forget']] as Key[]) : []),
|
|
337
|
+
// Backspace goes back from the start of the field; it is named while the
|
|
338
|
+
// field is empty, when that is what it will do.
|
|
339
|
+
...(chip && query === '' ? ([['Backspace', 'back']] as Key[]) : []),
|
|
340
|
+
['Esc', 'close'],
|
|
341
|
+
]
|
|
342
|
+
|
|
343
|
+
return (
|
|
344
|
+
<Autocomplete.Root
|
|
345
|
+
items={items}
|
|
346
|
+
mode="none"
|
|
347
|
+
inline
|
|
348
|
+
open
|
|
349
|
+
value={query}
|
|
350
|
+
onValueChange={(value, details) => {
|
|
351
|
+
// Pressing a row would write the row's name into the field.
|
|
352
|
+
if (details.reason === 'item-press') return
|
|
353
|
+
onQueryChange(value)
|
|
354
|
+
}}
|
|
355
|
+
itemToStringValue={(row) => (row as CommandPaletteRow).label}
|
|
356
|
+
/* The first row is always lit, so Enter always has something to do;
|
|
357
|
+
the highlight stays where it is when the pointer leaves; and the
|
|
358
|
+
arrows stop at the ends rather than wrapping (the key list). */
|
|
359
|
+
autoHighlight="always"
|
|
360
|
+
keepHighlight
|
|
361
|
+
loopFocus={false}
|
|
362
|
+
onItemHighlighted={(row, details) => {
|
|
363
|
+
const id = (row as CommandPaletteRow | undefined)?.id
|
|
364
|
+
reported.current = { id, reason: details.reason }
|
|
365
|
+
if (details.reason !== 'none') moved.current = true
|
|
366
|
+
setLitId(id)
|
|
367
|
+
}}
|
|
368
|
+
>
|
|
369
|
+
<div className="flex h-12 shrink-0 items-center gap-3 border-b border-border-subtle px-5">
|
|
370
|
+
<IconSearch size={16} stroke={1.5} className="shrink-0 text-text-secondary" />
|
|
371
|
+
{chip && <LevelChip chip={chip} onBack={back} />}
|
|
372
|
+
<Autocomplete.Input
|
|
373
|
+
ref={inputRef}
|
|
374
|
+
data-command-palette-field=""
|
|
375
|
+
placeholder={placeholder}
|
|
376
|
+
/* Named by what it asks for. Chrome would fall back to the
|
|
377
|
+
placeholder by itself; said outright, the name does not depend
|
|
378
|
+
on a fallback (ChipInput's field lost its name to one, Finding 6). */
|
|
379
|
+
aria-label={placeholder}
|
|
380
|
+
onKeyDown={onKeyDown}
|
|
381
|
+
className="min-w-0 flex-1 bg-transparent text-input-value text-text-primary outline-none placeholder:text-text-muted"
|
|
382
|
+
/>
|
|
383
|
+
</div>
|
|
384
|
+
|
|
385
|
+
{/* The rows' box is a menu's (P4, Katerina 16 September): 8px in from
|
|
386
|
+
the edge, so the lit row's fill sits where it sits in every menu,
|
|
387
|
+
and the tiles line up with the magnifier at 21px. */}
|
|
388
|
+
<ScrollArea viewportClassName="max-h-[420px]" contentClassName="flex flex-col p-2">
|
|
389
|
+
{children != null && <div className="flex flex-col gap-2 px-3 pb-2 pt-3">{children}</div>}
|
|
390
|
+
|
|
391
|
+
<Autocomplete.List className="flex flex-col">
|
|
392
|
+
{(group: ListGroup) => (
|
|
393
|
+
<Autocomplete.Group key={group.value} items={group.items} className="flex flex-col">
|
|
394
|
+
{/* A heading labels the rows, it is not one of them: read
|
|
395
|
+
secondary, as in a menu (MenuSection). */}
|
|
396
|
+
<Autocomplete.GroupLabel className="flex h-7 shrink-0 items-center px-3">
|
|
397
|
+
<SectionLabel className="text-text-secondary">{group.value}</SectionLabel>
|
|
398
|
+
</Autocomplete.GroupLabel>
|
|
399
|
+
<Autocomplete.Collection>
|
|
400
|
+
{(row: CommandPaletteRow) => (
|
|
401
|
+
/* The menu row, as the list's own option — the way Select
|
|
402
|
+
puts it on `Select.Item`. One row, two parts. */
|
|
403
|
+
<Autocomplete.Item key={row.id} value={row} onClick={() => row.onSelect()} className={menuItemClassName({ size: 'tall' })}>
|
|
404
|
+
<MenuItemBody
|
|
405
|
+
size="tall"
|
|
406
|
+
label={row.label}
|
|
407
|
+
description={row.description}
|
|
408
|
+
leading={<RowLeading row={row} />}
|
|
409
|
+
submenu={!!row.onGoIn}
|
|
410
|
+
hint={row.onGoIn ? <Kbd>Tab</Kbd> : <EnterHint />}
|
|
411
|
+
/>
|
|
412
|
+
</Autocomplete.Item>
|
|
413
|
+
)}
|
|
414
|
+
</Autocomplete.Collection>
|
|
415
|
+
</Autocomplete.Group>
|
|
416
|
+
)}
|
|
417
|
+
</Autocomplete.List>
|
|
418
|
+
|
|
419
|
+
{/* Both lines stay mounted, as Base UI asks, so a screen reader hears
|
|
420
|
+
them change; they take no room while they say nothing. */}
|
|
421
|
+
<Autocomplete.Status className="flex items-center gap-2 px-3 [&:not(:empty)]:h-8">
|
|
422
|
+
{pending && (
|
|
423
|
+
<>
|
|
424
|
+
<IconLoader2 size={12} stroke={1.5} className="shrink-0 animate-spin text-text-muted" />
|
|
425
|
+
<span className="text-caption text-text-muted">{pending}</span>
|
|
426
|
+
</>
|
|
427
|
+
)}
|
|
428
|
+
</Autocomplete.Status>
|
|
429
|
+
{notes.map((note) => (
|
|
430
|
+
<div key={note} className="flex min-h-8 items-center px-3">
|
|
431
|
+
<FieldLine>{note}</FieldLine>
|
|
432
|
+
</div>
|
|
433
|
+
))}
|
|
434
|
+
<Autocomplete.Empty className="flex items-center px-3 [&:not(:empty)]:min-h-10">
|
|
435
|
+
{empty && <EmptyState scope="section" message={empty} />}
|
|
436
|
+
</Autocomplete.Empty>
|
|
437
|
+
</ScrollArea>
|
|
438
|
+
|
|
439
|
+
<Footer keys={keys} />
|
|
440
|
+
</Autocomplete.Root>
|
|
441
|
+
)
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** Every row leads with the same 32px space, so labels line up whatever leads them. */
|
|
445
|
+
function RowLeading({ row }: { row: CommandPaletteRow }) {
|
|
446
|
+
if (row.icon != null) {
|
|
447
|
+
return <span className="flex size-8 items-center justify-center rounded-sm bg-bg-inset text-text-secondary">{row.icon}</span>
|
|
448
|
+
}
|
|
449
|
+
return <span className="flex size-8 items-center justify-center">{row.leading}</span>
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/* ── A level that is a form ─────────────────────────────────────────────── */
|
|
453
|
+
|
|
454
|
+
export interface CommandPaletteFormProps {
|
|
455
|
+
/** Names what the form makes, and goes back when removed. */
|
|
456
|
+
chip: CommandPaletteChip
|
|
457
|
+
/** Before the chip, 16px — the mark of whatever the form writes to. */
|
|
458
|
+
icon?: ReactNode
|
|
459
|
+
/** The button's word, and the footer's beside Ctrl+Enter. */
|
|
460
|
+
submitLabel: string
|
|
461
|
+
onSubmit: () => void
|
|
462
|
+
/** Why submitting has to wait — nothing changed yet. The button says it on hover, and Ctrl+Enter does nothing. */
|
|
463
|
+
submitWaits?: string
|
|
464
|
+
/** While the thing is being made: the fields lock, the button reads `button`, and `line` says what is happening. */
|
|
465
|
+
working?: { button: string; line: string }
|
|
466
|
+
/** What went wrong, beside the button. */
|
|
467
|
+
error?: string
|
|
468
|
+
/** The fields, each in a `Field`. */
|
|
469
|
+
children: ReactNode
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const TEXT_TYPES = new Set(['', 'text', 'search', 'email', 'url', 'tel', 'password', 'number'])
|
|
473
|
+
|
|
474
|
+
/** A field you type into — not a list, and not a chip field, whose Backspace takes a chip. */
|
|
475
|
+
function isTextField(el: Element | null): el is HTMLInputElement | HTMLTextAreaElement {
|
|
476
|
+
if (el instanceof HTMLTextAreaElement) return true
|
|
477
|
+
return el instanceof HTMLInputElement && TEXT_TYPES.has(el.getAttribute('type') ?? '') && el.getAttribute('role') !== 'combobox' && el.tabIndex >= 0
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
export function CommandPaletteForm({ chip, icon, submitLabel, onSubmit, submitWaits, working, error, children }: CommandPaletteFormProps) {
|
|
481
|
+
const { modKey, popupRef } = usePalette('CommandPaletteForm')
|
|
482
|
+
const back = useBack(chip, popupRef)
|
|
483
|
+
const frameRef = useRef<HTMLDivElement>(null)
|
|
484
|
+
const busy = !!working
|
|
485
|
+
const busyRef = useRef(busy)
|
|
486
|
+
busyRef.current = busy
|
|
487
|
+
const returnTo = useRef<HTMLElement | null>(null)
|
|
488
|
+
|
|
489
|
+
/** A field that says it needs something: Base UI marks the `Field` itself. */
|
|
490
|
+
const firstInvalid = () => frameRef.current?.querySelector<HTMLElement>('[data-invalid]')?.querySelector<HTMLElement>(CONTROL) ?? null
|
|
491
|
+
|
|
492
|
+
const submit = () => {
|
|
493
|
+
if (busyRef.current || submitWaits) return
|
|
494
|
+
const active = document.activeElement
|
|
495
|
+
returnTo.current = active instanceof HTMLElement && frameRef.current?.contains(active) ? active : null
|
|
496
|
+
onSubmit()
|
|
497
|
+
// If the caller marked fields instead of starting, the first of them
|
|
498
|
+
// takes focus: the key list's "focus goes to the first".
|
|
499
|
+
requestAnimationFrame(() => {
|
|
500
|
+
if (!busyRef.current) firstInvalid()?.focus()
|
|
501
|
+
})
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/*
|
|
505
|
+
The lock must not lose focus.
|
|
506
|
+
|
|
507
|
+
Locking disables the fields, and a disabled field drops focus to the page
|
|
508
|
+
— measured in the prototype, where after Ctrl+Enter no key but Esc ever
|
|
509
|
+
worked again, even after the error came back. So while the form works,
|
|
510
|
+
focus sits on the form's own box, where its keys still arrive; when it
|
|
511
|
+
stops, focus goes to the first field that needs something, or back where
|
|
512
|
+
it was, or to the first field.
|
|
513
|
+
*/
|
|
514
|
+
useLayoutEffect(() => {
|
|
515
|
+
if (busy) {
|
|
516
|
+
frameRef.current?.focus()
|
|
517
|
+
return
|
|
518
|
+
}
|
|
519
|
+
if (document.activeElement !== frameRef.current) return
|
|
520
|
+
const was = returnTo.current
|
|
521
|
+
const target = firstInvalid() ?? (was?.isConnected && !(was as HTMLButtonElement).disabled ? was : null) ?? firstControl(popupRef.current)
|
|
522
|
+
target?.focus()
|
|
523
|
+
// Runs when the lock changes, and reads the DOM it leaves behind.
|
|
524
|
+
}, [busy])
|
|
525
|
+
|
|
526
|
+
/*
|
|
527
|
+
Backspace goes back only where nothing typed is lost: from an empty text
|
|
528
|
+
field, or from anywhere in a form that has no text field at all (a form
|
|
529
|
+
that is one list). In a list inside a form with text fields it does
|
|
530
|
+
nothing, so walking the form with Tab never throws the draft away.
|
|
531
|
+
*/
|
|
532
|
+
const backWorksFrom = (el: Element | null) => {
|
|
533
|
+
const frame = frameRef.current
|
|
534
|
+
if (!frame || busy || !el || !frame.contains(el)) return false
|
|
535
|
+
if (isTextField(el)) return el.value === ''
|
|
536
|
+
return ![...frame.querySelectorAll('input, textarea')].some(isTextField)
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// The footer names Backspace only where it works, so it follows focus and typing.
|
|
540
|
+
const [backNamed, setBackNamed] = useState(false)
|
|
541
|
+
const measure = () => setBackNamed(backWorksFrom(document.activeElement))
|
|
542
|
+
useLayoutEffect(measure)
|
|
543
|
+
|
|
544
|
+
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
|
545
|
+
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
|
546
|
+
e.preventDefault()
|
|
547
|
+
submit()
|
|
548
|
+
return
|
|
549
|
+
}
|
|
550
|
+
if (e.key === 'Backspace' && !e.ctrlKey && !e.metaKey && !e.altKey && backWorksFrom(e.target as Element)) {
|
|
551
|
+
e.preventDefault()
|
|
552
|
+
back()
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const keys: Key[] = [
|
|
557
|
+
...(!busy && !submitWaits ? ([[`${modKey}+Enter`, lowerFirst(submitLabel)]] as Key[]) : []),
|
|
558
|
+
...(backNamed ? ([['Backspace', 'back']] as Key[]) : []),
|
|
559
|
+
['Esc', 'close'],
|
|
560
|
+
]
|
|
561
|
+
|
|
562
|
+
return (
|
|
563
|
+
<div ref={frameRef} tabIndex={-1} onKeyDown={onKeyDown} onFocus={measure} onBlur={measure} onInput={measure} className="flex min-h-0 flex-col outline-none">
|
|
564
|
+
<div className="flex h-12 shrink-0 items-center gap-3 border-b border-border-subtle px-5">
|
|
565
|
+
{icon != null && <span className="flex shrink-0 items-center text-text-secondary">{icon}</span>}
|
|
566
|
+
<LevelChip chip={chip} onBack={back} />
|
|
567
|
+
</div>
|
|
568
|
+
<ScrollArea viewportClassName="max-h-[420px]" contentClassName="flex flex-col px-5 py-4">
|
|
569
|
+
{/* `contents`: the fieldset only locks; the fields lay out as if it
|
|
570
|
+
were not there. */}
|
|
571
|
+
<fieldset data-command-palette-fields="" disabled={busy} className="contents">
|
|
572
|
+
<div className="flex flex-col gap-4">{children}</div>
|
|
573
|
+
</fieldset>
|
|
574
|
+
</ScrollArea>
|
|
575
|
+
<div className="flex shrink-0 items-center gap-3 px-5 pb-4">
|
|
576
|
+
<div className="min-w-0 flex-1">
|
|
577
|
+
{working ? <FieldLine>{working.line}</FieldLine> : error ? <FieldLine tone="error">{error}</FieldLine> : null}
|
|
578
|
+
</div>
|
|
579
|
+
<Button
|
|
580
|
+
variant="primary"
|
|
581
|
+
onClick={submit}
|
|
582
|
+
disabled={busy}
|
|
583
|
+
disabledReason={busy ? undefined : submitWaits}
|
|
584
|
+
leadingIcon={busy ? <IconLoader2 size={16} stroke={1.5} className="animate-spin" /> : undefined}
|
|
585
|
+
>
|
|
586
|
+
{working ? working.button : submitLabel}
|
|
587
|
+
</Button>
|
|
588
|
+
</div>
|
|
589
|
+
<Footer keys={keys} />
|
|
590
|
+
</div>
|
|
591
|
+
)
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/* ── What sits above the rows ───────────────────────────────────────────── */
|
|
595
|
+
|
|
596
|
+
const BAR_WIDTHS = ['w-4/5', 'w-3/5', 'w-2/3']
|
|
597
|
+
|
|
598
|
+
export interface CommandPaletteWorkingProps {
|
|
599
|
+
/** What is happening — "Reading 12 messages…". */
|
|
600
|
+
children: ReactNode
|
|
601
|
+
/** Grey bars under the line, where the result will be. Default 2. */
|
|
602
|
+
bars?: 0 | 1 | 2 | 3
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/** A line saying what is being worked on, with grey bars where the result will land. */
|
|
606
|
+
export function CommandPaletteWorking({ children, bars = 2 }: CommandPaletteWorkingProps) {
|
|
607
|
+
return (
|
|
608
|
+
<div role="status" className="flex flex-col gap-2">
|
|
609
|
+
<span className="flex items-center gap-2 text-caption text-text-secondary">
|
|
610
|
+
<IconLoader2 size={14} stroke={1.5} className="shrink-0 animate-spin" />
|
|
611
|
+
{children}
|
|
612
|
+
</span>
|
|
613
|
+
{BAR_WIDTHS.slice(0, bars).map((width) => (
|
|
614
|
+
<SkeletonBar key={width} className={cn('h-3', width)} />
|
|
615
|
+
))}
|
|
616
|
+
</div>
|
|
617
|
+
)
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
export interface CommandPaletteAnswerProps {
|
|
621
|
+
/**
|
|
622
|
+
* The answer's text. A blank line starts a new paragraph, and a number in
|
|
623
|
+
* square brackets — `[1]` — is drawn as a small mark pointing at the row of
|
|
624
|
+
* the same number below.
|
|
625
|
+
*/
|
|
626
|
+
children: string
|
|
627
|
+
/** A quiet line under the answer — what a key will do now. */
|
|
628
|
+
note?: string
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/** A written answer, with marks that point at the rows it came from. */
|
|
632
|
+
export function CommandPaletteAnswer({ children, note }: CommandPaletteAnswerProps) {
|
|
633
|
+
const paragraphs = children.split(/\n\s*\n/).filter((p) => p.trim() !== '')
|
|
634
|
+
return (
|
|
635
|
+
<div className="flex flex-col gap-2">
|
|
636
|
+
{paragraphs.map((paragraph, i) => (
|
|
637
|
+
<p key={i} className="whitespace-pre-wrap text-body-2 text-text-primary">
|
|
638
|
+
{/* A mark belongs to the word before it: the space a writer leaves
|
|
639
|
+
before "[1]" would stand between them as a gap. */}
|
|
640
|
+
{paragraph.split(/\s*(\[\d+\])/).map((part, j) =>
|
|
641
|
+
/^\[\d+\]$/.test(part) ? (
|
|
642
|
+
<sup key={j} className="ml-0.5 font-mono text-small text-accent-primary">
|
|
643
|
+
{part.slice(1, -1)}
|
|
644
|
+
</sup>
|
|
645
|
+
) : (
|
|
646
|
+
part
|
|
647
|
+
),
|
|
648
|
+
)}
|
|
649
|
+
</p>
|
|
650
|
+
))}
|
|
651
|
+
{note && <FieldLine>{note}</FieldLine>}
|
|
652
|
+
</div>
|
|
653
|
+
)
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
export interface CommandPaletteQuoteProps {
|
|
657
|
+
/** Text written for someone to use — shown as it will be used, line breaks kept. */
|
|
658
|
+
children: string
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/** Text written to be used elsewhere, set off by a rule at its left edge. */
|
|
662
|
+
export function CommandPaletteQuote({ children }: CommandPaletteQuoteProps) {
|
|
663
|
+
return <p className="whitespace-pre-wrap border-l-2 border-border-default pl-3 text-body-2 text-text-primary">{children}</p>
|
|
664
|
+
}
|
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
|
-
|
|
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 ? (
|