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

Fokus boshqaruvi va klaviatura navigatsiyasi: modal, roving tabindex, skip link va inert

Qisqacha: React DOM'ni o'zgartirganda fokus haqida o'ylamaydi: element o'chsa, fokus <body> ga tushib qoladi va klaviatura foydalanuvchisi "adashadi". Shuning uchun fokusni o'zingiz boshqarasiz: modal ochilganda — ichiga, yopilganda — ochgan tugmaga qaytarish; modal ichida Tab aylansin (focus trap), orqadagi sahifa esa inert bo'lsin. Ro'yxat va menyularda Tab bitta to'xtash, ichida — strelkalar (roving tabindex). Sahifa boshida — "Asosiy qismga o'tish" (skip link). Fokus siljimasa ham muhim xabar — aria-live hudud orqali.

Bu darsda

  • Modal oynada fokusni ichkariga olib kirasiz va yopilganda ochgan tugmaga qaytarasiz.
  • Focus trap'ni qo'lda va native <dialog> + inert bilan qilasiz, farqini tushuntira olasiz.
  • Roving tabindex bilan strelka navigatsiyasini yozasiz.
  • Skip link qo'shasiz va SPA'da "sahifa" almashganda fokusni sarlavhaga qo'yasiz.
  • vazifalar ga «Ro'yxatga o'tish» havolasini qo'shasiz va Boburning klaviatura ssenariysini o'qiysiz.

Oldin bilishingiz kerak: React'da accessibility asoslari, useRef II, Klaviatura va fokus boshqaruvi, useLayoutEffect.

1. Nega bu kerak?

Oldingi darsda har elementga to'g'ri teg va nom berdik. Lekin dinamik ilovada yana bir muammo bor — fokus qayerda.

Bobur «Bahor» saytida taomni o'chirmoqchi. Tab bilan «O'chirish» ga keldi, Enter bosdi. Tasdiq oynasi ochildi — lekin fokus orqadagi sahifada qoldi. Bobur Tab bosaveradi, fokus oyna orqasidagi ko'rinmaydigan tugmalarda yuradi. Oxiri oynani sichqonchasiz yopolmay, sahifani yangiladi.

Ikkinchi holat: Bobur vazifani o'chirdi. Qator yo'qoldi — u bilan birga fokusdagi tugma ham. Brauzer fokusni <body> ga tashladi. Keyingi Tab — sahifaning eng boshidan, 30 ta to'xtashdan keyin yana ro'yxatga.

Hayotiy o'xshatish: mehmonxona yo'lagidagi gid. U sizni xonaga olib kiradi, eshikni ko'rsatadi, chiqqaningizda qayerda to'xtaganingizni eslaydi. Fokus — klaviatura foydalanuvchisining "gid"i. Ko'zi ko'radigan odam sahifaning istalgan joyiga qaraydi. Bobur esa faqat fokus turgan joyni "ko'radi". Malika uchun esa fokus — qaysi elementni eshitayotgani.

2. Modal oyna: fokusni ichkariga olib kirish va qaytarish

2.1 Uch qoida

