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

sursa = JSON · generat de scripts/build-catalog.py · scrie în câmp — lista se filtrează
Sugestii
Cont

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

Regula care ține componenta onestă, aceeași ca la typeahead: câmpul E .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>
ExportPrimitivaClasa DS
Commandcmdk root.command
CommandInputCommand.Input într-un wrapper.command-input > .input (.lg din context, în dialog)
CommandListCommand.List.command-list
CommandGroupCommand.Group.command-group · antetul e [cmdk-group-heading], alias pentru .popover-label
CommandItemCommand.Item.popover-item · cursorul e [data-selected="true"], alias pentru .is-active
CommandSeparatorCommand.Separator.popover-sep
CommandEmptyCommand.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ă.

declanșatorul (.input-group.command-trigger) deschide căutarea din acest catalog — același dialog, cu scrim
.modal.lg.command-dialog · fără scrim, poziționat în pagină
<!-- 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ă.

pe bej-1, câmpul e alb (context A) · click oriunde în el deschide căutarea din catalog
<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 ceNu
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ăutare40 + 4/4 = 48 · padding lateral 16 — .input-group44 + 6/6 = 56 · padding lateral 20 — .input-group.lg
Rând14/20 · padding 8/10 · --r-md16/24 · padding 8/10 · --r-md — treapta .popover.lg, lateral 10
Coloana iconițelor16 (câmp) = 6 + 10 (rând)20 (câmp) = 10 + 10 (rând)
Textul câmpului40px44px

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

StareCumNotă
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.openfocus în câmp; Esc / X / click pe scrim închid și focusul se întoarce în declanșator
Fără „ales". Comenzile sunt verbe: „Emite factură" nu poate fi valoarea curentă a nimic, deci niciun rând nu primește .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.

Ce NU e

Dacă vrei…FoloseștiDe ce nu .command
sugestii sub un câmp din pagină.typeaheadlista aia plutește sub câmp (.popover ancorat); comanda e în flux sau în modal
o valoare dintr-o listă închisă.selectare un rând ales; comanda n-are
un meniu scurt dintr-un buton.popoversub ~7 rânduri nu e nimic de filtrat
o decizie care blochează.modalpaleta 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.