import { createContext, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode, type RefObject, } from 'react' import { Dialog } from '@base-ui/react/dialog' import { Autocomplete } from '@base-ui/react/autocomplete' import type { BaseUIEvent } from '@base-ui/react/types' import { IconLoader2, IconSearch } from '@tabler/icons-react' import { cn } from './cn' import { MONO_CAPTION_CLASSES } from './looks' import { Button } from './Button' import { Card } from './Card' import { InputChip } from './ChipInput' import { EmptyState } from './EmptyState' import { FieldLine } from './Field' import { FIELDS_CLASSES, Form } from './Form' import { Kbd } from './Kbd' import { EnterHint, MenuItemBody, menuItemClassName } from './Menu' import { ScrollArea } from './ScrollArea' import { SectionLabel } from './SectionLabel' import { SkeletonBar } from './Skeleton' /** * A window that searches and runs things: a field, rows in groups, and a * footer naming the keys that work right now. Built the way Base UI's own * command palette example is — `Dialog` with an `Autocomplete` inside it, * the list drawn inline — which is migration decision D8, brought forward * by UIG-29 (Katerina, 15–16 September 2026). * * **Why not `DialogShell`.** A palette has no title bar, no close button and * no button row; its top is a field and its bottom is a key footer. Built on * the shell it would be the shell with every slot switched off and a second * card drawn inside it. `SearchInput` and `ChipInput` do not fit either: the * field here drives a list that is always open and never a popup. * * **What it owns, and what the caller owns** (Katerina, 16 September, P1 and * P2). Every key is this component's: the arrows and Enter are Base UI's, * and Tab to go in, Ctrl+Backspace to forget, Backspace to go back, Ctrl+Enter * to submit and where focus goes are written here, once, so the footer can be * written from the same state and never name a key that does nothing. The * caller owns the words, which rows exist, what each one does, and the levels * — a palette shows one level at a time, a `CommandPaletteSearch` or a * `CommandPaletteForm`, and the caller decides which. */ /* ── The window ─────────────────────────────────────────────────────────── */ interface PaletteContextValue { where?: string modKey: string popupRef: RefObject } const PaletteContext = createContext(null) function usePalette(part: string) { const palette = useContext(PaletteContext) if (!palette) throw new Error(`[@estiva-app/ui] ${part} must be inside a CommandPalette.`) return palette } export interface CommandPaletteProps { open: boolean onOpenChange: (open: boolean) => void /** The window's name for a screen reader — there is no title on screen. */ label: string /** The footer's left side: where the palette was opened from. */ where?: string /** * How the footer spells the modifier in `Ctrl+Backspace` and `Ctrl+Enter` — * `Cmd` on a Mac. The keys answer to Ctrl and Cmd either way; the package * does not guess the platform, the app says it. */ modKey?: string /** One level: a `CommandPaletteSearch` or a `CommandPaletteForm`. */ children: ReactNode } /** The field a level starts on: the search field, or the form's first control. */ const CONTROL = 'input:not([type="hidden"]):not([tabindex="-1"]), textarea, button:not([tabindex="-1"]), [role="combobox"]' function firstControl(popup: HTMLElement | null): HTMLElement | null { if (!popup) return null const field = popup.querySelector('[data-command-palette-field]') if (field) return field return popup.querySelector('[data-command-palette-fields]')?.querySelector(CONTROL) ?? null } export function CommandPalette({ open, onOpenChange, label, where, modKey = 'Ctrl', children }: CommandPaletteProps) { const popupRef = useRef(null) /* Focus never falls out of a level. Measured in the prototype: when Enter picks a row that then leaves the list, or a level is swapped for another, the element that had focus is gone, and focus lands on the page or on the dialog's own box. Every key after that goes nowhere — Esc still closes, and nothing else works. So after every render, a frame later, focus that is nowhere goes to the level's first control. Focus that is somewhere — a list opened from a field, which is portalled out of this box — is left alone. */ useEffect(() => { if (!open) return const frame = requestAnimationFrame(() => { const popup = popupRef.current const active = document.activeElement if (popup && (!active || active === document.body || active === popup)) firstControl(popup)?.focus() }) return () => cancelAnimationFrame(frame) }) const context = useMemo(() => ({ where, modKey, popupRef }), [where, modKey]) return ( onOpenChange(next)}> {/* @estiva-escape(no-copied-look): A3 (Katerina, 24 September: leave CommandPalette alone): the palette's window keeps its own border */} firstControl(popupRef.current) ?? true} onFocus={(e) => { if (e.target === popupRef.current) firstControl(popupRef.current)?.focus() }} /* `outline-none`: the box is a programmatic focus target, not a Tab stop — the same reason as DialogShell's card. */ 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" > {children} ) } /* ── The parts every level shares ───────────────────────────────────────── */ /** * The level you are in, drawn as a chip before the field. Removing it goes * back, and so does Backspace at the start of the field — one meaning for the * chip, whichever way you reach it. */ export interface CommandPaletteChip { label: string /** Before the label, 16px — an icon, a mark. */ leading?: ReactNode onBack: () => void } function LevelChip({ chip, onBack }: { chip: CommandPaletteChip; onBack: () => void }) { return } /** Going back leaves focus in the level you land on, whichever way you went. */ function useBack(chip: CommandPaletteChip | undefined, popupRef: RefObject) { return () => { if (!chip) return chip.onBack() requestAnimationFrame(() => firstControl(popupRef.current)?.focus()) } } type Key = [key: string, word: string] function Footer({ keys }: { keys: Key[] }) { const { where } = usePalette('The footer') return (
{where} {keys.map(([key, word]) => ( {key} {word} ))}
) } const lowerFirst = (s: string) => s.charAt(0).toLowerCase() + s.slice(1) /* ── A level of rows ────────────────────────────────────────────────────── */ export interface CommandPaletteRow { /** Unique in the level. The footer follows the lit row by it. */ id: string label: string /** The second line — where it is, who said it, when; a file's summary. */ description?: string /** After the label, muted: where a file sits (an issue's ref and project) when its second line is its summary. */ aside?: string /** A 16px icon, drawn on the row's 32px tile. */ icon?: ReactNode /** Instead of `icon`: something with a look of its own — a face, a status mark — in the same 32px space, with no tile. */ leading?: ReactNode /** Enter, or a click. */ onSelect: () => void /** The row leads to more rows: Tab, or → at the end of the text, goes in, and the row shows a chevron. */ onGoIn?: () => void /** The row is the person's own history: Ctrl+Backspace forgets it. */ onForget?: () => void } export interface CommandPaletteGroup { /** The heading over the rows. A group with no rows is not drawn. */ label: string rows: CommandPaletteRow[] } export interface CommandPaletteSearchProps { query: string onQueryChange: (query: string) => void placeholder: string groups: CommandPaletteGroup[] /** The level you are in. Without one, this is the first level and there is nowhere to go back to. */ chip?: CommandPaletteChip /** A line under the rows while more are on their way — "Searching…". */ pending?: string /** * Quiet lines under the rows, for what a person should know about them and * cannot act on — "2 more where you cannot open them", "the search was * refused, so this list is incomplete". Plain lines, never rows: the arrows * do not stop on them. */ notes?: string[] /** The line when there are no rows. Leave it out while rows are still on their way. */ empty?: string /** Above the rows: a `CommandPaletteWorking`, `CommandPaletteAnswer` or `CommandPaletteQuote`. */ children?: ReactNode } type ListGroup = { value: string; items: CommandPaletteRow[] } /** The palette as a search: a query field, rows in labelled groups walked with * the arrow keys, and what to show while nothing has arrived and when nothing * matches. */ export function CommandPaletteSearch({ query, onQueryChange, placeholder, groups, chip, pending, notes = [], empty, children }: CommandPaletteSearchProps) { const { modKey, popupRef } = usePalette('CommandPaletteSearch') const back = useBack(chip, popupRef) const items = useMemo(() => groups.filter((g) => g.rows.length > 0).map((g) => ({ value: g.label, items: g.rows })), [groups]) const rows = items.flatMap((g) => g.items) /* The lit row, followed by id rather than held as an object: a caller that builds its rows again on every render hands Base UI new objects with the same ids, and a key must run the handler of the row on screen now, not of the one that was lit a render ago. */ const [litId, setLitId] = useState() const lit = litId === undefined ? undefined : rows.find((r) => r.id === litId) /* The lit row stays lit when rows arrive above it (F7). Base UI keeps the highlight's *position*, not its row: measured, with the second row lit, two rows arriving above it handed the highlight to whatever now sat second, and Enter would have opened that. Base UI has no public way to set the highlight, so the palette walks it back with the arrow keys, exactly as a person would. It does so only when Base UI moved the highlight by itself ("none") — never after an arrow, the pointer, or typing, which starts again from the first row. And only once the person has moved the highlight on this level. Until then the lit row is simply the first one, and a row that arrives above it — a thread's own rows, a moment after opening — becomes the first one. */ const inputRef = useRef(null) const reported = useRef<{ id?: string; reason?: string }>({}) const settled = useRef<{ id?: string; query: string; level?: string } | null>(null) /** The person has moved the highlight since the text or the level last changed. */ const moved = useRef(false) useLayoutEffect(() => { const before = settled.current const now = reported.current const sameLevel = before?.query === query && before?.level === chip?.label if (!sameLevel) moved.current = false if (moved.current && before?.id && now.reason === 'none' && now.id !== before.id && sameLevel) { const ids = rows.map((r) => r.id) const want = ids.indexOf(before.id) const at = now.id === undefined ? -1 : ids.indexOf(now.id) const input = inputRef.current if (input && want !== -1 && at !== -1) { const key = want > at ? 'ArrowDown' : 'ArrowUp' for (let step = 0; step < Math.abs(want - at); step++) { input.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })) } } } settled.current = { id: reported.current.id, query, level: chip?.label } }) const onKeyDown = (e: BaseUIEvent>) => { if (e.key === 'Home' || e.key === 'End') { // Home and End move the text cursor and nothing else (the key list). // Base UI also sends the highlight to the first or last row (measured), // so its handling stops here and the field's own takes over. e.preventBaseUIHandler() return } const field = e.currentTarget const collapsed = field.selectionStart === field.selectionEnd const atStart = collapsed && field.selectionStart === 0 const atEnd = collapsed && field.selectionEnd === field.value.length if (e.key === 'Backspace' && (e.ctrlKey || e.metaKey)) { // Forgetting is for a row that can be forgotten; anywhere else the key // deletes a word, as it does in any field. if (lit?.onForget) { e.preventDefault() lit.onForget() } return } if (e.key === 'Backspace' && atStart && chip && !e.shiftKey && !e.altKey) { e.preventDefault() back() return } if (e.key === 'Tab' && !e.shiftKey) { // Tab never walks out of the field at a level of rows: it goes in, or // it does nothing (the key list). e.preventDefault() lit?.onGoIn?.() return } if (e.key === 'ArrowRight' && atEnd && lit?.onGoIn && !e.shiftKey) { e.preventDefault() lit.onGoIn() } } const keys: Key[] = [ ...(rows.length > 1 ? ([['↑↓', 'move']] as Key[]) : []), ...(lit ? ([lit.onGoIn ? ['Tab', 'go in'] : ['Enter', 'open']] as Key[]) : []), ...(lit?.onForget ? ([[`${modKey}+Backspace`, 'forget']] as Key[]) : []), // Backspace goes back from the start of the field; it is named while the // field is empty, when that is what it will do. ...(chip && query === '' ? ([['Backspace', 'back']] as Key[]) : []), ['Esc', 'close'], ] return ( { // Pressing a row would write the row's name into the field. if (details.reason === 'item-press') return onQueryChange(value) }} itemToStringValue={(row) => (row as CommandPaletteRow).label} /* The first row is always lit, so Enter always has something to do; the highlight stays where it is when the pointer leaves; and the arrows stop at the ends rather than wrapping (the key list). */ autoHighlight="always" keepHighlight loopFocus={false} onItemHighlighted={(row, details) => { const id = (row as CommandPaletteRow | undefined)?.id reported.current = { id, reason: details.reason } if (details.reason !== 'none') moved.current = true setLitId(id) }} >
{chip && } {/* @estiva-escape(no-copied-look): A5 (Katerina, 24 September: leave CommandPalette alone): the palette's search field keeps its own classes */}
{/* The rows' box is a menu's (P4, Katerina 16 September): 8px in from the edge, so the lit row's fill sits where it sits in every menu, and the tiles line up with the magnifier at 21px. */} {children != null &&
{children}
} {(group: ListGroup) => ( {/* A heading labels the rows, it is not one of them: read secondary, as in a menu (MenuSection). 2px under it and between its rows, as every list has (Katerina, 5 October). */} {group.value} {(row: CommandPaletteRow) => ( /* The menu row, as the list's own option — the way Select puts it on `Select.Item`. One row, two parts. */ row.onSelect()} className={menuItemClassName({ size: 'tall' })}> } submenu={!!row.onGoIn} hint={row.onGoIn ? Tab : } /> )} )} {/* Both lines stay mounted, as Base UI asks, so a screen reader hears them change; they take no room while they say nothing. */} {pending && ( <> {pending} )} {notes.map((note) => (
{note}
))} {empty && }