Mundarija (27)
- Bu darsda
- 1. Nega bu kerak?
- 2. Ko'rinish qancha "sizniki"
- 3. Birinchi headless hook
- 3.1 Xom qiymatlar — xato qilish oson
- 3.2 Prop getters
- 3.3 Nega funksiya, obyekt emas?
- 4. Getter va foydalanuvchi props'i
- 4.1 Tuzoq: yoyishdan keyingi onClick
- 4.2 Yechim: props'ni getter'ga berish
- 5. Mashhur headless kutubxonalar
- 6. Vazifalar qadami: useMenyu — xulq (holat 30)
- 6.1 Bandlar — DOM tartibida
- 6.2 Ochilganda fokus va tashqariga bosish
- 6.3 Ochish, yopish va fokus yurishi
- 6.4 Harf bilan qidirish
- 6.5 Prop getters
- 6.6 Testlar va natija
- 7. Ko'p uchraydigan xatolar
- 8. Mashqlar
- 1-mashq (oson): kim nimani hal qiladi?
- 2-mashq (o'rta): useTabs — headless
- 3-mashq (qiyin): getter'ga props birlashtirish
- 4-mashq: vazifalar qadami
- 9. Real ishda
- Xulosa
- Manbalar
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 bilanaria-expanded,onClick,onKeyDown,id,refelementga tushadi. Getter funksiya bo'lgani uchun sizning props'ingizni ham qabul qilib, hookniki bilan to'g'ri birlashtira oladi.vazifalardagiuseMenyu— 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 (
onClickzanjiri) yozasiz. - WAI-ARIA Menu Button naqshini va
vazifalardagiuseMenyuni 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", bandlardarole="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:
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:
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:
aria-expanded: false
aria-expanded: trueEkranda:
Ish vaqti?
Har kuni 9:00 dan 23:00 gachaFoydalanuvchi 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:
- 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). - Birlashtirish: getter foydalanuvchining props'ini qabul qilib, o'zinikiga qo'sha oladi — keyingi bo'lim.
- 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:
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:
analitika: bosildiEkranda:
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:
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:
analitika: bosildiEkranda:
Ish vaqti?
Har kuni 9:00 dan 23:00 gachaIkkalasi 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:
getButtonPropsichida...restARIA'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:
// 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 bosishSxemada 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
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
// 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
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
// 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
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:
refham 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"turistringga 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'ziclickga aylantiradi. Shuning uchunbandXossalarifaqatonClickberadi, 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
Menyuishlatadi va uOmitbilan 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:
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:
feat: Menyu — compound + headless (APG Menu Button), hisobotni yuklab olishCommit 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-expandedqiymati:- 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):
Ovqatlar Ichimliklar
Ko'k choy — 5 000 so'mYechim
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:
tanlangan: Ovqatlar
tanlangan: IchimliklarEkranda:
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):
analitika: ovqat
ichimlik aria-selected: falseYechim
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:
ichimlik aria-selected: false
ichimlik aria-selected: true
analitika: ovqat
ichimlik aria-selected: falseEkranda:
Ovqatlar IchimliklarUch 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
onClickini 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. vazifalarholat 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
Izohlar (0)
Izoh yozish uchun kiring.
- Hozircha izoh yo'q. Birinchi bo'ling!