IlmHamroh
JavaScript Full-stack/18-qism. React chuqur30/42-dars20 daqiqa
Mundarija (27)

Headless komponentlar React'da: xulq hookda, ko'rinish sizda — prop getters

Qisqacha: Headless komponent — ko'rinishi yo'q komponent: u holat, klaviatura, fokus va ARIA'ni beradi, qaysi teg, qanday stil va qanday tartib — to'liq sizda. Odatda bu custom hook: const menu = useMenu(). Hook elementlarga kerakli atributlarni prop getters orqali beradi: <button {...menu.getButtonProps()}> — bitta yoyish bilan aria-expanded, onClick, onKeyDown, id, ref elementga tushadi. Getter funksiya bo'lgani uchun sizning props'ingizni ham qabul qilib, hookniki bilan to'g'ri birlashtira oladi. vazifalar dagi useMenyu — shu naqsh, Menyu (compound) esa uning ustidagi yupqa qobiq.

Bu darsda

  • Styled, unstyled va headless komponentlar farqini tushuntira olasiz.
  • Oddiy headless hook yozasiz va prop getters nega xom qiymatlardan yaxshiroq ekanini ko'rasiz.
  • Getter foydalanuvchi props'ini hookniki bilan qanday birlashtirishini (onClick zanjiri) yozasiz.
  • WAI-ARIA Menu Button naqshini va vazifalar dagi useMenyu ni qatorma-qator o'qiysiz.
  • Holat 30 ni testlar bilan tugatib, commit qilasiz.

Oldin bilishingiz kerak: Controlled va uncontrolled komponent API dizayni, Compound komponentlar va slot naqshi, Polymorphic komponentlar va asChild, useLayoutEffect.

1. Nega bu kerak?

Oldingi darsda Slot props'larni birlashtirdi. Bu darsda o'sha g'oya teskari tomondan keladi: hook props'larni beradi, siz ularni elementga yoyasiz.

«Bahor» uchun menyu tugmasi kerak — #26 dagi «Hisobotni yuklab olish». Ko'rinishda u oddiy: tugma va ikkita band. Lekin to'g'ri menyu tugmasi uchun WAI-ARIA qo'llanmasi (APG — Authoring Practices Guide) o'ndan ortiq qoida yozadi:

  • tugmada aria-haspopup="menu", aria-expanded, aria-controls;
  • ro'yxatda role="menu", bandlarda role="menuitem";
  • ↓ bosilsa — ochiladi va fokus birinchi bandga, ↑ — oxirgisiga;
  • ro'yxat ichida ↓/↑ — aylanib yuradi, Home/End — boshi va oxiri;
  • harf bosilsa — shu harf bilan boshlanadigan bandga sakraydi;
  • Esc — yopiladi va fokus tugmaga qaytadi; Tab — yopiladi, fokus tabiiy davom etadi;
  • tashqariga bosilsa — yopiladi.

Bu — 200 qator kod. Endi tasavvur qiling: admin panelda xuddi shu menyu ikonkalar bilan kerak, telefonda esa pastdan chiqadigan panel bo'lib. Uchta ko'rinish — bitta xulq. Xulqni uch marta yozish — uch marta xato qilish imkoni.

Tayyor kutubxona olish mumkin (MUI Menu). Lekin u o'z ko'rinishi bilan keladi: dizaynerning maketiga moslash uchun uning stillari bilan "urushish" kerak. Kerakli narsa — xulq alohida, ko'rinish alohida.

Hayotiy o'xshatish: avtomobil shassisi. Zavod dvigatel, rul, tormoz va g'ildiraklarni — "xulq" ni — tayyorlaydi. Ustiga esa avtobus kuzovi ham, yuk mashinasi kuzovi ham qo'yiladi. Kuzov yasovchi tormoz tizimini qaytadan o'ylab topmaydi. Headless komponent — shassi.

2. Ko'rinish qancha "sizniki"

UI komponentlari ko'rinishni qanchalik o'zi hal qilishiga qarab uch xil bo'ladi:

Tur Nima beradi Misol
Styled (tayyor dizaynli) xulq + tayyor ko'rinish MUI, Ant Design, Chakra
Unstyled (stilsiz komponentlar) xulq + DOM tuzilmasi, stilsiz Radix, Headless UI, Base UI
Headless (hook) faqat xulq: holat va atributlar TanStack Table, Downshift, React Aria hooklari