Modal oyna uchun WAI-ARIA APG (Authoring Practices Guide — tayyor naqshlar to'plami, ARIA naqshlari) uch qoida beradi:

  1. Ochilganda — fokus oyna ichiga: birinchi fokuslanadigan elementga yoki eng xavfsiz tugmaga.
  2. Ochiq paytda — Tab va Shift+Tab oyna ichida aylanadi, orqadagi sahifaga chiqmaydi. Esc — yopadi.
  3. Yopilganda — fokus oynani ochgan elementga qaytadi.

Jarayonni ketma-ketlik diagrammasida ko'ring:

sequenceDiagram
  participant B as Bobur
  participant T as «O'chirish» tugmasi
  participant M as Modal
  B->>T: Enter
  T->>M: ochiladi
  M->>M: fokus «Bekor qilish» ga
  B->>M: Tab, Tab: ichida aylanadi
  B->>M: Esc
  M->>T: yopiladi, fokus qaytadi

Pastki o'q — eng ko'p unutiladigan qadam. Fokus qaytmasa, Bobur sahifaning boshidan qayta boshlaydi.

2.2 React'da: ref + effekt

Fokusni qo'yish — DOM bilan ish, demak ref va effekt. Ochgan tugmani esa ochish paytida eslab qolamiz:

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

function ConfirmModal({ onClose }) {
  const cancelRef = useRef(null);
  useEffect(() => {
    cancelRef.current.focus(); // 1-qoida: ichkariga
    console.log("fokus:", document.activeElement.textContent);
  }, []);
  return (
    <div role="dialog" aria-modal="true" aria-labelledby="sarlavha">
      <h2 id="sarlavha">Osh o'chirilsinmi?</h2>
      <button ref={cancelRef} onClick={onClose}>
        Bekor qilish
      </button>{" "}
      <button onClick={onClose}>Ha, o'chirish</button>
    </div>
  );
}

export default function App() {
  const [open, setOpen] = useState(false);
  const openerRef = useRef(null);
  function close() {
    setOpen(false);
    openerRef.current?.focus(); // 3-qoida: qaytarish
    console.log("fokus:", document.activeElement.textContent);
  }
  return (
    <div>
      <button
        onClick={(e) => {
          openerRef.current = e.currentTarget; // kim ochdi
          setOpen(true);
        }}
      >
        O'chirish
      </button>
      {open && <ConfirmModal onClose={close} />}
    </div>
  );
}

Konsolda:

text
fokus: Bekor qilish
fokus: O'chirish

Ekranda:

text
O'chirish

Uch narsaga qarang:

  • Fokus «Bekor qilish» ga, «Ha, o'chirish» ga emas. O'chirish — qaytarib bo'lmaydigan amal. Tasodifiy Enter xavfsiz tugmani bossin.
  • openerRef.current = e.currentTarget — ochgan elementni ref'da saqlaymiz. State emas, chunki bu qiymat ekranga ta'sir qilmaydi (useRef I).
  • role="dialog" va aria-modal="true" — Malikaga "bu oyna, orqasi faol emas" deydi. aria-labelledby — oyna nomi sarlavhadan.

Bu demo ataylab soddalashtirilgan: unda 2-qoida (Tab aylanishi) va Esc yo'q. Ularni qo'lda yozish mumkin, lekin keyingi bo'limda ko'rasiz — brauzer buni tayyor beradi.

2.3 Focus trap qo'lda: aylanish hisobi

Focus trap (fokus tuzog'i) — Tab oxirgi elementdan keyin birinchisiga, Shift+Tab birinchisidan oxirgisiga o'tadi. Mantiqning yuragi — kichik sof funksiya, uni alohida sinash oson:

js
function nextFocus(index, count, shift) {
  if (shift) return index === 0 ? count - 1 : index - 1;
  return index === count - 1 ? 0 : index + 1;
}

// Modalda 3 ta element: [maydon, Bekor qilish, Saqlash]
console.log(nextFocus(2, 3, false)); // 0
console.log(nextFocus(0, 3, true)); // 2
console.log(nextFocus(1, 3, false)); // 2

Komponentda u onKeyDown ichida ishlatiladi: event.key === "Tab" bo'lsa — event.preventDefault(), fokuslanadigan elementlar ro'yxatini (querySelectorAll) olish va list[nextFocus(...)].focus(). Haqiqiy loyihada qiyin joylar ko'p: disabled elementlar, yashirin elementlar, iframe, dinamik qo'shilgan tugmalar. Shuning uchun qo'lda trap — oxirgi chora. Birinchi tanlov — native <dialog>.

2.4 Native <dialog> va inert

Native modal oyna darsidagi <dialog> ning showModal() usuli uchala qoidaning ikkitasini o'zi bajaradi:

  • orqadagi butun sahifani inert qiladi — fokus, bosish va ekran o'quvchi u yerga kira olmaydi;
  • Esc bosilsa — oyna yopiladi;
  • ochilganda fokusni birinchi fokuslanadigan elementga qo'yadi.

Faqat 3-qoida (ochgan tugmaga qaytish) — zamonaviy brauzerlarda bu ham avtomatik, lekin o'zingiz tekshiring. Ko'rish uchun quyidagi blokni saytda ishga tushiring (bu tekshiruvchimizning soddalashtirilgan brauzerida showModal yo'q, shuning uchun u faqat saytda ishlaydi):

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

export default function App() {
  const dialogRef = useRef(null);
  const [answer, setAnswer] = useState("");
  return (
    <div>
      <button onClick={() => dialogRef.current.showModal()}>
        O'chirish
      </button>
      <button>Orqadagi tugma</button>
      <p>{answer}</p>
      <dialog
        ref={dialogRef}
        aria-labelledby="dlg-sarlavha"
        onClose={(e) => {
          setAnswer("javob: " + e.currentTarget.returnValue);
        }}
      >
        <form method="dialog">
          <h2 id="dlg-sarlavha">Osh o'chirilsinmi?</h2>
          <button value="bekor">Bekor qilish</button>{" "}
          <button value="ochir">Ha, o'chirish</button>
        </form>
      </dialog>
    </div>
  );
}

Natija oynasida «O'chirish» ni bosing va Tab bosib ko'ring: fokus faqat ikki tugma va brauzer manzil qatori orasida yuradi — «Orqadagi tugma» ga yetmaydi. Esc — yopadi, javob: (bo'sh returnValue). method="dialog" li forma tugmasi oynani yopadi va returnValue — tugmaning value si. vazifalar dagi OchirishDialog aynan shunday (13-qismdan beri).

inert atributini o'zingiz ham qo'llay olasiz — masalan, o'z yon paneli (drawer) ochilganda asosiy qismni o'chirish uchun. React 19 uni boolean prop sifatida biladi:

jsx
import { useState } from "react";

export default function App() {
  const [open, setOpen] = useState(false);
  return (
    <div>
      <main inert={open}>
        <button onClick={() => setOpen(true)}>Savat</button>
      </main>
      {open && (
        <aside aria-label="Savat">
          <p>Osh × 2</p>
          <button
            onClick={() => {
              const main = document.querySelector("main");
              console.log("inert:", main.hasAttribute("inert"));
              setOpen(false);
            }}
          >
            Yopish
          </button>
        </aside>
      )}
    </div>
  );
}

Konsolda:

text
inert: true

Ekranda:

text
Savat

«Savat» ochilganda <main> da inert atributi bor edi, «Yopish» dan keyin u yo'qoldi. inert={open} — true bo'lsa, DOM'da inert="" atributi, false bo'lsa — umuman yo'q. React 18 da inert noma'lum atribut edi va inert="" satr bilan yozilardi. React 19 da boolean. <main> ichidagi hamma narsa endi Tab, bosish va ekran o'quvchidan yashirin — aynan modal orqasi kerak bo'lgan holat.

Tekshirib ko'ring: Modal yopilganda fokusni qaytarish uchun document.activeElement ni ochish paytida eslab qolsa bo'ladimi, e.currentTarget o'rniga?

Javob

Ko'pincha bo'ladi: klaviatura bilan Enter bosilganda fokus tugmada edi. Lekin sichqoncha bilan bosilganda ba'zi brauzerlar (Safari, macOS'da) tugmaga fokus bermaydi — activeElement <body> bo'lib qoladi. Modal ochilishi kod orqali (taymer, server javobi) bo'lsa — ham. e.currentTarget doim aniq: bosilgan element. Native <dialog> qaytishni o'zi boshqaradi, qo'lda modal uchun esa — aniq ref.

3. Roving tabindex: bitta to'xtash, ichida strelkalar

3.1 Muammo: 20 ta Tab

Filtr tugmalari: «Hammasi», «Bajarilmagan», «Bajarilgan» va yana teglar — 10 ta. Har biri Tab to'xtashi bo'lsa, Bobur ro'yxatga yetish uchun 10 marta Tab bosadi. APG naqshi (toolbar, tablist, menyu, radio guruh): guruh — bitta Tab to'xtash, ichida — strelkalar.

3.2 Texnika

Guruhdagi faqat bitta element tabIndex={0} (Tab unga keladi), qolganlari tabIndex={-1} (Tab o'tkazib yuboradi, lekin .focus() bilan fokuslanadi). Strelka bosilganda — "faol" indeks o'zgaradi va fokus yangi elementga ko'chadi. Avval sof hisob:

js
function nextRoving(key, index, count) {
  switch (key) {
    case "ArrowRight":
      return (index + 1) % count;
    case "ArrowLeft":
      return (index - 1 + count) % count;
    case "Home":
      return 0;
    case "End":
      return count - 1;
    default:
      return index;
  }
}

console.log(nextRoving("ArrowRight", 2, 3)); // 0
console.log(nextRoving("ArrowLeft", 0, 3)); // 2
console.log(nextRoving("End", 0, 3)); // 2
console.log(nextRoving("a", 1, 3)); // 1

Aylanma: oxirgidan o'ngga — birinchiga (qoldiq operatori). Endi komponent:

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

const FILTERS = ["Hammasi", "Bajarilmagan", "Bajarilgan"];

function nextRoving(key, index, count) {
  if (key === "ArrowRight") return (index + 1) % count;
  if (key === "ArrowLeft") return (index - 1 + count) % count;
  if (key === "Home") return 0;
  if (key === "End") return count - 1;
  return index;
}

export default function App() {
  const [active, setActive] = useState(0);
  const refs = useRef([]);
  function handleKeyDown(e) {
    const next = nextRoving(e.key, active, FILTERS.length);
    if (next !== active) {
      e.preventDefault();
      setActive(next);
      refs.current[next].focus();
    }
  }
  return (
    <div role="toolbar" aria-label="Filtr" onKeyDown={handleKeyDown}>
      {FILTERS.map((name, i) => (
        <button
          key={name}
          ref={(el) => {
            refs.current[i] = el;
          }}
          tabIndex={i === active ? 0 : -1}
          aria-pressed={i === active}
          onClick={() => {
            setActive(i);
            const t = refs.current.map((b) => b.tabIndex).join(" ");
            console.log("tabIndex:", t);
          }}
        >
          {name}
        </button>
      ))}
    </div>
  );
}

Konsolda:

text
tabIndex: 0 -1 -1

Ekranda:

text
HammasiBajarilmaganBajarilgan

Konsolda bosish paytidagi holat: hali «Hammasi» faol. Vaqt chizig'i:

Qadam active DOM'dagi tabIndex
«Bajarilgan» bosildi, onClick ichida (setActive(2) chaqirildi) hali 0 0 -1 -1 — konsolga shu chiqdi
ishlovchi tugadi → render → commit 2 -1 -1 0

setActive qiymatni darhol emas, keyingi render'da o'zgartiradi. Commit'dan keyin Tab endi «Bajarilgan» ga keladi. Natija oynasida o'zingiz sinang: Tab bilan guruhga kiring, ← → bilan yuring, Tab bilan chiqing. onKeyDown guruhda bitta — hodisa tugmadan <div> ga ko'tariladi (Hodisa tarqalishi).

3.3 vazifalar dagi variant: Menyu

vazifalar dagi Menyu (Headless komponentlar) shu g'oyaning ikkinchi shaklidan foydalanadi: bandlar hammasi tabIndex: -1, strelka bilan fokus to'g'ridan-to'g'ri ko'chadi, Tab esa menyuni yopadi va fokus tabiiy davom etadi. Menyu vaqtinchalik (ochiladi va yopiladi), shuning uchun "eslab qolinadigan" tabIndex={0} kerak emas. Toolbar esa doimiy — u oxirgi tanlovni eslaydi. Uchinchi texnika — aria-activedescendant: fokus konteynerda qoladi, "faol" element id bilan ko'rsatiladi. U combobox va katta ro'yxatlarda qulay.

4. Sahifa darajasida fokus

Har sahifa boshida bir xil narsalar: logo, menyu, qidiruv, filtrlar. Bobur har safar ularni Tab bilan bosib o'tadi. Skip link — sahifadagi birinchi havola, "Asosiy qismga o'tish". U faqat fokusda ko'rinadi (WCAG 2.4.1 "Bypass Blocks"). vazifalar da u ro'yxat sarlavhasiga olib boradi — «Vazifalar qadami» bo'limida kanon kodi. Uning ishlash mexanizmi:

jsx
import { useRef } from "react";

export default function App() {
  const headingRef = useRef(null);
  return (
    <div>
      <a
        href="#royxat"
        className="otish"
        onClick={(e) => {
          e.preventDefault();
          headingRef.current.focus();
          console.log("fokus:", document.activeElement.textContent);
          console.log("URL hash:", JSON.stringify(location.hash));
        }}
      >
        Ro'yxatga o'tish
      </a>
      <nav>Logo · Menyu · Qidiruv · Filtrlar</nav>
      <h2 id="royxat" ref={headingRef} tabIndex={-1}>
        Ro'yxat
      </h2>
    </div>
  );
}

Konsolda:

text
fokus: Ro'yxat
URL hash: ""

Ekranda:

text
Ro'yxatga o'tish
Logo · Menyu · Qidiruv · Filtrlar
Ro'yxat

Ikki nozik joy:

  • tabIndex={-1} sarlavhada. Sarlavha odatda fokuslanmaydi. -1 — "Tab bilan emas, lekin .focus() bilan mumkin". Endi keyingi Tab — ro'yxatdagi birinchi element.
  • preventDefault. Oddiy #royxat havolasi URL'ga #royxat yozardi — brauzer tarixiga yangi qadam. SPA'da URL boshqa ma'noga ega bo'lishi mumkin (useSyncExternalStore: URL), shuning uchun fokusni qo'lda qo'yamiz va URL'ga tegmaymiz.

4.2 SPA'da "sahifa" almashganda

Oddiy saytda havola bosilsa — yangi sahifa yuklanadi, fokus boshiga qaytadi, ekran o'quvchi yangi sarlavhani o'qiydi. SPA'da (React Router) faqat kontent almashadi — fokus eski joyida (ko'pincha o'chirilgan havolada → <body>), Malika esa hech narsa eshitmaydi. Keng tarqalgan yechim — yangi "sahifa" chizilgach, fokusni uning <h1> iga qo'yish:

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

function Page({ title }) {
  const ref = useRef(null);
  useEffect(() => {
    ref.current.focus();
    console.log("sahifa:", document.activeElement.textContent);
  }, [title]);
  return (
    <main>
      <h1 ref={ref} tabIndex={-1}>
        {title}
      </h1>
    </main>
  );
}

export default function App() {
  const [page, setPage] = useState("Bosh sahifa");
  return (
    <div>
      <nav>
        <button onClick={() => setPage("Menyu")}>Menyu</button>{" "}
        <button onClick={() => setPage("Buyurtmalar")}>
          Buyurtmalar
        </button>
      </nav>
      <Page title={page} />
    </div>
  );
}

Konsolda:

text
sahifa: Bosh sahifa
sahifa: Menyu
sahifa: Buyurtmalar

Ekranda:

text
Menyu Buyurtmalar
Buyurtmalar

Diqqat: birinchi qator — ochilishdagi fokus. Haqiqiy ilovada sahifa birinchi marta yuklanganda fokusni o'g'irlamang: brauzer o'zi boshidan boshlaydi, Malika esa sahifani boshidan o'qiyotgan bo'lishi mumkin. Odatda "birinchi render emas" sharti qo'shiladi. vazifalar ning auth qadamida aynan shunday qaror bor: fokus faqat shu tabda hozir kirilganda ko'chadi, sessiya bilan ochilganda — yo'q (React'da auth holati). React Router va Next.js'da bu xulq qisman o'rnatilgan — ularni o'rganganda qaytamiz.

5. aria-live: fokus siljimaydigan xabarlar

Ba'zi o'zgarishlar fokussiz bo'lishi kerak: "Vazifa qo'shildi", "Internet yo'q", "3 ta natija topildi". Fokusni xabarga ko'chirish — Boburni ishidan chalg'itadi. Malika esa xabarni baribir eshitishi kerak. Buning uchun — jonli hudud (live region): role="status" (yoki aria-live="polite") li element. Ichidagi matn o'zgarsa, ekran o'quvchi uni joriy gapdan keyin o'qiydi.

jsx
import { useState } from "react";

export default function App() {
  const [count, setCount] = useState(0);
  const [message, setMessage] = useState("");
  function add() {
    const next = count + 1;
    setCount(next);
    setMessage(`Osh savatga qo'shildi. Jami: ${next}`);
  }
  return (
    <div>
      <button onClick={add}>Savatga</button>
      <p role="status">{message}</p>
    </div>
  );
}

Ekranda:

text
Savatga
Osh savatga qo'shildi. Jami: 2

Uch qoida:

Qoida Nega
hudud sahifada oldindan bor, faqat matni o'zgaradi yangi qo'shilgan hudud ko'pincha o'qilmaydi
odatda — role="status" (polite) joriy gapni bo'lmaydi
role="alert" (assertive) — faqat shoshilinch hamma narsani to'xtatib o'qiydi

Bitta tuzoq: bir xil matn qayta yozilsa (ikki marta "qo'shildi"), ba'zi ekran o'quvchilar o'zgarishni sezmaydi. Shuning uchun yuqorida matnda son bor. vazifalar da beshta jonli hudud bor (oldingi dars) — e'lon, aloqa, qidiruv, statistika holati, forma xatosi. E'lon esa portal orqali <body> da turadi (Portallar).

6. Vazifalar qadami: «Ro'yxatga o'tish» va Bobur ssenariysi

feat/a11y-otish ga bitta kod o'zgarishi. App.tsx da sarlavhadan keyin, kanondan (izoh qatorlari telefon uchun qayta bo'lingan):

tsx
      {/* Skip link (18/#40, WCAG 2.4.1): klaviatura bilan
          ishlovchi sahifa boshidan ro'yxatga bir bosishda o'tadi
          (formadagi 8 ta to'xtash o'rniga). Fokusdagina ko'rinadi.
          Fokus — sarlavhaga (tabIndex -1): keyingi Tab — birinchi
          vazifa. preventDefault: #royxat URL'ga yozilmasin (tarix,
          popstate — 17/#38) */}
      <a
        href="#royxat"
        className="otish"
        onClick={(event) => {
          event.preventDefault();
          sarlavhaRef.current?.focus();
        }}
      >
        Ro'yxatga o'tish
      </a>

sarlavhaRef yangi emas — <h2 ref={sarlavhaRef} tabIndex={-1}>Ro'yxat</h2> 17-qismdan beri bor: vazifa o'chirilib, qo'shni qator qolmasa, fokus shu sarlavhaga tushadi. Ya'ni "fokusni yo'qotmaslik" qoidasi kanonda allaqachon ishlardi. CSS — vazifalar.css oxirida (izoh ikki qatorga bo'lingan):

css
/* 18/#40: skip link — fokusda paydo bo'ladi
   (oqimdan tashqarida: CLS yo'q) */
.otish {
  position: absolute;
  top: var(--joy-2);
  left: var(--joy-2);
  z-index: 3;
  padding: var(--joy-2) var(--joy-3);
  border-radius: var(--burchak);
  background: var(--rang-asosiy);
  color: var(--rang-asosiy-ustida);
  transform: translateY(-200%);
}
.otish:focus { transform: none; }

position: absolute — havola sahifa oqimidan tashqarida, paydo bo'lganda hech narsani surmaydi (CLS 0, Portallar darsidagi o'lchov kabi). transform: translateY(-200%) — ekrandan tepaga yashirin, fokusda — joyiga. display: none bo'lmaydi: unda havolaga fokus umuman tushmasdi.

6.2 Bobur — haqiqiy ssenariy

Kanon e2e'da Bobur — sichqonchasiz, faqat keyboard.press bilan ishlaydigan ssenariy. Haqiqiy natija (natija-18-40.txt, build qilingan ilova, CSP bilan):

text
✅ Bobur: birinchi Tab — skip link, ko'rinadi: ["a|Ro'yxatga o'tish","none"]
✅ Bobur: Enter — fokus ro'yxat sarlavhasida, URL o'zgarmadi: ["h2|Ro'yxat",""]
✅ Bobur: Tab — birinchi vazifa katagi: "vazifa-1"
✅ Bobur: Space — belgilandi, qator pastga ko'chdi, fokus o'sha katakda: [["vazifa-1",true],["2","1","3"]]
✅ Bobur: Tab, Enter — tahrir maydoni fokusda, matn belgilangan: ["tahrir-maydon",0,9]
✅ Bobur: Enter — saqlandi, fokus «Tahrirlash» tugmasida: ["button|Tahrirlash: Non va sut olish","Non va sut olish"]
✅ Bobur: «O'chirish» — dialog, fokus «Bekor qilish»da: "button|Bekor qilish"
✅ Bobur: Esc — dialog yopildi, fokus «O'chirish»ga qaytdi: "button|O'chirish: Non va sut olish"

Har qator — darsning bir bo'limi:

  • 1–2-qatorlar — skip link: birinchi Tab'da ko'rinadi (transform: none), Enter — sarlavhaga, URL bo'sh.
  • 4-qator — eng qiziq. Katak belgilanganda vazifa ro'yxatda pastga ko'chdi (["2","1","3"] — 1-vazifa birinchi o'rindan ikkinchiga tushdi). React <li> ni DOM'da ko'chirdi va fokus tushib qolishi kerak edi. Lekin fokus o'sha katakda — fokusniRejala + useLayoutEffect uni qaytardi (useOptimistic qadamida qo'shilgan).
  • 5–6-qatorlar — tahrir: fokus maydonga, matn belgilangan (0–9 belgilar). Saqlangach — fokus «Tahrirlash» tugmasiga qaytdi, maydon o'chganiga qaramay.
  • 7–8-qatorlar — native <dialog>: «Bekor qilish» da ochildi, Esc bilan yopildi, fokus ochgan tugmaga qaytdi — «Modal oyna» bo'limidagi uch qoidaning hammasi.

6.3 Commit

bash
git add -A
git commit

Commit (kanon): feat: «Ro'yxatga o'tish» havolasi; a11y — axe 0, klaviatura ssenariysi. Tekshiruvlar (haqiqiy):

  • axe-core — 6 holatda 0 buzilish; har holatda tekshirilgan qoidalar soni 3 taga oshdi (asosiy ko'rinishda 36 → 39, 40-axe-keyin.txt): yangi havola bilan bog'liq qoidalar endi qo'llanadi.
  • npm test — node 170, Vitest 45; lint, format, tip, knip — toza.
  • Brauzer tekshiruvi — 159/159, e2e-18 — 63/63 (Bobur va Malika qo'shildi).
  • Diff: 2 fayl, +28.

7. Ko'p uchraydigan xatolar

Belgi Sabab Davosi
element o'chdi — Tab sahifa boshidan fokus <body> ga tushdi qo'shni element yoki sarlavhaga .focus()
modal ochiq, Tab orqada yuradi trap yo'q <dialog> + showModal() yoki inert
modal yopildi, fokus yo'qoldi ochgan element eslanmagan ref'da saqlab, .focus()
sarlavhaga .focus() ishlamaydi tabIndex yo'q tabIndex={-1}
skip link hech ko'rinmaydi display: none transform bilan yashirish, :focus da ko'rsatish
10 ta filtr — 10 ta Tab har tugma Tab to'xtashi roving tabindex
xabar eshitilmadi jonli hudud xabar bilan birga qo'shildi hudud oldindan bor, faqat matni o'zgaradi
sahifa ochilishi bilan fokus sakraydi birinchi render'da .focus() faqat foydalanuvchi harakatidan keyin

8. Mashqlar

Mashqlar kurs/mashqlar/18/40-fokus/ da.

1-mashq (oson): qaysi texnika?

Har holat uchun: skip link, roving tabindex, aria-live yoki fokusni qaytarish.

  • Hafta kunlari tanlovi (7 tugma) — Tab bilan bitta to'xtash bo'lsin.
  • "Buyurtma yuborildi" — fokus siljimasin, lekin Malika eshitsin.
  • Rasm galereyasi yopildi — fokus ochgan rasmga.
  • Sahifa boshidagi 15 ta havoladan sakrab o'tish.
Yechim
  • roving tabindex — guruh bitta Tab to'xtash, ichida ← →.
  • aria-live — role="status" hudud, oldindan sahifada.
  • fokusni qaytarish — ochgan elementni ref'da saqlab, yopilganda .focus().
  • skip link — birinchi Tab'da ko'rinadigan havola, asosiy qismga.

2-mashq (o'rta): o'chirilgan qatordan keyin fokus

Taomlar ro'yxati, har qatorda «O'chirish». O'chirilgandan keyin fokus keyingi qatorning «O'chirish» tugmasiga tushsin; keyingi bo'lmasa — oldingisiga; ro'yxat bo'shasa — <h2> sarlavhaga (tabIndex={-1}). Konsolga fokus qayerda ekanini chiqaring. Ishora: tugmalar — Map ref (useRef II); fokus — DOM yangilangandan keyin (useEffect yoki useLayoutEffect useLayoutEffect).

Yechim
jsx
import { useLayoutEffect, useRef, useState } from "react";

export default function App() {
  const [dishes, setDishes] = useState(["Osh", "Manti", "Lag'mon"]);
  const buttons = useRef(new Map());
  const headingRef = useRef(null);
  const [focusPlan, setFocusPlan] = useState(null);

  useLayoutEffect(() => {
    if (focusPlan === null) return;
    const target =
      buttons.current.get(focusPlan) ?? headingRef.current;
    target.focus();
    console.log("fokus:", document.activeElement.textContent);
  }, [focusPlan, dishes]);

  function remove(dish) {
    const i = dishes.indexOf(dish);
    const next = dishes[i + 1] ?? dishes[i - 1] ?? "__sarlavha";
    setDishes(dishes.filter((d) => d !== dish));
    setFocusPlan(next);
  }

  return (
    <section>
      <h2 ref={headingRef} tabIndex={-1}>
        Menyu
      </h2>
      <ul>
        {dishes.map((d) => (
          <li key={d}>
            {d}{" "}
            <button
              ref={(el) => {
                if (el) buttons.current.set(d, el);
                else buttons.current.delete(d);
              }}
              onClick={() => remove(d)}
            >
              O'chirish: {d}
            </button>
          </li>
        ))}
      </ul>
    </section>
  );
}

Konsolda:

text
fokus: O'chirish: Lag'mon
fokus: O'chirish: Osh
fokus: Menyu

Ekranda:

text
Menyu

Fokus rejasi — state: o'chirish paytida element hali DOM'da, .focus() ni render'dan keyin qilish kerak. useLayoutEffect — bo'yashdan oldin, foydalanuvchi "fokussiz" holatni ko'rmaydi. Ref callback null bilan chaqirilganda (element o'chdi) — Map dan o'chiramiz. Tugma matnida taom nomi — Malika qaysi qatorni o'chirayotganini eshitadi. vazifalar dagi fokusla(id, amal, zaxiraId) — xuddi shu g'oya.

3-mashq (qiyin): radio guruh — roving tabindex

Yetkazish usuli: «Olib ketish», «Yetkazish», «Zalda» — role="radiogroup" va har biri role="radio" li <button>. Talablar:

  1. Faqat tanlangan element tabIndex={0}, qolganlari -1.
  2. aria-checked — tanlanganda true.
  3. ← → — tanlov ham, fokus ham ko'chadi (radio guruhda strelka tanlaydi).
  4. Bosish ham tanlaydi; konsolga tanlandi: va nom.

Ishora: «Roving tabindex» bo'limidagi nextRoving; role="radiogroup" ga aria-label.

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

const OPTIONS = ["Olib ketish", "Yetkazish", "Zalda"];

function nextRoving(key, index, count) {
  if (key === "ArrowRight" || key === "ArrowDown") {
    return (index + 1) % count;
  }
  if (key === "ArrowLeft" || key === "ArrowUp") {
    return (index - 1 + count) % count;
  }
  return index;
}

export default function App() {
  const [selected, setSelected] = useState(0);
  const refs = useRef([]);
  function select(i) {
    setSelected(i);
    refs.current[i].focus();
    console.log("tanlandi:", OPTIONS[i]);
  }
  return (
    <div
      role="radiogroup"
      aria-label="Yetkazish usuli"
      onKeyDown={(e) => {
        const next = nextRoving(e.key, selected, OPTIONS.length);
        if (next !== selected) {
          e.preventDefault();
          select(next);
        }
      }}
    >
      {OPTIONS.map((name, i) => (
        <button
          key={name}
          ref={(el) => {
            refs.current[i] = el;
          }}
          role="radio"
          aria-checked={i === selected}
          tabIndex={i === selected ? 0 : -1}
          onClick={() => select(i)}
        >
          {name}
        </button>
      ))}
    </div>
  );
}

Konsolda:

text
tanlandi: Zalda

Ekranda:

text
Olib ketishYetkazishZalda

Toolbar'dan farqi: radio guruhda strelka tanlovni ham o'zgartiradi (APG naqshi shunday). Tab bilan guruhdan chiqib, qayta kirsangiz — fokus tanlangan elementga keladi, birinchisiga emas. Amalda native <input type="radio"> guruhi bularning hammasini bepul beradi — o'z radio'ngiz faqat ko'rinish talab qilganda kerak.

4-mashq: Vazifalar qadami

Skip link'ni qo'shing (kanon parchasi va CSS). npm run build va npm run preview, keyin Boburning 8 qadamini o'zingiz qo'lda takrorlang. Har qadamda fokus qayerda ekanini DevTools konsolida tekshiring: document.activeElement. Commit qiling.

Yechim
  • Birinchi Tab — «Ro'yxatga o'tish» ko'rinadi. Enter — fokus «Ro'yxat» sarlavhasida, manzil qatorida #royxat yo'q.
  • Katak Space bilan belgilanganda qator pastga ketadi, document.activeElement — o'sha katak.
  • O'chirish dialogi: Tab faqat ikki tugma orasida, Esc — fokus «O'chirish» ga.
  • npm run check — toza; axe — 0. Commit — feat: «Ro'yxatga o'tish» havolasi; a11y — axe 0, klaviatura ssenariysi. PR'ni birlashtiring.

9. Real ishda

  • Dizayn tizimlari — Radix, React Aria, Headless UI fokusni ichkarida boshqaradi: Dialog, Menu, Tabs. Ularni tanlashning asosiy sababi — aynan shu (Mantine, Chakra va React Aria). O'zingiz yozsangiz — APG naqshi bo'yicha.
  • Router'lar — sahifa almashganda fokus va e'lon: Next.js "route announcer"ga ega, React Router'da — o'zingiz.
  • e2e testlar — Playwright'da page.keyboard.press("Tab") va expect(page.locator(":focus")) — vazifalar dagi Bobur ssenariysi shunday (Playwright I).
  • Intervyu savollari: "modal oyna uchun a11y talablari?", "roving tabindex nima?", "tabIndex={-1} va 0 farqi?", "inert nima qiladi?".

Xulosa

  • React fokusni boshqarmaydi: element o'chsa — fokus <body> ga. Qo'shni element yoki sarlavhaga .focus() — DOM yangilangandan keyin.
  • Modal: ochilganda — ichkariga (xavfsiz tugma), ochiq paytda — trap, yopilganda — ochgan elementga. Native <dialog> + showModal() ikkitasini o'zi qiladi; inert — orqa fonni o'chiradi.
  • Roving tabindex: guruh — bitta Tab to'xtash (tabIndex 0/-1), ichida strelkalar.
  • Skip link — birinchi Tab, sarlavha tabIndex={-1}; SPA'da sahifa almashganda — <h1> ga fokus (birinchi render'da emas).
  • Fokussiz xabar — oldindan turgan role="status" hudud.
  • vazifalar: «Ro'yxatga o'tish», Bobur ssenariysi 8/8, e2e-18 63/63, commit.

Keyingi dars: Frontend xavfsizligi React'da — React XSS'dan qayerda himoya qiladi va qayerda yo'q.

Manbalar

  • W3C: WAI-ARIA APG — Dialog (Modal), Toolbar, Radio Group, Menu Button naqshlari — w3.org/WAI/ARIA/apg/patterns
  • WCAG 2.2: 2.4.1 Bypass Blocks, 2.4.3 Focus Order — w3.org/TR/WCAG22
  • MDN: HTMLDialogElement.showModal(), inert — developer.mozilla.org
  • React 19: "inert — boolean atribut" — react.dev/reference/react-dom/components/common (2026-10 holatiga)
Ulashish:Telegram'da

Izohlar (0)

Izoh yozish uchun kiring.

  • Hozircha izoh yo'q. Birinchi bo'ling!
Fokus boshqaruvi va klaviatura navigatsiyasi: modal, roving tabindex, skip link va inert — IlmHamroh