IlmHamroh
JavaScript Full-stack/20-qism. React: UI kutubxonalar va animatsiya11/30-dars25 daqiqa
Mundarija (29)

Command palette React'da: cmdk, Ctrl+K va klaviatura yorliqlari

Qisqacha: Buyruqlar oynasi (command palette) — Ctrl+K (Mac'da K) bilan ochiladigan oyna: yozasiz, ro'yxat qisqaradi, ↑↓ va Enter bilan sahifaga yoki amalga o'tasiz. React'da uning ichini cmdk beradi: qidiruv maydoni, filtrlangan ro'yxat, combobox + listbox a11y. Yorliqni o'zingiz ulaysiz: keydown da ctrlKey/metaKey, preventDefault(), bitta harfli yorliqlar esa maydon ichida o'chiriladi. vazifalar da oyna — native <dialog>, cmdk esa birinchi Ctrl+K da yuklanadi (6.3 KB chunk).

Bu darsda

  • cmdk bilan qidiruvli buyruqlar ro'yxatini yasaysiz va klaviatura yo'lini jonli tekshirasiz.
  • cmdk'ning standart "fuzzy" qidiruvi o'zbekcha matnda qayerda adashishini ko'rasiz va o'z filtringizni yozasiz.
  • Ctrl+K va K ni platformaga qarab ulaysiz, key va code ni to'g'ri tanlaysiz.
  • Bitta harfli yorliqlarni maydon ichida o'chirasiz, react-hotkeys-hook'ni bilasiz.
  • Oynaning a11y'sini (aria-keyshortcuts, fokus, <kbd>) to'g'ri qilasiz.
  • vazifalar ga buyruqlar oynasini qo'shasiz — cmdk birinchi ochilishda yuklanadi (holat 11, commit).

Oldin bilishingiz kerak: Klaviatura hodisalari va tezkor tugmalar, Modal, drawer, popover va tooltip, lazy va code splitting, Matn algoritmlari, Fokus boshqaruvi.

1. Nega bu kerak?

vazifalar o'sdi: ro'yxat, statistika, sozlamalar, "katta ishni bo'lish", har vazifaning batafsil sahifasi. Sardor kun bo'yi sichqoncha bilan menyuni ochib, sahifa qidiradi. Bobur (faqat klaviatura) uchun esa har sahifaga Tab bilan yetib borish — o'nlab bosish.

Klaviatura hodisalari darsida Ctrl+K bilan qidiruv maydoniga o'tishni yozgan edik va "command palette" ni tilga olgan edik — GitHub, VS Code, Slack, Notion'dagi "buyruqlar oynasi". Bugun shuni React'da quramiz: Ctrl+K → oyna → "stat" deb yozish → Enter → statistika sahifasi. To'rt harakat, sichqonchasiz.

Hayotiy o'xshatish: katta bozordagi ma'lumot byurosi. Har rastani aylanib qidirish o'rniga, byuroga "ko'k choy" deysiz — sizga aniq joyni ko'rsatishadi.

2. cmdk: qidiruvli ro'yxat

2.1 Birinchi buyruqlar oynasi

cmdk (1.1.1) — buyruqlar oynasining "ichi": qidiruv maydoni, ro'yxat, filtrlash, tanlangan band va klaviatura. shadcn'dagi Command komponenti aynan shu (shadcn/ui II). Natija oynasida cmdk bor.

jsx
import { Command } from "cmdk";

export default function App() {
  return (
    <Command label="Buyruqlar">
      <Command.Input placeholder="Qidirish…" />
      <Command.List>
        <Command.Empty>Hech narsa topilmadi.</Command.Empty>
        <Command.Item>Non olish</Command.Item>
        <Command.Item>Kitob o'qish</Command.Item>
        <Command.Item>Sozlamalar</Command.Item>
      </Command.List>
    </Command>
  );
}

Ekranda:

text
Non olish

Qismlar — Compound komponentlar naqshida:

  • Command — ildiz: holat (qidiruv matni, tanlangan band) va label — ekran o'quvchi uchun nom. U yashirin <label> bo'lib chiziladi: ko'zga ko'rinmaydi (shuning uchun «Ekranda» yo'q), lekin ekran o'quvchi maydonni «Buyruqlar» deb e'lon qiladi.
  • Command.Input — qidiruv maydoni.
  • Command.List — ro'yxat; Command.Item — band; Command.Empty — hech narsa topilmasa.

«nl» yozdik — «Non olish» qoldi. "nl" so'z emas-ku? Bu cmdk'ning fuzzy search (taxminiy qidiruv) i: harflar ketma-ket emas, lekin shu tartibda uchrasa ham mos deb hisoblanadi — Non olish. Bu command-score kutubxonasi (cmdk turlaridagi izoh: "By default, uses the command-score library").

Testlar uchun bir eslatma: cmdk brauzerning ResizeObserver (ro'yxat balandligini kuzatadi) va scrollIntoView (tanlangan bandni ko'rinadigan joyga suradi) API'laridan foydalanadi. Brauzerda ikkalasi bor. Vitest ishlatadigan jsdom'da esa yo'q — u yerda test sozlash faylida bo'sh o'rinbosar yoziladi.

2.2 Klaviatura

Endi Bobur yo'li — faqat klaviatura. «Ishga tushir» dan keyin maydonni bosing, ↓ ni ikki marta, keyin Enter ni bosing. Guruh va tanlov:

jsx
export default function App() {
  const choose = (value) => console.log("tanlandi:", value);
  return (
    <Command label="Buyruqlar" loop>
      <Command.Input placeholder="Qidirish…" />
      <Command.List>
        <Command.Group heading="Sahifalar">
          <Command.Item onSelect={choose}>Ro'yxat</Command.Item>
          <Command.Item onSelect={choose}>Statistika</Command.Item>
          <Command.Item onSelect={choose}>Sozlamalar</Command.Item>
        </Command.Group>
      </Command.List>
    </Command>
  );
}

Konsolda:

text
tanlandi: Sozlamalar

Qadamma-qadam:

Qadam Fokus Tanlangan band Natija
maydonni bosish maydon Ro'yxat —
↓ maydon Statistika —
↓ maydon Sozlamalar —
Enter maydon Sozlamalar tanlandi: Sozlamalar

Ochilganda birinchi band tanlangan. ↓ ↓ — uchinchisi, Enter — onSelect. Fokus esa doim maydonda qoladi: odam yozishda davom etishi mumkin. Qaysi band "tanlangan" ekanini ekran o'quvchiga aria-activedescendant aytadi — maydon fokusda, lekin "faol" band ro'yxatdagi element. Bu WAI-ARIA'ning combobox naqshi: maydon role="combobox", ro'yxat listbox, bandlar option.

  • Command.Group heading — guruh sarlavhasi («Sahifalar», «Vazifalar»).
  • loop — oxiridan ↓ — boshiga.
  • onSelect(value) — Enter yoki bosish. value — band matni (yoki value prop'i).

cmdk'da yana bir narsa standart yoqilgan: vimBindings — Ctrl+N/J (pastga), Ctrl+P/K (tepaga). Ilovangizning o'z Ctrl+K si bo'lsa, ikkalasi bir tugmada — keraksiz bo'lsa vimBindings={false}.

2.3 Fuzzy search o'zbekcha matnda

Jasur aka «o‘qish» deb yozdi — telefonidagi klaviatura apostrofni ‘ (U+2018) bilan qo'ydi:

jsx
export default function App() {
  return (
    <Command label="Buyruqlar">
      <Command.Input placeholder="Qidirish…" />
      <Command.List>
        <Command.Empty>Hech narsa topilmadi.</Command.Empty>
        <Command.Item>Non olish</Command.Item>
        <Command.Item>Kitob o'qish</Command.Item>
      </Command.List>
    </Command>
  );
}

Ekranda:

text
Hech narsa topilmadi.

«Kitob o'qish» bor, lekin topilmadi: ‘ va ' — boshqa-boshqa belgilar. O'zbek tilida apostrofning kamida beshta varianti yuradi (', ‘, ’, ʻ, ʼ) — Matn algoritmlari darsida normalla funksiyasi aynan shuning uchun yozilgan.

Ikkinchi muammo — teskarisi: «nl» uchun «Non olish» chiqdi. Qisqa ro'yxatda bu yordam, lekin 300 vazifada "nl" deb yozganingizda o'nlab kutilmagan natija chiqadi.

Davosi — o'z filtringiz. cmdk'da filter prop'i bor: (value, search, keywords) => son — 1 — mos, 0 — yashirilsin. Kanon (BuyruqlarRoyxati.tsx) shunday qilgan. Bu yerda normalla ning soddalashgan nusxasi bilan:

jsx
// Soddalashgan normalla: kichik harf + apostroflar bir xil
const normalize = (text) =>
  text.toLowerCase().replace(/[‘’ʻʼ`]/g, "'");

function matches(value, search, keywords = []) {
  const text = normalize([value, ...keywords].join(" "));
  return text.includes(normalize(search)) ? 1 : 0;
}

export default function App() {
  return (
    <Command label="Buyruqlar" filter={matches}>
      <Command.Input placeholder="Qidirish…" />
      <Command.List>
        <Command.Empty>Hech narsa topilmadi.</Command.Empty>
        <Command.Item>Non olish</Command.Item>
        <Command.Item>Kitob o'qish</Command.Item>
      </Command.List>
    </Command>
  );
}

Ekranda:

text
Kitob o'qish

Endi includes — oddiy "ichida bormi?" qidiruvi: «nl» endi hech narsa topmaydi, «o‘qish» esa topadi. Fuzzy'dan voz kechdik — bu ataylab: vazifa va sahifa nomlari qisqa, aniq qidiruv tushunarliroq. Kanon izohi: "cmdk'ning standart "fuzzy" bahosi o'zbekcha apostrofni bilmaydi va "nl" ni "Non olish" ga mos deydi".

Tekshirib ko'ring: Kanonda har band value={vazifa:${v.id}} va keywords={[v.matn]} bilan yozilgan. Nega matnning o'zini value qilmaydi?

Javob

value — bandning noyob kaliti: cmdk tanlovni shu bilan eslab qoladi. Ikki vazifa bir xil matnli bo'lsa («Non olish» ikki marta), ikkalasining value si bir xil bo'lib qolardi va tanlov chalkashardi. vazifa:3 — doim noyob. Qidiruv esa keywords bo'yicha: kanonning mosmi filtri faqat keywords ni normallab tekshiradi. Bonus: "sahifa:" va "vazifa:" prefikslari bir xil nomli sahifa va vazifani ham ajratadi.

3. Ctrl+K: yorliqni ulash

3.1 key yoki code?

Klaviatura hodisalari darsidagi jadvalni eslang: Ctrl + harf uchun code, chunki rus yoki kirill tartibida K tugmasining key i — «л», code esa doim KeyK. Tekshiruvni sof funksiya qilib, uch holatda sinaymiz:

js
// mac — Mac'dami (u yerda Cmd), aks holda Ctrl
function isPaletteShortcut(event, mac) {
  const mod = Boolean(mac ? event.metaKey : event.ctrlKey);
  const extra = event.altKey || event.shiftKey;
  return mod && event.code === "KeyK" && !extra;
}

const latin = { ctrlKey: true, key: "k", code: "KeyK" };
const russian = { ctrlKey: true, key: "л", code: "KeyK" };
const macCmd = { metaKey: true, key: "k", code: "KeyK" };

console.log("lotin, Windows:", isPaletteShortcut(latin, false));
console.log("rus tartibi:", isPaletteShortcut(russian, false));
console.log("Mac, ⌘K:", isPaletteShortcut(macCmd, true));
console.log("Mac, Ctrl+K:", isPaletteShortcut(latin, true));
console.log("key bilan, rus:", russian.key.toLowerCase() === "k");

Konsolda:

text
lotin, Windows: true
rus tartibi: true
Mac, ⌘K: true
Mac, Ctrl+K: false
key bilan, rus: false
  • code bilan — tartibdan qat'i nazar ishlaydi.
  • Mac'da Ctrl+K — false: Mac'da odatiy modifikator — . Ikkalasini qabul qilish ham mumkin (ctrlKey || metaKey, Klaviatura hodisalari darsidagi kabi) — lekin Mac'da Ctrl+K matn maydonida "qator oxirigacha o'chirish" vazifasini bajaradi, shuning uchun platformaga qarab ajratish yaxshiroq.
  • !altKey && !shiftKey — Ctrl+Shift+K boshqa yorliq (masalan, Firefox'da konsol). Aniq kombinatsiya.
  • Oxirgi qator — key bilan tekshiruv rus tartibida ishlamaydi.

Diqqat — biz ham shu xatoga tushgan edik. vazifalar dagi BuyruqlarOynasi.tsx ning birinchi varianti yorliqni event.key.toLowerCase() === "k" bilan tekshirgan — yuqoridagi oxirgi qatordagi usul. e2e testlari lotin tartibida yurardi, shuning uchun xato ko'rinmadi. Keyin e2e-20 ga yangi tekshiruv qo'shdik: brauzerga Ctrl + «л» (code — KeyK) yuboriladi. Eski kod [false,false] berdi — oyna ochilmadi. event.code === "KeyK" ga o'tilgach, test o'tdi: [true,false] (ochildi, qayta bosish yopdi).

Saboq: test faqat siz o'ylagan holatni tekshiradi. Rus tartibidagi foydalanuvchi testda bo'lmasa, uning xatosi ham ko'rinmaydi. 3-mashqda shu tuzatishni o'zingiz takrorlaysiz.

3.2 Platformani aniqlash

Kanon:

ts
// Mac'da Cmd, qolganlarida Ctrl. navigator.platform eskirgan, lekin
// hamma brauzerda bor (userAgentData — faqat Chromium'da)
const MAC = /Mac|iPhone|iPad/.test(navigator.platform);
const YORLIQ = MAC ? "⌘K" : "Ctrl+K";

navigator.platform — "eskirgan" (deprecated) deb belgilangan, lekin hamma brauzerda ishlaydi. Yangi navigator.userAgentData.platform faqat Chromium'da bor. Shuning uchun kanon eskisini tanlagan va izohda sababini yozgan — bu to'g'ri odat.

YORLIQ — foydalanuvchiga ko'rsatiladigan matn (tooltip'da, oldingi dars). Mac foydalanuvchisi «Ctrl+K» ni ko'rsa, adashadi.

3.3 O'quv modeli: Ctrl+K bilan ochiladigan buyruqlar oynasi

Endi hammasini birga. Telefonda Ctrl tugmasi yo'q, shuning uchun «Ctrl+K (sinov)» tugmasi window ga xuddi shunday keydown hodisasini yuboradi — haqiqiy klaviatura o'rnida "robot":

jsx
import { useEffect, useRef, useState } from "react";

function pressCtrlK() {
  const init = { key: "k", code: "KeyK", ctrlKey: true };
  window.dispatchEvent(new KeyboardEvent("keydown", init));
}

const go = (path) => () => console.log("→", path);

export default function App() {
  const [open, setOpen] = useState(false);
  const input = useRef(null);

  useEffect(() => {
    function onKeyDown(event) {
      if (isPaletteShortcut(event, false)) {
        event.preventDefault(); // brauzerning o'z Ctrl+K si
        setOpen((o) => !o);
      }
    }
    window.addEventListener("keydown", onKeyDown);
    return () => window.removeEventListener("keydown", onKeyDown);
  }, []);

  useEffect(() => {
    if (open) input.current?.focus();
  }, [open]);

  return (
    <div>
      <button onClick={pressCtrlK}>Ctrl+K (sinov)</button>
      {open && (
        <Command label="Buyruqlar" filter={matches}>
          <Command.Input ref={input} placeholder="Qidirish…" />
          <Command.List>
            <Command.Item onSelect={go("/statistika")}>
              Statistika
            </Command.Item>
            <Command.Item onSelect={go("/sozlamalar")}>
              Sozlamalar
            </Command.Item>
          </Command.List>
        </Command>
      )}
    </div>
  );
}

function isPaletteShortcut(event, mac) {
  const mod = Boolean(mac ? event.metaKey : event.ctrlKey);
  const extra = event.altKey || event.shiftKey;
  return mod && event.code === "KeyK" && !extra;
}

Ekranda:

text
Ctrl+K (sinov)
Statistika

«Ctrl+K (sinov)» ni bosing va «stat» deb yozing. Oyna ochildi, fokus maydonga o'tdi (shuning uchun yozish darhol maydonga tushdi), «stat» bo'yicha faqat «Statistika» qoldi. Uch muhim joy:

  1. preventDefault() — Chrome'da Ctrl+K manzil satrida qidiruvni ochadi. Busiz ikkalasi birga ishlardi.
  2. window dagi tinglovchi — yorliq sahifaning qayerida bo'lmasin ishlaydi, maydon ichida ham. Ctrl+K matn yozmaydi, shuning uchun maydon ichida ham xavfsiz. Effekt cleanup bilan (useEffect).
  3. Toggle — ochiq oynada Ctrl+K uni yopadi. Ko'p ilovalarda shunday.

matches — oldingi blokdagi filtr (davomi — bitta modul). Haqiqiy ilovada oyna <dialog> ichida bo'ladi — «Vazifalar qadami» bo'limida.

4. Bitta harfli yorliqlar va maydonlar

4.1 Muammo

Gmail'da c — yangi xat, / — qidiruv. Sardor vazifalar ga ham qo'shmoqchi: n — yangi vazifa. Lekin Jasur aka vazifa nomiga «Non olish» deb yozganda… birinchi harf n — yangi vazifa oynasi ochildi!

Qoida: modifikatorsiz yorliqlar maydon ichida ishlamasin. Ctrl+K — mumkin (u yozmaydi), n, /, ?, j, k — yo'q.

Tekshirish kerak bo'lgan joylar: <input>, <textarea>, <select> va contenteditable (boy matn muharrirlari). Yana bitta — event.isComposing: IME (masalan, xitoy yoki yapon yozuvi) bilan so'z terilayotganda tugmalar hali "harf" emas.

js
function isTypingTarget(target) {
  if (target.isContentEditable) return true;
  return ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName);
}

function shouldHandle(event) {
  if (event.isComposing) return false;
  if (event.ctrlKey || event.metaKey || event.altKey) return true;
  return !isTypingTarget(event.target);
}

const inInput = { key: "n", target: { tagName: "INPUT" } };
const onPage = { key: "n", target: { tagName: "BODY" } };
const input = { tagName: "INPUT" };
const ctrlInInput = { key: "k", ctrlKey: true, target: input };
const div = { tagName: "DIV", isContentEditable: true };
const editor = { key: "n", target: div };

console.log("n maydonda:", shouldHandle(inInput));
console.log("n sahifada:", shouldHandle(onPage));
console.log("Ctrl+K maydonda:", shouldHandle(ctrlInInput));
console.log("n muharrirda:", shouldHandle(editor));

Konsolda:

text
n maydonda: false
n sahifada: true
Ctrl+K maydonda: true
n muharrirda: false

Sinov uchun oddiy obyektlar ishlatdik — funksiya faqat key, target va modifikatorlarni o'qiydi. Bu sof funksiyani testlash oson (Toza funksiya g'oyasi).

4.2 react-hotkeys-hook

Ko'p yorliq bo'lsa, ularni bitta hook bilan ulash qulay. Eng ko'p ishlatiladigani — react-hotkeys-hook (5.3.3, natija oynasida yo'q):

tsx
import { useHotkeys } from "react-hotkeys-hook";

// "mod" — Mac'da ⌘, boshqalarda Ctrl
useHotkeys("mod+k", () => setOpen((o) => !o), {
  enableOnFormTags: true, // maydon ichida ham (Ctrl+K yozmaydi)
  preventDefault: true,
});

// Bitta harf — standart bo'yicha maydon ichida ishlamaydi
useHotkeys("n", () => yangiVazifa());

Paketni o'rnatib, ichini ko'rdik (2026-10-17):

  • mod — kod ichida Mac tekshiruvi bilan metaKey yoki ctrlKey ga aylanadi (biz qo'lda yozgan isPaletteShortcut ning o'zi).
  • enableOnFormTags — standart bo'yicha o'chiq: yorliqlar maydonlarda ishlamaydi («Muammo» bo'limidagi qoida).
  • Tugmani code bo'yicha taniydi (ichida code ni nomga aylantiradi); useKey: true bilan key ga o'tadi. Ya'ni standart xulq — tartibdan mustaqil.

vazifalar da bitta yorliq bor (Ctrl+K), shuning uchun kanon kutubxona qo'shmadi — 17-qismdan beri bor useOynaHodisasi hooki yetdi.

5. A11y: yorliqni e'lon qilish

Yorliq ko'rinmas narsa. Uni bilmagan odam uchun u yo'q. Uch qatlamda aytamiz:

  1. Tugma — oynani ochadigan tugma doim bor (sichqoncha, sensorli ekran va yorliqni bilmaganlar uchun). Kanonda: « Buyruqlar».
  2. aria-keyshortcuts — ekran o'quvchiga: aria-keyshortcuts="Control+K" (Mac'da "Meta+K"). Qiymat — ARIA nomlari, + bilan.
  3. Ko'rinadigan yozuv — tooltip «Tezkor ochish: Ctrl+K» (oldingi dars) yoki <kbd>Ctrl</kbd>+<kbd>K</kbd> (W-18).

Oyna ichida:

  • ochilganda fokus — qidiruv maydoniga;
  • Esc — yopadi, fokus ochgan tugmaga qaytadi (Fokus boshqaruvi);
  • Enter bilan sahifaga o'tilganda — fokus yangi sahifa sarlavhasiga (19-qismdagi Qobiq qoidasi).

6. Vazifalar qadami: Ctrl+K buyruqlar oynasi (holat 11)

6.1 Ikki komponent

Oldingi darsda feat/buyruqlar branch'ini ochib, tooltip'ni qo'shgan edik. Endi oynaning o'zi:

bash
npm i -E cmdk@1.1.1

package.json ga yana bitta narsa — overrides:

json
"overrides": {
  "@radix-ui/react-dialog": "1.2.0"
}

Nega? cmdk 1.1.1 @radix-ui/react-dialog ga ^1.1.6 bilan bog'langan (u Command.Dialog uchun). ^ diapazoni o'rnatish kunidagi eng yangi versiyani tortardi — kanon yozganda bu 2 kunlik 1.3.1 edi. Kanon qoidasi — 7 kundan yangi reliz olinmaydi, shuning uchun overrides bilan qotirildi (Semver va lock fayl qoidalari).

Oyna ikki faylga bo'lingan:

  • BuyruqlarOynasi.tsx — doim yuklanadi: tugma, tooltip, Ctrl+K va <dialog>. cmdk'ni import qilmaydi.
  • BuyruqlarRoyxati.tsx — oynaning ichi: cmdk, qidiruv, ro'yxat. Alohida chunk, birinchi ochilishda yuklanadi.

Nega bunday? Kanon birinchi variantda cmdk'ni to'g'ridan import qilgan edi: birinchi yuklash +9.0 KB. Kod ko'rigida bu qabul qilinmadi: cmdk birinchi Ctrl+K da yuklanishi kerak — lazy va code splitting darsidagi qoida o'z ilovamizda ham. Oynani hamma ham ochmaydi — nega hamma uni yuklab olishi kerak?

Birinchi ochilishdagi tartib:

sequenceDiagram
  participant U as Foydalanuvchi
  participant O as BuyruqlarOynasi
  participant N as Tarmoq
  U->>O: Ctrl+K (yoki tugma)
  O->>O: showModal(), «Yuklanmoqda…»
  O->>N: import("./BuyruqlarRoyxati")
  N-->>O: cmdk chunk (6.3 KB)
  O->>O: ro'yxat chiziladi, fokus maydonga
  U->>O: «statis» + Enter
  O->>U: /statistika, fokus sarlavhada

Nimaga qarang: oyna chunk'ni kutmaydi — darhol ochiladi va role="status" li «Yuklanmoqda…» ni ko'rsatadi. Ikkinchi ochilishda chunk keshda: ro'yxat bir zumda.

6.2 BuyruqlarRoyxati.tsx — cmdk qismi

Kanon fayli (sarlavha izohi qisqartirilgan):

tsx
// BuyruqlarRoyxati.tsx — buyruqlar oynasining ichi (20/#11): cmdk.
// Alohida chunk: BuyruqlarOynasi uni birinchi Ctrl+K (yoki tugma) da
// lazy() bilan yuklaydi
import { useEffect, useRef, useState } from "react";
import { Command } from "cmdk";
import { normalla } from "@/qidiruv.ts";
import { useVazifalar } from "@/server-sinxron.ts";

const SAHIFALAR = [
  { yol: "/", nomi: "Ro'yxat" },
  { yol: "/statistika", nomi: "Statistika" },
  { yol: "/sozlamalar", nomi: "Sozlamalar" },
  { yol: "/yangi", nomi: "Katta ishni qadamlarga bo'lish" },
] as const;

// Qidiruv — o'zimizniki (14/#11 normalla: kichik harf, apostroflar
// ' ‘ ’ ʻ ʼ bir xil): cmdk'ning standart "fuzzy" bahosi
// o'zbekcha apostrofni bilmaydi va "nl" ni "Non olish" ga mos deydi
function mosmi(_qiymat: string, sorov: string, kalitlar?: string[]) {
  return normalla((kalitlar ?? []).join(" ")).includes(normalla(sorov))
    ? 1
    : 0;
}

type Props = {
  bor: (yol: string) => void;
};

// default eksport — lazy() shuni kutadi
export default function BuyruqlarRoyxati({ bor }: Props) {
  const [sorov, setSorov] = useState("");
  const maydon = useRef<HTMLInputElement>(null);
  const { vazifalar } = useVazifalar();

  // Birinchi ochilishda chunk showModal'dan KEYIN keladi (oyna
  // allaqachon ochiq, fokus <dialog> da) — maydon paydo bo'lganda
  // fokusni o'zi oladi
  useEffect(() => {
    maydon.current?.focus();
  }, []);

  return (
    <Command label="Buyruqlar" filter={mosmi} loop>
      <Command.Input
        ref={maydon}
        value={sorov}
        onValueChange={setSorov}
        placeholder="Sahifa yoki vazifa nomi…"
        className="mb-2 min-h-bosish w-full rounded-burchak border-2 border-ikkinchi bg-yuza px-2"
      />
      <Command.List className="max-h-80 overflow-y-auto">
        <Command.Empty className="px-3 py-2 text-ikkinchi">
          Hech narsa topilmadi.
        </Command.Empty>
        <Command.Group heading="Sahifalar" className={GURUH}>
          {SAHIFALAR.map((s) => (
            <Command.Item
              key={s.yol}
              value={`sahifa:${s.yol}`}
              keywords={[s.nomi]}
              onSelect={() => bor(s.yol)}
              className={BAND}
            >
              {s.nomi}
            </Command.Item>
          ))}
        </Command.Group>
        {/* «Vazifalar» guruhi — xuddi shunday:
            value={`vazifa:${v.id}`}, keywords={[v.matn]},
            bor(`/vazifa/${v.id}`) */}
      </Command.List>
    </Command>
  );
}

BAND va GURUH — Tailwind sinflari (tanlangan band data-[selected=true]:bg-asosiy); qisqartirish uchun ko'rsatilmadi. default export — lazy() faqat shuni qabul qiladi (lazy). useVazifalar() — Query keshidan, so'rovsiz (TanStack Query).

6.3 BuyruqlarOynasi.tsx — tugma, yorliq, dialog va lazy

tsx
import { lazy, Suspense, useRef, useState } from "react";
import { flushSync } from "react-dom";
import { Search } from "lucide-react";
import { useNavigate } from "react-router";
import { useOynaHodisasi } from "@/hooklar/useOynaHodisasi.ts";
import { XatoChegarasi } from "@/komponentlar/XatoChegarasi.tsx";
import { Tugma } from "@/ui/Tugma.tsx";
import {
  Tooltip,
  TooltipContent,
  TooltipTrigger,
} from "@/ui/tooltip.tsx";

const MAC = /Mac|iPhone|iPad/.test(navigator.platform);
const YORLIQ = MAC ? "⌘K" : "Ctrl+K";

// Modul Promise'i bitta (StatistikaPaneli.tsx dagidek, 18/#09):
// oldindan yuklash va lazy() ikkalasi shuni kutadi
let modul: Promise<typeof import("./BuyruqlarRoyxati.tsx")> | null =
  null;
function modulniOl() {
  modul ??= import("./BuyruqlarRoyxati.tsx");
  return modul;
}
const BuyruqlarRoyxati = lazy(modulniOl);

// Sichqoncha tugma ustiga kelsa — bosishdan oldin yuklay
// boshlaymiz. Klaviatura fokusida EMAS: Tab bilan o'tib ketgan
// hamma yuklab olardi. Xato bu yerda jim — ochilganda chegara
// ko'rsatadi
function oldindanYukla() {
  modulniOl().catch(() => {});
}

export function BuyruqlarOynasi() {
  const oyna = useRef<HTMLDialogElement>(null);
  const [ochiq, setOchiq] = useState(false);
  const navigate = useNavigate();

  function och() {
    // flushSync: ichki qism showModal'dan OLDIN DOM'da bo'lsin (chunk
    // kelgan bo'lsa — maydon, kelmagan bo'lsa — "Yuklanmoqda…")
    flushSync(() => setOchiq(true));
    oyna.current?.showModal();
    // showModal fokusni birinchi fokuslanadigan elementga
    // qo'yadi — cmdk ildizida tabIndex={-1} bor, fokus qidiruv
    // maydoniga emas, ildizga tushardi (e2e-20 topdi:
    // activeElement — div[cmdk-root]). Shuning uchun — o'zimiz
    oyna.current?.querySelector("input")?.focus();
  }

  function bor(yol: string) {
    oyna.current?.close(); // fokus avval ochgan joyga qaytadi...
    void navigate(yol); // ...keyin Qobiq yangi sahifa sarlavhasiga qo'yadi
  }

  // Yorliq hamma joyda, maydon ichida ham ishlaydi: Ctrl+K matn
  // yozmaydi. preventDefault — brauzerning o'z Ctrl+K'si (manzil
  // qatorida qidiruv). code — jismoniy tugma (W-18): rus tartibida
  // key "л", code baribir "KeyK" (key bilan yorliq rus tartibida
  // ishlamasdi — e2e-20)
  useOynaHodisasi("keydown", (event) => {
    if (
      (MAC ? event.metaKey : event.ctrlKey) &&
      event.code === "KeyK" &&
      !event.altKey &&
      !event.shiftKey
    ) {
      event.preventDefault();
      if (oyna.current?.open === true) {
        oyna.current.close();
      } else {
        och();
      }
    }
  });

  return (
    <>
      <Tooltip>
        <TooltipTrigger asChild>
          <Tugma
            id="buyruqlar-tugma"
            aria-haspopup="dialog"
            aria-keyshortcuts={MAC ? "Meta+K" : "Control+K"}
            onClick={och}
            onPointerEnter={oldindanYukla}
          >
            <Search size={16} />
            Buyruqlar
          </Tugma>
        </TooltipTrigger>
        <TooltipContent>{`Tezkor ochish: ${YORLIQ}`}</TooltipContent>
      </Tooltip>
      <dialog
        ref={oyna}
        id="buyruqlar"
        aria-label="Buyruqlar"
        className="w-[min(32rem,100%-2rem)]"
        onClose={() => setOchiq(false)}
      >
        {ochiq && (
          <XatoChegarasi
            fallback={() => (
              <p role="alert">
                Buyruqlar oynasini yuklab bo'lmadi — sahifani yangilang.
              </p>
            )}
          >
            <Suspense fallback={<p role="status">Yuklanmoqda…</p>}>
              <BuyruqlarRoyxati bor={bor} />
            </Suspense>
          </XatoChegarasi>
        )}
      </dialog>
    </>
  );
}

Bu darsdagi va oldingi qismlardagi g'oyalar bitta faylda:

  • Native <dialog> — fokus tuzog'i, Esc, fon — brauzerda; cmdk'ning Command.Dialog i (Radix Dialog) emas, chunki u <style> qo'shadi va CSP'ni buzadi (Modal, drawer…, kanon izohi).
  • lazy + bitta modul Promise'i — oldindanYukla va lazy bir xil import() ni kutadi, ikki marta yuklanmaydi.
  • pointerenter da oldindan yuklash — sichqoncha tugmaga kelganda chunk tarmoqdan kela boshlaydi, bosilganda tayyor. Tab bilan o'tganlar uchun emas — ular hammasi yuklardi.
  • flushSync — ichki qism showModal() dan oldin DOM'da bo'lsin (flushSync).
  • e2e topgan xato — cmdk ildizida tabIndex={-1} bor: showModal() fokusni maydonga emas, ildizga qo'yardi. Shuning uchun input ga fokus qo'lda; chunk kech kelsa — BuyruqlarRoyxati dagi useEffect.
  • Suspense fallback — role="status" li «Yuklanmoqda…»; XatoChegarasi — chunk kelmasa (oflayn) aniq xabar (Error boundary).
  • aria-keyshortcuts va tooltip'dagi YORLIQ — «A11y: yorliqni e'lon qilish» bo'limidagi qatlamlar.
  • useOynaHodisasi — 17-qismdagi window hodisasi hooki (useEffectEvent bilan).
  • event.code === "KeyK" — «key yoki code?» bo'limidagi qoida. Izoh tuzatish tarixini ham saqlaydi: kim keyin key ga "soddalashtirmoqchi" bo'lsa, nega bunday qilmaslik kerakligini o'qiydi.

Qobiq.tsx dagi ulanish — oldingi darsda ko'rsatilgan: {kirgan && <BuyruqlarOynasi />} TooltipProvider ichida.

6.4 Narx va tekshiruvlar

Build (tekshiruv-11.txt):

text
dist/assets/BuyruqlarRoyxati-BZxZmeVJ.js     16.76 kB │ gzip:   6.33 kB
JAMI birinchi js                          578660  184776  158829
  • cmdk qismi — alohida 6.3 KB chunk, faqat oyna ochilganda.
  • Birinchi yuklash JS gzip: 181.1 → 184.8 KB (+3.7: Radix tooltip va lazy qobiq). cmdk to'g'ridan import qilinganda +9.0 KB edi.
  • npm test — node:test pass 198, Vitest Tests 65 passed (65); lint, format:check, tip, knip — exit 0.
  • Brauzer (Chrome 155): e2e 159, e2e-18 72, e2e-19 100 — ; e2e-20 — 33 (rus tartibi tekshiruvi bilan). axe — 7 ko'rinishda 0 buzilish, oyna ochiq holda ham (11-axe.txt).

e2e-20 ning buyruqlar oynasi qismi (natija-20-11.txt, tanlab). Har qatorni darsning bo'limiga bog'lab o'qing:

text
✅ cmdk: ochilishda yuklanmagan (chunk yo'q, modulepreload ham yo'q): [false,0]
✅ cmdk: tugma fokusi va tooltip — hali yuklanmagan: false
✅ cmdk: chunk kelguncha — oyna ochiq, «Yuklanmoqda…» (role=status): [true,"Yuklanmoqda…"]
✅ cmdk: birinchi Ctrl+K — chunk yuklandi: true
✅ Ctrl+K (maydon ichida ham): oyna modal, fokus qidiruvda (combobox): [true,true,"combobox",true]
✅ bo'sh qidiruv: 4 sahifa + 3 vazifa: 7
✅ qidiruv: «statis» — faqat Statistika: ["Statistika"]
✅ Enter: sahifaga o'tdi, oyna yopildi, fokus sahifa sarlavhasida: [false,"H2","Statistika"]
✅ qidiruv: o‘qish (U+2018) — «Kitob o'qish» topildi: ["Kitob o'qish"]
✅ topilmasa — «Hech narsa topilmadi.»: "Hech narsa topilmadi."
✅ Esc — yopildi, fokus tugmaga qaytdi: [false,"buyruqlar-tugma"]
✅ Ctrl+K rus tartibida (key «л», code KeyK): ochildi, qayta bosish — yopildi: [true,false]
✅ tooltip/Ctrl+K: CSP buzilishi yo'q: []

Nima bo'ldi? Kech yuklash, sekin tarmoqda «Yuklanmoqda…» (test chunk'ni +600 ms kechiktirib sinaydi), fokus maydonda, o'zbekcha apostrof, Esc'da fokus qaytishi, rus tartibi, CSP — 0. Rus tartibi qatori tuzatishsiz [false,false] bilan yiqilgan edi (natija-20-11-tuzatishsiz.txt).

6.5 Commit

bash
git add -A
git commit

Kanonda uch commit (tarix bo'yicha):

  1. feat: Ctrl+K buyruqlar oynasi (cmdk, native dialog), tooltip (Radix) — diff: 6 fayl, +412 −2.
  2. perf: buyruqlar oynasi — cmdk birinchi Ctrl+K da yuklanadi (lazy, Suspense) — birinchi yuklashni kamaytirish.
  3. fix: Ctrl+K — event.code (rus tartibida ham ishlaydi) — diff: 1 fayl, +4 −2.

Siz yakuniy holatni yozdingiz — bitta commit ham bo'ladi, lekin "nima va nega" uchalasi ham tanasida bo'lsin.

7. Ko'p uchraydigan xatolar

Belgi Sabab Davosi
Ctrl+K manzil satrini ochadi preventDefault() yo'q event.preventDefault()
rus tartibida yorliq ishlamaydi event.key bilan tekshirilgan event.code === "KeyK"
Mac'da yorliq yo'q faqat ctrlKey platformaga qarab metaKey
«Non olish» yozganda n yorlig'i ishladi bitta harfli yorliq maydonda isTypingTarget / enableOnFormTags
«o‘qish» topilmaydi apostrof varianti normallovchi filter
showModal() dan keyin fokus ro'yxat ildizida cmdk ildizi tabIndex={-1} maydonga qo'lda focus()
testda ResizeObserver is not defined jsdom'da yo'q sozlash faylida o'rinbosar
ERESOLVE yoki kutilmagan @radix-ui/react-dialog cmdk ^ diapazoni overrides
birinchi yuklash +9 KB cmdk to'g'ridan import lazy() alohida faylda

8. Mashqlar

Mashqlar kurs/mashqlar/20/11-buyruqlar-oynasi/ da.

1-mashq (oson): to'ldiring

  • Mac'da odatiy modifikator xususiyati: event.
  • Tartibdan mustaqil tugma tekshiruvi: event.code === " "
  • Ekran o'quvchiga yorliqni aytadigan atribut:
  • cmdk'da o'z qidiruvingizni beradigan prop:
Yechim
  • metaKey — Mac'da (Windows'da Win tugmasi).
  • code: "KeyK" — jismoniy joy; key esa tartibga bog'liq («л»).
  • aria-keyshortcuts="Control+K" (Mac'da "Meta+K").
  • filter={(value, search, keywords) => …} — 1 yoki 0 qaytaradi.

2-mashq (o'rta): sahifa va vazifalar guruhlari

Ikki guruhli buyruqlar oynasini yasang: «Sahifalar» (Ro'yxat, Statistika) va «Vazifalar» (Non olish, Kitob o'qish). Har bandga noyob value (sahifa:/, vazifa:2 …) va qidiruv uchun keywords bering. Filtr — keywords ni normallab, includes bilan. «O‘QISH» (katta harf va ‘) yozilganda faqat «Kitob o'qish» qolsin, Enter bosilganda konsolda → /vazifa/2.

Ishora: «Fuzzy search o'zbekcha matnda» bo'limidagi normalize; kanon mosmi faqat keywords ni tekshiradi.

text
→ /vazifa/2
Yechim
jsx
import { Command } from "cmdk";

const PAGES = [
  { path: "/", name: "Ro'yxat" },
  { path: "/statistika", name: "Statistika" },
];
const TASKS = [
  { id: 1, text: "Non olish" },
  { id: 2, text: "Kitob o'qish" },
];

const normalize = (text) =>
  text.toLowerCase().replace(/[‘’ʻʼ`]/g, "'");

function matches(_value, search, keywords = []) {
  return normalize(keywords.join(" ")).includes(normalize(search))
    ? 1
    : 0;
}

const go = (path) => console.log("→", path);

export default function App() {
  return (
    <Command label="Buyruqlar" filter={matches} loop>
      <Command.Input placeholder="Sahifa yoki vazifa nomi…" />
      <Command.List>
        <Command.Empty>Hech narsa topilmadi.</Command.Empty>
        <Command.Group heading="Sahifalar">
          {PAGES.map((p) => (
            <Command.Item
              key={p.path}
              value={`sahifa:${p.path}`}
              keywords={[p.name]}
              onSelect={() => go(p.path)}
            >
              {p.name}
            </Command.Item>
          ))}
        </Command.Group>
        <Command.Group heading="Vazifalar">
          {TASKS.map((t) => (
            <Command.Item
              key={t.id}
              value={`vazifa:${t.id}`}
              keywords={[t.text]}
              onSelect={() => go(`/vazifa/${t.id}`)}
            >
              {t.text}
            </Command.Item>
          ))}
        </Command.Group>
      </Command.List>
    </Command>
  );
}

Konsolda:

text
→ /vazifa/2

Filtr value ni (vazifa:2) e'tiborsiz qoldiradi — u faqat kalit. Katta harf va ‘ normallandi. Topilgan yagona band avtomatik tanlangan, shuning uchun Enter darhol uni ochdi. Bo'sh qolgan «Sahifalar» guruhini cmdk o'zi yashiradi.

3-mashq (qiyin): tartibdan mustaqil yorliq hooki

useShortcut(handler) hookini yozing:

  1. window dagi keydown ni tinglaydi (cleanup bilan).
  2. Ctrl+K (Mac'da K) ni code bo'yicha taniydi, preventDefault() qiladi va handler() ni chaqiradi.
  3. / (modifikatorsiz) ham handler() ni chaqiradi — lekin maydon ichida emas.

Sinov uchun tugmalar window ga (maydon nomidan ham) hodisa yuboradi: natija oynasida Ctrl bilan tugma bosish qadami yo'q. «Rus» tugmasi key: "л" yuboradi — kanondagi e2e tekshiruvining kichik nusxasi.

Ishora: isPaletteShortcut va shouldHandle — dars bloklaridan; hodisa maydondan yuborilsa, target — maydon (input.dispatchEvent(…) va bubbles: true).

text
ochildi: Ctrl+K (rus tartibi)
ochildi: /
Yechim
jsx
import { useEffect, useRef } from "react";

const MAC = /Mac|iPhone|iPad/.test(navigator.platform);

function isTypingTarget(target) {
  if (target.isContentEditable) return true;
  return ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName);
}

function useShortcut(handler) {
  const latest = useRef(handler);
  latest.current = handler;
  useEffect(() => {
    function onKeyDown(event) {
      if (event.isComposing) return;
      const mod = MAC ? event.metaKey : event.ctrlKey;
      if (mod && event.code === "KeyK" && !event.shiftKey) {
        event.preventDefault();
        latest.current(event);
        return;
      }
      const plain = !event.ctrlKey && !event.metaKey && !event.altKey;
      const typing = isTypingTarget(event.target);
      if (plain && event.key === "/" && !typing) {
        event.preventDefault();
        latest.current(event);
      }
    }
    window.addEventListener("keydown", onKeyDown);
    return () => window.removeEventListener("keydown", onKeyDown);
  }, []);
}

function send(target, init) {
  const options = { ...init, bubbles: true };
  target.dispatchEvent(new KeyboardEvent("keydown", options));
}

export default function App() {
  const input = useRef(null);
  useShortcut((event) => {
    const name = event.code === "KeyK" ? "Ctrl+K (rus tartibi)" : "/";
    console.log("ochildi:", name);
  });
  return (
    <div>
      <input ref={input} aria-label="Vazifa nomi" />{" "}
      <button
        onClick={() =>
          send(window, {
            key: "л",
            code: "KeyK",
            ctrlKey: !MAC,
            metaKey: MAC,
          })
        }
      >
        Ctrl+K (rus)
      </button>{" "}
      <button onClick={() => send(window, { key: "/" })}>
        / sahifada
      </button>{" "}
      <button
        onClick={() =>
          send(input.current, { key: "/" })
        }
      >
        / maydonda
      </button>
    </div>
  );
}

Konsolda:

text
ochildi: Ctrl+K (rus tartibi)
ochildi: /

Uchinchi tugma hech narsa chiqarmadi: hodisa maydondan ko'tarildi, target — INPUT, / modifikatorsiz — e'tiborsiz. Rus tartibida (key: "л") Ctrl+K ishladi — code tufayli. latest ref — effekt bir marta ulanadi, handler esa har doim eng yangisi (latest ref naqshi; kanonda xuddi shu ishni useEffectEvent qiladi). Kanon tuzatishi ham aynan shu bitta qator edi: event.key.toLowerCase() === "k" → event.code === "KeyK". key bilan yozsangiz, «Ctrl+K (rus)» tugmasi hech narsa chiqarmaydi — sinab ko'ring.

4-mashq: Vazifalar qadami

feat/buyruqlar branch'ida: cmdk@1.1.1 va overrides ni qo'shing, BuyruqlarRoyxati.tsx va BuyruqlarOynasi.tsx ni yarating, Qobiq.tsx da ulang (oldingi darsdagi TooltipProvider ichida). Keyin commit.

Yechim

Tekshiruv ro'yxati:

  • Build: BuyruqlarRoyxati-….js alohida chunk (~6 KB gzip); birinchi yuklash ~+4 KB. Agar ~+9 KB bo'lsa — cmdk to'g'ridan import qilingan.
  • DevTools Network: sahifa ochilganda cmdk chunk'i yo'q; sichqonchani «Buyruqlar» ustiga olib borganda yoki Ctrl+K da — paydo bo'ladi.
  • Ctrl+K (maydon ichida ham) — oyna, fokus qidiruvda; «statis» → Statistika; Enter — sahifa, fokus sarlavhada; Esc — fokus tugmada.
  • Network'da "Slow 3G": birinchi ochilishda «Yuklanmoqda…», keyin maydon fokus oladi.
  • npm test, lint, tip, knip — toza.

9. Real ishda

  • Ilg'or foydalanuvchilar — Linear, GitHub, Vercel, Slack: Ctrl+K hamma joyda. Ichki panellarda ham (operator yuzlab buyurtma bilan ishlaydi) — vaqtni ko'p tejaydi.
  • Kod ko'rigi — "yorliq maydon ichida ishlab ketmaydimi?", "Mac'da ishlaydimi?", "rus tartibida-chi?" — uch odatiy savol.
  • Intervyu savollari: "key va code farqi?", "combobox naqshi va aria-activedescendant nima?", "katta komponentni qachon lazy qilasiz?", "fuzzy qidiruvning kamchiligi?".

Xulosa

  • cmdk — buyruqlar oynasining ichi: Command, Input, List, Group, Item, Empty; fokus maydonda, tanlov — aria-activedescendant (combobox naqshi).
  • Standart fuzzy qidiruv o'zbekcha apostrofni bilmaydi va keraksiz moslik beradi — filter bilan o'z normallovchi qidiruvingiz; value — noyob kalit, keywords — qidiruv matni.
  • Ctrl+K: platformaga qarab ctrlKey/metaKey, code === "KeyK", preventDefault(); bitta harfli yorliqlar maydon va isComposing da o'chiriladi.
  • A11y: ochuvchi tugma, aria-keyshortcuts, ko'rinadigan <kbd>/tooltip; Esc'da fokus qaytadi.
  • vazifalar: native <dialog> + cmdk lazy() alohida chunk'da (6.3 KB), pointerenter da oldindan yuklash; birinchi yuklash +3.7 KB; yorliq code bilan (rus tartibida ham), e2e-20 33 .

Keyingi dars: Yuklanish, bo'sh va xato holatlari UX — bo'sh ro'yxat ikonka bilan, skeleton 300 ms kechikib va motion-safe animatsiya.

Manbalar

  • cmdk — github.com/pacocoursey/cmdk (1.1.1): Command, filter, keywords, vimBindings
  • WAI-ARIA APG: Combobox Pattern; aria-keyshortcuts — w3.org/WAI/ARIA/apg, w3.org/TR/wai-aria
  • MDN: KeyboardEvent.code, KeyboardEvent.key, isComposing, navigator.platform — developer.mozilla.org
  • react-hotkeys-hook — react-hotkeys-hook.vercel.app (5.3.3)
Ulashish:Telegram'da

Izohlar (0)

Izoh yozish uchun kiring.

  • Hozircha izoh yo'q. Birinchi bo'ling!
Command palette React'da: cmdk, Ctrl+K va klaviatura yorliqlari — IlmHamroh