Ikkinchi va uchinchi qator o'rtasidagi farq nozik. Radix <DropdownMenu.Content> ni o'zi chizadi — <div> va uning ichki tuzilmasi uniki, siz faqat stil berasiz (#26 dagi compound). Headless hook esa umuman hech narsa chizmaydi. U qaytargan obyektni qaysi elementga yoyishni siz hal qilasiz: <ul> ga, <div> ga, <table> ga yoki umuman boshqa joyga.

vazifalar da ikkalasi ham bor va bu tasodif emas:

flowchart TD
  H["useMenyu (headless)<br/>holat, klaviatura, ARIA"] --> M["Menyu (compound)<br/>Tugma · Royxat · Band"]
  M --> S["BatafsilStatistika<br/>CSV / JSON"]
  H -.->|"to'g'ridan-to'g'ri"| X["boshqa ko'rinish<br/>(masalan, pastki panel)"]

Diagrammadagi asosiy narsa: Menyu — useMenyu ning bitta ko'rinishi. Ertaga boshqa ko'rinish kerak bo'lsa, hook o'zgarmaydi — uning ustiga yangi komponent yoziladi.

3. Birinchi headless hook

3.1 Xom qiymatlar — xato qilish oson

Eng sodda menyuga o'xshash narsa — ochiladigan blok (disclosure): tugma va panel. Birinchi variant: hook faqat holatni qaytaradi, qolganini foydalanuvchi o'zi yozadi:

jsx
function useDisclosure() {
  const [open, setOpen] = useState(false);
  const id = useId();
  return { open, toggle: () => setOpen(!open), id };
}

function Faq() {
  const d = useDisclosure();
  return (
    <>
      <button onClick={d.toggle}>Ish vaqti?</button>
      {/* ❌ aria-expanded va aria-controls unutildi */}
      <p id={d.id} hidden={!d.open}>9:00 — 23:00</p>
    </>
  );
}

Ko'zga hammasi ishlaydi. Lekin Malikaning ekran o'quvchisi tugma blokni ochishini ham, blok hozir ochiqmi — buni ham bilmaydi: aria-expanded va aria-controls yo'q. Xulqning yarmi — atributlar. Hook ularni foydalanuvchining xotirasiga qoldirdi, foydalanuvchi esa unutdi.

3.2 Prop getters

Prop getter — element uchun kerakli hamma props'ni obyekt qilib qaytaradigan funksiya. Foydalanuvchi uni bitta yoyish bilan elementga beradi:

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

function useDisclosure() {
  const [open, setOpen] = useState(false);
  const panelId = useId();
  return {
    open,
    getButtonProps: () => ({
      type: "button",
      "aria-expanded": open,
      "aria-controls": panelId,
      onClick: () => setOpen(!open),
    }),
    getPanelProps: () => ({ id: panelId, hidden: !open }),
  };
}

function App() {
  const d = useDisclosure();
  useEffect(() => {
    const b = document.querySelector("button");
    console.log("aria-expanded:", b.getAttribute("aria-expanded"));
  });
  return (
    <section>
      <button {...d.getButtonProps()}>Ish vaqti?</button>
      <p {...d.getPanelProps()}>Har kuni 9:00 dan 23:00 gacha</p>
    </section>
  );
}

Konsolda:

text
aria-expanded: false
aria-expanded: true

Ekranda:

text
Ish vaqti?
Har kuni 9:00 dan 23:00 gacha

Foydalanuvchi endi ARIA haqida o'ylamaydi: {...d.getButtonProps()} hammasini olib keladi. Shu bilan birga teg, matn, tartib — uniki. U <section> o'rniga <li> yozishi, tugmaga ikonka qo'yishi, panelni tugmadan oldin chizishi mumkin. Hook uni cheklamaydi. Bu — render control (chizishni boshqarish) foydalanuvchida degani: hook "nima qilish kerak" ni biladi, "qanday ko'rinish" ni esa yo'q.

hidden: !open — hidden atributli element ekranga ham, a11y daraxtiga ham kirmaydi. Shuning uchun boshida konsolda false va ekranda faqat tugma edi.

3.3 Nega funksiya, obyekt emas?

getButtonProps() o'rniga buttonProps obyektini qaytarsa bo'lmaydimi? Uchta sabab bor:

  1. Parametr: getter argument olishi mumkin. Ro'yxatdagi har band uchun alohida props kerak: getItemProps({ item, index }) — qaysi band tanlanganini hook shundan biladi. Kanonda: bandXossalari(onTanla).
  2. Birlashtirish: getter foydalanuvchining props'ini qabul qilib, o'zinikiga qo'sha oladi — keyingi bo'lim.
  3. Kelajak: keyinchalik atribut hisoblash qimmatlashsa yoki yangi parametr qo'shilsa, API o'zgarmaydi.

4. Getter va foydalanuvchi props'i

4.1 Tuzoq: yoyishdan keyingi onClick

Sardor tugma bosilganini analitikaga yubormoqchi:

jsx
import { useId, useState } from "react";

function useDisclosure() {
  const [open, setOpen] = useState(false);
  const panelId = useId();
  return {
    getButtonProps: () => ({
      "aria-expanded": open,
      "aria-controls": panelId,
      onClick: () => setOpen(!open),
    }),
    getPanelProps: () => ({ id: panelId, hidden: !open }),
  };
}

function App() {
  const d = useDisclosure();
  return (
    <section>
      <button
        {...d.getButtonProps()}
        onClick={() => console.log("analitika: bosildi")} // ❌
      >
        Ish vaqti?
      </button>
      <p {...d.getPanelProps()}>Har kuni 9:00 dan 23:00 gacha</p>
    </section>
  );
}

Konsolda:

text
analitika: bosildi

Ekranda:

text
Ish vaqti?

Analitika ishladi, panel esa ochilmadi. JSX'da keyingi atribut oldingisini yopadi — bu oddiy obyekt yoyish qoidasi (spread). Sardorning onClick i hookning onClick ini o'chirib yubordi. Teskari tartibda yozsa (onClick yoyishdan oldin) — hookniki yutadi, analitika o'chadi. Ikkala tartib ham noto'g'ri.

4.2 Yechim: props'ni getter'ga berish

Getter foydalanuvchi props'ini argument sifatida oladi va hodisalarni zanjirlaydi — Slot dagi mergeProps g'oyasi:

jsx
import { useId, useState } from "react";

function callAll(...fns) {
  return (...args) => fns.forEach((fn) => fn?.(...args));
}

function useDisclosure() {
  const [open, setOpen] = useState(false);
  const panelId = useId();
  return {
    getButtonProps: ({ onClick, ...rest } = {}) => ({
      ...rest,
      "aria-expanded": open,
      "aria-controls": panelId,
      onClick: callAll(onClick, () => setOpen(!open)),
    }),
    getPanelProps: (props = {}) => ({
      ...props,
      id: panelId,
      hidden: !open,
    }),
  };
}

function App() {
  const d = useDisclosure();
  return (
    <section>
      <button
        {...d.getButtonProps({
          onClick: () => console.log("analitika: bosildi"),
          className: "faq-tugma",
        })}
      >
        Ish vaqti?
      </button>
      <p {...d.getPanelProps()}>Har kuni 9:00 dan 23:00 gacha</p>
    </section>
  );
}

Konsolda:

text
analitika: bosildi

Ekranda:

text
Ish vaqti?
Har kuni 9:00 dan 23:00 gacha

Ikkalasi ham ishladi. callAll — "hammasini chaqir": undefined larni o'tkazib yuborib, har funksiyani bir xil argumentlar bilan chaqiradi. Getter ichidagi tartib ham qaror: ...rest avval, hookning ARIA atributlari keyin. Foydalanuvchi className qo'sha oladi, lekin aria-expanded ni buzolmaydi — u xulqning bir qismi.

Downshift bu qoidani bir qadam oldinga olib boradi: foydalanuvchi ishlovchisi ichida event.preventDownshiftDefault = true yozsa, hook o'z ishini qilmaydi. Bu — state reducer ga yaqin "rad etish" imkoniyati.

Tekshirib ko'ring: getButtonProps ichida ...rest ARIA'dan keyin yozilsa, <button {...getButtonProps({ "aria-expanded": true })}> nimaga olib keladi?

Javob

Foydalanuvchi qiymati yutadi: tugma doim "ochiq" deb e'lon qilinadi, panel esa yopiq bo'lishi mumkin. Ekran o'quvchi yolg'on gapiradi. Shuning uchun hook xulqiga tegishli atributlar (holat, rol, bog'lovchi id'lar) oxirida yoziladi, bezak atributlari (className, data-*) esa foydalanuvchidan erkin olinadi. vazifalar dagi Menyu.Tugma ham shunday: {...qolgan} avval, {...menyu.tugmaXossalari()} keyin (#26).

5. Mashhur headless kutubxonalar

Headless API'ni o'qishni o'rgansangiz, bu kutubxonalarni ham tez tushunasiz:

jsx
// Downshift: avtoto'ldirish (combobox)
const {
  isOpen,
  getInputProps,
  getMenuProps,
  getItemProps,
  highlightedIndex,
} = useCombobox({ items, onInputValueChange });

<input {...getInputProps()} />
<ul {...getMenuProps()}>
  {isOpen &&
    items.map((item, index) => (
      <li key={item} {...getItemProps({ item, index })}>
        {item}
      </li>
    ))}
</ul>

Tanish narsalar: isOpen — holat, get…Props — getterlar, getItemProps({ item, index }) — parametrli getter. <ul> va <li> — sizniki, istalgan teg bo'lishi mumkin.

Kutubxona Nima uchun API
Downshift combobox, select useCombobox, useSelect + getterlar
TanStack Table jadval: saralash, filtr, sahifalash useReactTable → getHeaderGroups(), getRowModel()
React Aria (Adobe) 50+ komponent xulqi useButton(props, ref) → buttonProps
TanStack Virtual virtual ro'yxat useVirtualizer → getVirtualItems()

TanStack Virtual'ni virtualizatsiya darsida o'lchagan edik — u ham headless: elementlarni siz chizasiz, hook faqat qaysi qatorlar va qayerda ekanini aytadi. vazifalar o'z useOynaVirtual hookini yozgan — u ham headless.

6. Vazifalar qadami: useMenyu — xulq (holat 30)

feat/ui-menyu branch'idagi uchinchi va oxirgi fayl — src/ui/useMenyu.ts. #26 da Menyu uning natijasini ishlatdi, #27 da boshida useBoshqariladiganHolat ni ko'rdik. Endi ichini o'qiymiz. Avval holatlar sxemasi — APG Menu Button qoidalarining qisqa xaritasi:

stateDiagram-v2
  [*] --> Yopiq
  Yopiq --> Ochiq: bosish yoki ↓ (fokus birinchiga)
  Yopiq --> Ochiq: ↑ (fokus oxirgiga)
  Ochiq --> Ochiq: ↓ ↑ Home End harf — fokus bandlar bo'ylab
  Ochiq --> Yopiq: Esc yoki band tanlandi (fokus tugmaga)
  Ochiq --> Yopiq: Tab yoki tashqariga bosish

Sxemada fokusning yo'li ham bor: menyudan qaysi yo'l bilan chiqilsa, fokus qayerga borishi APG'da aniq yozilgan. Klaviatura bilan ishlaydigan Bobur uchun bu — eng muhim qism.

6.1 Bandlar — DOM tartibida

ts
  const tugmaId = useId();
  const royxatId = useId();
  const tugmaRef = useRef<HTMLButtonElement>(null);
  const royxatRef = useRef<HTMLDivElement>(null);
  const [fokusJoyi, setFokusJoyi] = useState<FokusJoyi>("birinchi");

  // Bandlar — DOM tartibida (compound bolalari o'zini ro'yxatdan
  // o'tkazishi shart emas): role="menu" ichidagi menuitem'lar
  function bandlar(): HTMLElement[] {
    return [
      ...(royxatRef.current?.querySelectorAll<HTMLElement>(
        '[role="menuitem"]',
      ) ?? []),
    ];
  }

Compound komponentlar darsidagi savolni eslang: "qism o'zi nechanchi ekanini qanday biladi?" Kanonning javobi — bilmaydi va bilishi shart emas. Hook kerak bo'lganda (strelka bosilganda) ro'yxat ichidagi menuitem larni DOM'dan o'qiydi. Bu render paytida emas, hodisada bo'ladi — DOM tayyor. Bandlar shartli bo'lsa ham, tartib doim to'g'ri. useId ikki marta: tugma va ro'yxat bir-biriga aria-controls va aria-labelledby orqali bog'lanadi (useId).

6.2 Ochilganda fokus va tashqariga bosish

ts
  // Ochildi — fokus bandga (commit'dan keyin: bandlar endi ko'rinadi)
  useLayoutEffect(() => {
    if (ochiq) {
      const b = bandlar();
      (fokusJoyi === "birinchi" ? b[0] : b.at(-1))?.focus();
    }
  }, [ochiq, fokusJoyi]);

  // Tashqariga bosilsa — yopiladi (fokus o'sha joyda qoladi)
  const yopilsin = () => setOchiq(false);
  useEffect(() => {
    if (!ochiq) {
      return undefined;
    }
    function tashqarida(event: PointerEvent) {
      const nishon = event.target;
      if (
        nishon instanceof Node &&
        !(royxatRef.current?.contains(nishon) ?? false) &&
        !(tugmaRef.current?.contains(nishon) ?? false)
      ) {
        yopilsin();
      }
    }
    addEventListener("pointerdown", tashqarida);
    return () => removeEventListener("pointerdown", tashqarida);
  });

Nega useLayoutEffect? Ochilganda ro'yxat hidden dan chiqadi, va fokus brauzer chizishidan oldin bandga o'tishi kerak. Oddiy effekt bo'lsa, ro'yxat ekranga chiqib, fokus esa bir lahza tugmada qolishi mumkin edi — ekran o'quvchi avval tugmani, keyin bandni e'lon qilardi (useLayoutEffect). Yashirin (hidden) elementga fokus berib bo'lmaydi — shuning uchun fokus commit'dan keyin beriladi, render paytida emas.

Tashqariga bosish effektining bog'liqliklar massivi yo'q — u har renderdan keyin qayta obuna bo'ladi. Bu ataylab: yopilsin har renderda yangi va eng so'nggi setOchiq ni chaqiradi (controlled rejimda — otaning onOchiqOzgardi sini). Menyu ochiq turganda renderlar kam, shuning uchun narxi sezilmaydi. Muqobil — useEffectEvent. pointerdown — sichqoncha, barmoq va qalam uchun bitta hodisa, click dan oldin keladi.

6.3 Ochish, yopish va fokus yurishi

ts
  function och(joy: FokusJoyi) {
    setFokusJoyi(joy);
    setOchiq(true);
  }

  // Yopish va fokusni tugmaga qaytarish (Esc, band tanlandi)
  function yop() {
    setOchiq(false);
    tugmaRef.current?.focus();
  }

  function fokusniSur(qadam: 1 | -1 | "bosh" | "oxir") {
    const b = bandlar();
    const hozir = b.findIndex(
      (band) => band === document.activeElement,
    );
    let keyingi: number;
    if (qadam === "bosh") {
      keyingi = 0;
    } else if (qadam === "oxir") {
      keyingi = b.length - 1;
    } else {
      keyingi = (hozir + qadam + b.length) % b.length; // aylanma
    }
    b[keyingi]?.focus();
  }

(hozir + qadam + b.length) % b.length — aylanma indeks. Oxirgi bandda ↓: (2 + 1 + 3) % 3 = 0 — birinchiga. Birinchida ↑: (0 − 1 + 3) % 3 = 2 — oxirgiga. + b.length manfiy sondan qochish uchun: JavaScript'da -1 % 3 — -1, 2 emas (qoldiq operatori).

Fokus state'da emas, DOM'da saqlanadi (document.activeElement). Bu — roving tabindex (ko'chib yuruvchi fokus) naqshining sodda varianti: bandlarda tabIndex={-1}, ya'ni Tab ularga to'xtamaydi, faqat strelkalar fokusni ko'chiradi. To'liq naqsh — Fokus boshqaruvi darsida.

6.4 Harf bilan qidirish

ts
  // Harf bosilsa — shu harf bilan boshlanadigan keyingi band
  // (type-ahead)
  function harfBilan(harf: string) {
    const b = bandlar();
    const hozir = b.findIndex(
      (band) => band === document.activeElement,
    );
    const tartib = [...b.slice(hozir + 1), ...b.slice(0, hozir + 1)];
    tartib
      .find((band) =>
        (band.textContent ?? "")
          .trim()
          .toLowerCase()
          .startsWith(harf.toLowerCase()),
      )
      ?.focus();
  }

tartib — hozirgi banddan keyingilar, keyin boshidan hozirgigacha. Shuning uchun "j" ikki marta bosilsa, ikkinchi "j" li bandga o'tadi, birinchisida qolib ketmaydi. Izoh telefonga sig'ishi uchun ikki qatorga bo'lindi.

6.5 Prop getters

ts
  return {
    ochiq,
    tugmaXossalari: () => ({
      ref: tugmaRef,
      id: tugmaId,
      type: "button" as const,
      "aria-haspopup": "menu" as const,
      "aria-expanded": ochiq,
      "aria-controls": royxatId,
      onClick: () => {
        if (ochiq) {
          setOchiq(false);
        } else {
          och("birinchi");
        }
      },
      onKeyDown: (event: KeyboardEvent<HTMLButtonElement>) => {
        if (event.key === "ArrowDown" || event.key === "ArrowUp") {
          event.preventDefault();
          och(event.key === "ArrowDown" ? "birinchi" : "oxirgi");
        }
      },
    }),
    royxatXossalari: () => ({
      ref: royxatRef,
      id: royxatId,
      role: "menu" as const,
      "aria-labelledby": tugmaId,
      hidden: !ochiq,
      onKeyDown: (event: KeyboardEvent<HTMLDivElement>) => {
        switch (event.key) {
          case "ArrowDown":
            event.preventDefault();
            fokusniSur(1);
            return;
          // … ArrowUp (-1), Home ("bosh"), End ("oxir") —
          // xuddi shunday
          case "Escape":
            event.preventDefault();
            yop();
            return;
          case "Tab":
            setOchiq(false); // fokus tabiiy davom etadi
            return;
          default:
            if (event.key.length === 1 && /\S/.test(event.key)) {
              harfBilan(event.key);
            }
        }
      },
    }),
    // Band tanlansa (bosish, Enter, Space — <button> o'zi click
    // beradi)
    bandXossalari: (onTanla: () => void) => ({
      role: "menuitem" as const,
      tabIndex: -1,
      onClick: (event: MouseEvent<HTMLElement>) => {
        event.preventDefault();
        yop();
        onTanla();
      },
    }),
  };

ArrowUp, Home, End qismlari … bilan qisqartirildi — ular ArrowDown bilan bir xil, faqat fokusniSur argumenti boshqa. Ikki izoh telefonga sig'ishi uchun bo'lindi, kod qatorlari — aynan kanon. E'tibor beradigan joylar:

  • ref ham getter ichida. {...tugmaXossalari()} ref'ni ham elementga beradi — React 19 da ref oddiy props. Hook o'z elementini shu yo'l bilan "ushlaydi".
  • as const — "menu" turi string ga kengaymasin, aniq "menu" qolsin. Shunda <button aria-haspopup={…}> ning turi mos keladi (literal turlar).
  • event.preventDefault() strelkalarda — sahifa aylanib ketmasin.
  • Bandlar — <button>. Enter va Space ni ular o'zi click ga aylantiradi. Shuning uchun bandXossalari faqat onClick beradi, klaviatura uchun alohida kod yo'q. Native element bepul beradigan xulqni qaytadan yozmaslik — #26 dagi «Teglar tartibi» qaroriga o'xshash.
  • Getterlar foydalanuvchi props'ini olmaydi. «Yechim: props'ni getter'ga berish» bo'limidagi birlashtirish bu yerda kerak bo'lmadi: hookni faqat Menyu ishlatadi va u Omit bilan menyu xulqiga tegishli props'larni turdan olib tashlagan (#26). Hook kutubxonaga chiqsa — birlashtirish qo'shiladi.

6.6 Testlar va natija

tekshiruv/Menyu.test.tsx — 6 ta test. Klaviatura testi:

tsx
  test("strelkalar aylanadi, Home/End, harf bilan (type-ahead); ↑ — oxirgisidan ochadi", () => {
    render(<Namuna />);
    fireEvent.keyDown(tugma(), { key: "ArrowUp" });
    expect(fokusda()).toBe("PDF");
    const menyu = screen.getByRole("menu");
    fireEvent.keyDown(menyu, { key: "ArrowDown" });
    expect(fokusda()).toBe("CSV"); // oxiridan — boshiga
    fireEvent.keyDown(menyu, { key: "ArrowUp" });
    expect(fokusda()).toBe("PDF");
    fireEvent.keyDown(menyu, { key: "Home" });
    expect(fokusda()).toBe("CSV");
    fireEvent.keyDown(menyu, { key: "End" });
    expect(fokusda()).toBe("PDF");
    fireEvent.keyDown(menyu, { key: "j" });
    expect(fokusda()).toBe("JSON");
  });

Namuna — uch bandli (CSV, JSON, PDF) test menyusi, fokusda() — document.activeElement ning matni. Test butun «Ochiq → Ochiq» o'qini bir ketma-ketlikda yuradi. Qolgan beshtasi: ARIA atributlari, Esc va band tanlash (fokus tugmaga qaytadi), tashqariga bosish, controlled rejim (#27) va <Menyu> tashqarisidagi aniq xato (#26). Test nomi 70 belgidan uzun — kanon qatori.

Holat 30 natijasi (natijalar/tekshiruv-30.txt): build, lint, format:check, tip toza; testlar — node 170, Vitest 45 (+6). Brauzerda — 159/159, yangi xulq tekshiruvi e2e-18 — 50/50. U menyuni faqat klaviatura bilan ochib, CSV'ni yuklab oladi va fayl tarkibini (BOM, qo'shtirnoq, '= himoyasi) tekshiradi. Diff: 8 fayl, +553 qator.

Commit:

text
feat: Menyu — compound + headless (APG Menu Button), hisobotni yuklab olish

Commit tanasida — uch qatlam: useMenyu (headless, prop getters), useBoshqariladiganHolat (controlled/uncontrolled), Menyu (compound, Context); va «Hisobotni yuklab olish» — CSV (BOM, formula injection) va JSON.

7. Ko'p uchraydigan xatolar

Belgi Sabab Davosi
Hookning onClick i ishlamay qoldi foydalanuvchi onClick yoyishdan keyin yozildi props'ni getter'ga bering, getter zanjirlaydi
Ekran o'quvchi holatni bilmaydi hook xom qiymat qaytaradi, ARIA foydalanuvchida prop getters
Ochilganda fokus bandga o'tmaydi fokus render paytida yoki useEffect da yashirin elementga useLayoutEffect, commit'dan keyin
↓ sahifani aylantiradi preventDefault yo'q strelka ishlovchisida event.preventDefault()
Getter yoyilgan, lekin ref yo'q getter ref qaytarmaydi yoki o'ram komponent ref'ni yoymaydi getterda ref; o'ramda {...props}
Har band Tab'da to'xtaydi bandlarda tabIndex yo'q tabIndex: -1 — fokus faqat strelka bilan

8. Mashqlar

Mashqlar kurs/mashqlar/18/30-headless/ da. Har yechimni «Ishga tushir» bilan sinang.

1-mashq (oson): kim nimani hal qiladi?

hook yoki foydalanuvchi deb yozing (headless menyu):

  • aria-expanded qiymati:
  • Tugmaning rangi va ikonkasi:
  • ↓ bosilganda fokus qaysi bandga o'tishi:
  • Bandlar <ul> dami yoki <div> da:
Yechim

Headless'ning chegarasi: xulq (holat, ARIA, klaviatura, fokus) — hookda; ko'rinish va tuzilma (teg, stil, ikonka, tartib) — foydalanuvchida. aria-expanded — holatning a11y ko'rinishi, demak xulq. Rang — sof ko'rinish. Fokus yurishi — APG qoidasi, xulq. Teg tanlash — tuzilma, foydalanuvchiniki.

2-mashq (o'rta): useTabs — headless

#26 dagi Tabs ni headless hook qiling: useTabs({ defaultValue }) → { value, getTabProps(value), getPanelProps(value) }. getTabProps role="tab", aria-selected, id, aria-controls va onClick qaytarsin; getPanelProps — role="tabpanel", id, aria-labelledby va hidden. App hookni ishlatib, tablarni <button>, panellarni <p> bilan chizsin. Effektda tanlangan tabning aria-selected ini konsolga chiqaring.

Kutilgan natija («Ichimliklar» bosilgandan keyin):

text
Ovqatlar Ichimliklar
Ko'k choy — 5 000 so'm
Yechim
jsx
import { useEffect, useId, useState } from "react";

function useTabs({ defaultValue }) {
  const [value, setValue] = useState(defaultValue);
  const base = useId();
  return {
    value,
    getTabProps: (v) => ({
      role: "tab",
      id: `${base}-tab-${v}`,
      "aria-controls": `${base}-panel-${v}`,
      "aria-selected": value === v,
      onClick: () => setValue(v),
    }),
    getPanelProps: (v) => ({
      role: "tabpanel",
      id: `${base}-panel-${v}`,
      "aria-labelledby": `${base}-tab-${v}`,
      hidden: value !== v,
    }),
  };
}

function App() {
  const tabs = useTabs({ defaultValue: "ovqat" });
  useEffect(() => {
    const t = document.querySelector('[aria-selected="true"]');
    console.log("tanlangan:", t.textContent);
  }, [tabs.value]);
  return (
    <div>
      <div role="tablist">
        <button {...tabs.getTabProps("ovqat")}>Ovqatlar</button>{" "}
        <button {...tabs.getTabProps("ichimlik")}>Ichimliklar</button>
      </div>
      <p {...tabs.getPanelProps("ovqat")}>Osh — 35 000 so'm</p>
      <p {...tabs.getPanelProps("ichimlik")}>
        Ko'k choy — 5 000 so'm
      </p>
    </div>
  );
}

Konsolda:

text
tanlangan: Ovqatlar
tanlangan: Ichimliklar

Ekranda:

text
Ovqatlar Ichimliklar
Ko'k choy — 5 000 so'm

#26 dagi Context endi kerak emas: hook qiymatlarni to'g'ridan-to'g'ri qaytaradi, App ularni o'zi tarqatadi. Getterlar parametrli — har tab va panel o'z v si bilan. Panellar hidden bilan yashiringan, o'chirilmagan: ichidagi state saqlanadi (Activity'dagi g'oyaning sodda varianti, Activity). Bu hook ustiga #26 dagi compound Tabs ni yupqa qobiq qilib yozish mumkin — vazifalar dagi useMenyu + Menyu juftligi kabi.

3-mashq (qiyin): getter'ga props birlashtirish

2-mashqdagi getTabProps foydalanuvchi props'ini qabul qilmaydi. Uni getTabProps(v, props) qiling: foydalanuvchining onClick i ham, hookniki ham chaqirilsin (avval foydalanuvchiniki), className o'tkazilsin, lekin aria-selected va role ni foydalanuvchi buzolmasin. App da birinchi tabga onClick: () => console.log("analitika: ovqat"), ikkinchisiga "aria-selected": true (buzishga urinish) bering.

Kutilgan natija («Ovqatlar» bosilgandan keyin konsolda va effektdan):

text
analitika: ovqat
ichimlik aria-selected: false
Yechim
jsx
import { useEffect, useId, useState } from "react";

const callAll =
  (...fns) =>
  (...args) =>
    fns.forEach((fn) => fn?.(...args));

function useTabs({ defaultValue }) {
  const [value, setValue] = useState(defaultValue);
  const base = useId();
  return {
    value,
    getTabProps: (v, { onClick, ...rest } = {}) => ({
      ...rest, // className, data-* — erkin
      role: "tab", // xulq — foydalanuvchidan keyin
      id: `${base}-tab-${v}`,
      "aria-selected": value === v,
      onClick: callAll(onClick, () => setValue(v)),
    }),
  };
}

function App() {
  const tabs = useTabs({ defaultValue: "ovqat" });
  useEffect(() => {
    const id = tabs.getTabProps("ichimlik").id;
    const selected = document
      .getElementById(id)
      .getAttribute("aria-selected");
    console.log("ichimlik aria-selected:", selected);
  }, [tabs]);
  return (
    <div role="tablist">
      <button
        {...tabs.getTabProps("ovqat", {
          onClick: () => console.log("analitika: ovqat"),
          className: "tab",
        })}
      >
        Ovqatlar
      </button>{" "}
      <button
        {...tabs.getTabProps("ichimlik", { "aria-selected": true })}
      >
        Ichimliklar
      </button>
    </div>
  );
}

Konsolda:

text
ichimlik aria-selected: false
ichimlik aria-selected: true
analitika: ovqat
ichimlik aria-selected: false

Ekranda:

text
Ovqatlar Ichimliklar

Uch holat — uch effekt: boshida (false — buzish urinishi o'tmadi), «Ichimliklar» dan keyin (true — haqiqiy holat), «Ovqatlar» dan keyin (false). Analitika hookning onClick idan oldin chiqdi — callAll(onClick, …) tartibi. Foydalanuvchining "aria-selected": true si ...rest bilan avval yozildi, keyin hookniki ustidan yozdi. Effekt bog'liqligi — tabs: hook har renderda yangi obyekt qaytaradi, shuning uchun effekt har renderda ishlaydi. Bu yerda ataylab — har holatni ko'rish uchun.

4-mashq: vazifalar qadami

src/ui/useMenyu.ts ni «Vazifalar qadami» dagidek yozing (#26 dagi stub o'rniga). tekshiruv/Menyu.test.tsx ga 6 ta testni qo'shing. npm test, npm run lint, npm run tip, npm run format:check toza bo'lsin. Brauzerda menyuni sichqonchasiz sinang: Tab bilan tugmaga keling, ↓, ↓, Enter — JSON yuklab olinadi. Commit va PR.

Yechim

Kutilgan: node 170, Vitest 45. Klaviatura sinovida fokus ko'rinishi kerak — .menyu-band:focus-visible stili (#26). Agar ↓ dan keyin fokus bandga o'tmasa, useLayoutEffect ning bog'liqliklarini tekshiring: [ochiq, fokusJoyi]. Biri tushib qolsa, ↑ bilan ochish birinchi bandga boradi. ESLint react-hooks/exhaustive-deps bu yerda bandlar funksiyasi haqida ogohlantirmaydi, chunki u faqat ref o'qiydi. Ogohlantirsa ham, eslint-disable emas — sababini o'qing (xatolar katalogi). Commit xabari — «Testlar va natija» bo'limida.

9. Real ishda

  • Dizayn tizimlari: katta kompaniyalar (Adobe — React Aria, Atlassian, Shopify) xulqni headless qatlamga, ko'rinishni o'z dizayn tizimiga ajratadi. Bitta xulq — veb, mobil veb va admin panel ko'rinishlari.
  • shadcn/ui: Radix (unstyled) + Tailwind ko'rinishi; TanStack Table (headless) + shadcn jadval ko'rinishi — "headless + o'z ko'rinishingiz" naqshining eng mashhur namunasi (UI kutubxonalar xaritasi).
  • O'z hookingiz: jadval saralash, ko'p bosqichli forma, fayl yuklash zonasi — ko'p ko'rinishli xulq bo'lsa, useX + getterlar.
  • Intervyu savollari: "Headless komponent nima va styled'dan farqi?", "Prop getters nima uchun kerak?", "Getter foydalanuvchi onClick ini qanday birlashtiradi?", "Menu Button naqshida fokus qayerga qaytadi?".

Xulosa

  • Headless — xulq (holat, klaviatura, fokus, ARIA) hookda, ko'rinish va tuzilma foydalanuvchida.
  • Styled → unstyled (Radix) → headless (hook): har pog'onada ko'rinish ustidan nazorat ko'proq.
  • Prop getter — element uchun props obyektini qaytaradigan funksiya: bitta yoyish bilan ARIA va hodisalar to'g'ri tushadi.
  • Getter foydalanuvchi props'ini argument sifatida oladi: hodisalar zanjirlanadi (callAll), bezak erkin, xulq atributlari oxirida.
  • vazifalar holat 30: useMenyu — APG Menu Button (strelkalar, Home/End, harf, Esc, Tab, tashqariga bosish), fokus DOM'da, bandlar DOM tartibida; Menyu — uning compound qobig'i.

Keyingi dars: Komponent API'sini loyihalash — props nomlash, boolean portlashi, variant va vazifalar ning Tugma komponenti.

Manbalar

  • WAI-ARIA APG: Menu Button Pattern — w3.org/WAI/ARIA/apg/patterns/menu-button/
  • Kent C. Dodds: "How to give rendering control to users with prop getters" — kentcdodds.com/blog/how-to-give-rendering-control-to-users-with-prop-getters
  • Downshift: useCombobox — downshift-js.com
  • React Aria: hooks — react-spectrum.adobe.com/react-aria/hooks.html
  • TanStack Table: "Headless" — tanstack.com/table/latest/docs/introduction
  • Kanon: sinov/18/QADAMLAR.md (#26–#30), holatlar/30/src/ui/useMenyu.ts, tekshiruv/Menyu.test.tsx, natijalar/tekshiruv-30.txt
Ulashish:Telegram'da

Izohlar (0)

Izoh yozish uchun kiring.

  • Hozircha izoh yo'q. Birinchi bo'ling!
Headless komponentlar React'da: xulq hookda, ko'rinish sizda — prop getters — IlmHamroh