Command
Un câmp care filtrează o listă de comenzi sau destinații pe măsură ce scrii — paleta ⌘K, „sari la…", un meniu prea lung ca să fie răsfoit. Compus din ce există: câmpul e .input, rândurile sunt .popover-item. Pe cmdk în React, exact ca în shadcn.
Markup canonic · components/command.json
Demoul e viu (_search.js, chrome de documentație): ↑ ↓ mută cursorul, textul filtrează rândurile, un grup rămas gol dispare. În producție comportamentul vine din cmdk, prin react/command.tsx.
Anatomie · nimic propriu
.input, rândul E .popover-item. Eticheta de grup e .popover-label, separatorul .popover-sep, starea goală .typeahead-empty (sub aliasul .command-empty). Ce adaugă componenta e doar carcasa: rândul de căutare, lista care derulează, subsolul cu taste. Un al doilea rând desenat pentru paletă derivă de rândul de meniu la prima schimbare de token..command alb · 1px --c-rule-card · --r-lg · overflow hidden · FĂRĂ umbră
(nu plutește: e în flux sau într-un .modal, care are scrim)
.command-input rândul de căutare · geometria lui .input-group: padding 4 16 · gap 8
border-bottom 1px --c-rule-soft · iconița `search` în fog
> .input controlul, fără chrome: border 0 · radius 0 · fundal transparent
· fără halo pe focus — carcasa ia bordura, controlul o pierde
(regula din .input-group); suprafața cu focus e comanda
.command-list padding 6 · gap 2 · max-height 300 · overflow-y auto
.command-group etichetă + rândurile ei · coloană cu gap 2
.popover-label 11px · 600 · 0.10em · uppercase · padding 8 10 4
.popover-item 8px 10px · --r-md · 14/20 ink · iconița din stânga fog
.command-shortcut tasta de la capătul rândului — TEXT, mono 11 fog, margin-left auto
.typeahead-meta rândul secundar (unde e rezultatul, un cod) — 12/16 fog
.popover-sep 1px --c-rule-soft · margin 4 0
.command-empty 14/20 fog · centrat · padding 14 10 (= .typeahead-empty)
.command-footer legenda tastelor · padding 8 16 · border-top · 12/16 fog, tastele în ink
Rândul de căutare e un câmp, deci urmează Input group: 16 + icon 16 + gap 8 = textul la 40px, ca în orice căutare din sistem. Rândurile se aliniază pe coloana iconițelor: 6 (listă) + 10 (rând) = 16, sub iconița câmpului. Textul lor e la 42 — gap-ul rândului de meniu e 10, al câmpului 8; fiecare își ține regula. Lista e geometria popoverului — padding 6, deci rândul la --r-md: 20 − 6 = 14.
Instalare
npm i cmdk @radix-ui/react-dialog # copiezi react/command.tsx peste components/ui/command.tsx — e sursă de copiat, nu pachet
API-ul e cel din shadcn/ui, deci fișierul înlocuiește components/ui/command.tsx fără să atingi call-site-urile; ce se schimbă e stratul de stil — clase din bono-ds.css în loc de utilitare. cn nu are nevoie de tailwind-merge. Vezi react/README.md.
Utilizare
import {
Command, CommandInput, CommandList, CommandEmpty,
CommandGroup, CommandItem, CommandSeparator, CommandShortcut,
} from "@/components/ui/command"
<Command>
<CommandInput placeholder="Scrie o comandă sau caută…" />
<CommandList>
<CommandEmpty>Nimic pentru căutarea asta.</CommandEmpty>
<CommandGroup heading="Sugestii">
<CommandItem><Receipt />Emite factură<CommandShortcut>⌘N</CommandShortcut></CommandItem>
<CommandItem><FileUp />Încarcă document</CommandItem>
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Cont">
<CommandItem><User />Profil<CommandShortcut>⌘P</CommandShortcut></CommandItem>
</CommandGroup>
</CommandList>
</Command>
| Export | Primitiva | Clasa DS |
|---|---|---|
Command | cmdk root | .command |
CommandInput | Command.Input într-un wrapper | .command-input > .input (.lg din context, în dialog) |
CommandList | Command.List | .command-list |
CommandGroup | Command.Group | .command-group · antetul e [cmdk-group-heading], alias pentru .popover-label |
CommandItem | Command.Item | .popover-item · cursorul e [data-selected="true"], alias pentru .is-active |
CommandSeparator | Command.Separator | .popover-sep |
CommandEmpty | Command.Empty | .command-empty |
CommandShortcut | <span> | .command-shortcut |
CommandDialog | @radix-ui/react-dialog | .modal-backdrop + .modal.command-dialog + X-ul DS |
Atributele lui cmdk sunt alias-uri în bono-ds.css, nu reguli paralele — același mecanism ca [data-state="checked"] pentru .is-selected la select: cmdk nu lasă un className pe antetul grupului și își pune cursorul pe data-selected, deci fără alias forma React n-ar avea nici etichetă, nici cursor.
Exemplu · Dialog (⌘K)
Comanda într-un .modal: scrimul canonic, X-ul din DS, Esc, focus trap, scroll-lock — toate ale modalului, nimic propriu. Modalul își pierde padding-ul și fundalul bej (suprafața e comanda) și stă în treimea de sus, nu la centru: lista crește și scade cu fiecare literă, iar un dialog centrat ar sări pe verticală la fiecare tastă. Câmpul e .input.lg, ca orice câmp din modal — și de aici lista se face mare singură.
<!-- HTML -->
<div class="modal-backdrop open">
<div class="modal lg command-dialog" role="dialog" aria-modal="true" aria-label="Caută">
<button class="btn outline sm icon-only" type="button" aria-label="Închide"><svg class="ic">…x…</svg></button>
<div class="command">
<div class="command-input"><svg class="ic">…search…</svg><input class="input lg" role="combobox" …></div>
<div class="command-list" role="listbox">…</div>
<div class="command-footer">…</div>
</div>
</div>
</div>
// React — Radix Dialog în clasele de mai sus, câmpul .lg prin context
const [open, setOpen] = useState(false)
useEffect(() => {
const down = (e) => { if (e.key === "k" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); setOpen(o => !o) } }
document.addEventListener("keydown", down)
return () => document.removeEventListener("keydown", down)
}, [])
<CommandDialog open={open} onOpenChange={setOpen} title="Caută" description="Scrie ca să cauți…">
<CommandInput placeholder="Caută o componentă…" />
<CommandList>…</CommandList>
</CommandDialog>
Declanșatorul · .input-group.command-trigger
Intrarea în paletă are rolul unui câmp — „aici cauți" — deci poartă câmpul: exact căutarea din Input group, cu iconița search și scurtătura — un badge alb, .badge.sm.outline — în addon-uri. Controlul însă e un <button class="input">, nu un <input>: aici nu se scrie, click-ul (sau ⌘K) deschide dialogul, unde se scrie. Textul butonului e placeholder-ul — --c-mist, la stânga, tăiat cu elipsă.
<div class="input-group command-trigger">
<button class="input" type="button" aria-haspopup="dialog"
aria-keyshortcuts="Meta+K Control+K">Caută…</button>
<span class="input-group-addon" aria-hidden="true"><svg class="ic">…search…</svg></span>
<span class="input-group-addon inline-end" aria-hidden="true"><kbd class="badge sm outline">⌘K</kbd></span>
</div>
| De ce | Nu |
|---|---|
| arată ca un câmp, fiindcă rolul e de câmp | .btn / .btn.outline — o pastilă se citește „fă ceva", nu „aici cauți" |
e un <button>, fiindcă aici nu se scrie | <input readonly> — anunță un câmp de text în care tastele nu fac nimic |
carcasa ține bordura, hover-ul și halo-ul (:focus-within) | stări proprii pe buton — focusul întors din dialog aprinde câmpul, ca la orice .input-group |
Mărimea o decide câmpul
Nu există .command.lg scris de mână. Scrii .input.lg în rândul de căutare și lista îl urmează — .command:has(> .command-input > .input.lg) — exact cum lista unui select se deduce din .select-trigger.lg. O singură decizie, luată de câmp. Într-un .modal câmpul e oricum .input.lg (modal.md), deci dialogul e mare fără să mai spună nimeni. În React, CommandDialog pune size="lg" pe CommandInput prin context.
| Axă | implicit (.input) | .input.lg |
|---|---|---|
| Suprafață | --r-lg 20 · listă padding 6 | --r-xl 24 · listă padding 10 |
| Rând de căutare | 40 + 4/4 = 48 · padding lateral 16 — .input-group | 44 + 6/6 = 56 · padding lateral 20 — .input-group.lg |
| Rând | 14/20 · padding 8/10 · --r-md | 16/24 · padding 8/10 · --r-md — treapta .popover.lg, lateral 10 |
| Coloana iconițelor | 16 (câmp) = 6 + 10 (rând) | 20 (câmp) = 10 + 10 (rând) |
| Textul câmpului | 40px | 44px |
Aceeași aritmetică ca la .popover.lg: raza suprafeței minus padding-ul listei = raza rândului (24 − 10 = 14, cum 20 − 6 = 14 la implicit). Rândul mare își ține treapta (16/24) dar padding-ul lateral al rândului implicit (10), ca iconițele să cadă sub iconița câmpului, care stă la 20 după regula lui .input-group.lg. Nicio cifră nouă.
Stări
| Stare | Cum | Notă |
|---|---|---|
| cursor | .popover-item.is-active + aria-activedescendant pe input · în React [data-selected="true"] | suprafață --c-bej-1, aceeași ca hover-ul; text și greutate normale. Mouse-ul mută cursorul, nu aprinde un al doilea rând |
| disabled | :disabled pe <button> · [data-disabled="true"] pe <div role="option"> (cmdk) | --c-mist, not-allowed; săgețile îl sar |
| gol | .command-empty în locul rândurilor | „Nimic pentru „qwerty"" — rândul de căutare rămâne, utilizatorul corectează |
| deschis (dialog) | .modal-backdrop.open | focus în câmp; Esc / X / click pe scrim închid și focusul se întoarce în declanșator |
.is-selected sau bifa .popover-check (popover.md § Numai rânduri cu VALOARE). Singura stare de rând în afară de disabled e cursorul.Accesibilitate
input role="combobox" aria-expanded="true" aria-controls=<listă> aria-activedescendant=<rând activ>
aria-autocomplete="list" autocomplete="off"
lista role="listbox" · grupul role="group" aria-labelledby=<etichetă> · rândul role="option"
dialog role="dialog" aria-modal="true" aria-label (sau un titlu .sr-only — Radix îl cere)
tastatură ↑ ↓ mută cursorul · Home/End · Enter execută rândul activ (sau primul)
Esc închide dialogul ȘI întoarce focusul în declanșator · Tab rămâne în dialog
declanșator aria-haspopup="dialog" aria-keyshortcuts="Meta+K Control+K"
Focusul rămâne în .input tot timpul — altfel utilizatorul nu mai poate scrie. Rândul de sub săgeată se marchează cu .is-active plus aria-activedescendant, nu prin mutarea focusului. În React toate astea vin din cmdk și Radix; forma scrisă de mână le reproduce în _search.js doar pentru acest sit.
Căutarea din acest catalog
Primul consumator al componentei e câmpul Caută din bară — declanșatorul, deasupra „Referințe" (⌘K). Indexul — components/_search-index.json — e generat de scripts/build-catalog.py din paginile catalogului: titlul, lede-ul și, pe fiecare bloc, antetul și textul vizibil. Nu se editează; o pagină schimbată fără rebuild pică --check, ca index.json.
| Ordinea | Potrivire în | Ce deschide |
|---|---|---|
| 1 · Pagini | titlul paginii sau eticheta din sidebar (prefix > început de cuvânt > substring) | pagina |
| 2 · În conținut | un antet de secțiune, apoi textul ei | blocul (#id), nu doar pagina |
Canalele sunt pe trepte care nu se amestecă (100 / 20 / 5): oricâte potriviri în text nu pot urca peste una în titlu. Toți tokenii trebuie să existe undeva; fără text, lista e catalogul pe secțiuni, în ordinea din sidebar — aceeași listă (_nav.js), nu o a doua. Diacriticele nu contează: „cautare" găsește „căutare".
Ce NU e
| Dacă vrei… | Folosești | De ce nu .command |
|---|---|---|
| sugestii sub un câmp din pagină | .typeahead | lista aia plutește sub câmp (.popover ancorat); comanda e în flux sau în modal |
| o valoare dintr-o listă închisă | .select | are un rând ales; comanda n-are |
| un meniu scurt dintr-un buton | .popover | sub ~7 rânduri nu e nimic de filtrat |
| o decizie care blochează | .modal | paleta navighează, nu confirmă |
Istoric
2026-10-07 — adăugat, din shadcn/ui (cmdk). Primul consumator: căutarea din catalog. Alias-uri noi în bono-ds.css: [data-selected="true"] → .is-active, [data-disabled="true"] → :disabled, [cmdk-group-heading] → .popover-label, .command-empty → .typeahead-empty; utilitarul .sr-only.