IlmHamroh
JavaScript Full-stack/19-qism. React: routing, server state, formalar35/36-dars24 daqiqa
Mundarija (33)

React'da maxsus inputlar: telefon maskasi, so'm formati, OTP, sana va combobox

Qisqacha: Maxsus input — ko'rinishi va qiymati farq qiladigan maydon: ekranda +998 (90) 123-45-67, formada 998901234567; ekranda 35 000, formada 35000. Asosiy qoida: formada xom qiymat, ekranda formatlangan, ulanish — Controller orqali. Telefon va raqam uchun inputMode va autoComplete, OTP uchun bitta maydon va autocomplete="one-time-code", sana uchun avval brauzerning <input type="date"> i. Combobox (takliflar ro'yxatli maydon) — ARIA 1.2 naqshi: fokus maydonda qoladi, faol taklif — aria-activedescendant.

Bu darsda

  • +998 telefon maskasi va so'm formatidagi raqam maydonini qo'lda yozasiz, xom qiymatni formaga berasiz.
  • OTP (bir martalik kod) maydonini telefon klaviaturasi va SMS avto-to'ldirishga mos qilasiz.
  • Brauzerning sana maydonini ishlatasiz va vaqt mintaqasi tuzog'ini bilasiz.
  • ARIA combobox naqshini tushunasiz va vazifalar dagi teg combobox'ini o'qiysiz.
  • Qaysi vaziyatda tayyor kutubxona (imask, input-otp, react-number-format, react-day-picker, Downshift) kerakligini bilasiz.

Oldin bilishingiz kerak: RHF II: Controller, RHF III, Intl.NumberFormat, Fokus boshqaruvi va klaviatura navigatsiyasi.

1. Nega bu kerak?

«Bahor» bron formasida Jasur aka to'rtta shikoyat yig'di:

  1. Mehmonlar telefonni har xil yozadi: 901234567, +998 90 123 45 67, 90-123-45-67. Operator qo'ng'iroq qila olmaydi.
  2. Narx maydonida 350000 ni o'qish qiyin — 35 000 mi, 350 000 mi?
  3. SMS kod maydoniga telefon harflar klaviaturasini ochadi, kod SMS'dan o'zi tushmaydi.
  4. Teg maydonida mehmon #bozor o'rniga #bazor yozadi — takliflar ro'yxati kerak.

Bularning hammasi — maxsus input: oddiy <input> ustiga xulq qo'shilgan maydon. Hayotiy o'xshatish: bank kartasi formasidagi raqam joyi. Siz 16 ta raqam yozasiz, u esa ularni to'rttalab ajratib ko'rsatadi — 8600 1234 5678 9012. Bank esa bo'shliqsiz 16 raqamni oladi. Ko'rinish — odam uchun, qiymat — mashina uchun.

Asosiy qoida butun dars davomida bitta:

flowchart LR
  K["Mehmon yozdi<br/>90 123 45 67"] --> X["Xom qiymat<br/>(formada)<br/>998901234567"]
  X --> F["Formatlangan<br/>(ekranda)<br/>+998 (90) 123-45-67"]
  X --> S["Server, sxema,<br/>saqlash"]

Format — faqat ekranda. Sxema, server va localStorage xom qiymatni ko'radi.

2. Telefon: +998 maskasi

2.1 Qo'lda yozilgan maska

Maska (mask) — maydon qiymatini qolipga solish: +998 (__) ___-__-__. Mehmon faqat raqam yozadi, qavs va chiziqchalar o'zi qo'yiladi. Ikki sof funksiya kerak: "ekrandagi matndan raqamlarni ol" va "raqamlardan ko'rinish yasa":

jsx
import { Controller, useForm } from "react-hook-form";

// "+998 (90) 123-45-67" → "901234567" (998 siz, ko'pi bilan 9 ta)
function toDigits(text) {
  const digits = text.replace(/\D/g, "");
  const local = digits.startsWith("998") ? digits.slice(3) : digits;
  return local.slice(0, 9);
}

// "901234567" → "+998 (90) 123-45-67"
function formatPhone(d) {
  if (d === "") return "";
  let out = "+998 (" + d.slice(0, 2);
  if (d.length > 2) out += ") " + d.slice(2, 5);
  if (d.length > 5) out += "-" + d.slice(5, 7);
  if (d.length > 7) out += "-" + d.slice(7, 9);
  return out;
}

export default function App() {
  const { control, handleSubmit } = useForm({
    defaultValues: { phone: "" },
  });
  return (
    <form onSubmit={handleSubmit((d) => console.log(d))}>
      <label htmlFor="phone">Telefon</label>
      <Controller
        control={control}
        name="phone"
        render={({ field }) => (
          <input
            id="phone"
            ref={field.ref}
            type="tel"
            inputMode="tel"
            autoComplete="tel-national"
            placeholder="+998 (90) 000-00-00"
            value={formatPhone(field.value)}
            onChange={(e) => field.onChange(toDigits(e.target.value))}
            onBlur={field.onBlur}
          />
        )}
      />
      <button>Bron qilish</button>
    </form>
  );
}

Konsolda:

text
{phone: '901234567'}

Mehmon 90 123 45 67 yozdi — maydonda +998 (90) 123-45-67 (input qiymati "Ekranda" ga kirmaydi, shuning uchun bu yerda ko'rsatilmagan; «Ishga tushir» da ko'rasiz), formada esa 901234567. Uch muhim tafsilot:

  • toDigits 998 bilan boshlangan matnni ham to'g'ri o'qiydi — mehmon +998 90 … ni to'liq yozsa yoki nusxalab qo'ysa (paste).
  • type="tel" va inputMode="tel" — telefonda raqamli klaviatura ochiladi. type="number" emas: telefon raqami "son" emas (boshida 0 bo'lishi, + belgisi), va number maydoni strelkalar bilan qiymatni o'zgartiradi.
  • autoComplete="tel-national" — brauzer saqlangan raqamni taklif qiladi (mamlakat kodisiz qism).

Sxemada xom qiymat tekshiriladi: z.string().regex(/^\d{9}$/, "To'liq raqam yozing") (Zod chuqurroq). Serverga — "998" + phone.

2.2 Kursor muammosi va kutubxonalar

Qo'lda yozilgan maskaning bitta zaif joyi bor — kursor. Mehmon raqamning o'rtasini tuzatsa (123 dagi 2 ni o'chirsa), React qiymatni qayta formatlaydi va kursor oxiriga sakraydi. Buni to'g'rilash — selectionStart ni hisoblab qayta qo'yish, chiziqchalarni hisobga olish. Mayda, lekin chalkash ish.

Shuning uchun real loyihada maska — kutubxona:

Kutubxona Nima uchun
react-imask (IMask) har qanday maska: telefon, karta, sana; kursorni to'g'ri boshqaradi
maska kichik, faqat maska (Vue va vanilla ham)
react-number-format raqam va valyuta + PatternFormat (telefon)

react-imask bilan ko'rinishi (bu kutubxona natija oynasida yo'q — o'z loyihangizda):

jsx
import { IMaskInput } from "react-imask";

<Controller
  control={control}
  name="phone"
  render={({ field }) => (
    <IMaskInput
      mask="+{998} (00) 000-00-00"
      unmask={true}
      inputRef={field.ref}
      value={field.value}
      onAccept={(value) => field.onChange(value)}
      onBlur={field.onBlur}
    />
  )}
/>

unmask={true} — onAccept xom raqamlarni beradi (998901234567), 0 — "bu yerga raqam", {998} — o'zgarmas qism. Yana Controller (RHF II): kutubxona komponentida value/onChange o'z nomlari bilan.

Tekshirib ko'ring: Nega field.value ga formatlangan matnni (+998 (90) 123-45-67) saqlamadik?

Javob

Uch sabab. Sxema va server bitta ko'rinishni tekshiradi — \d{9}, qavs va chiziqchasiz. Ko'rinish keyin o'zgarishi mumkin (90 123 45 67 ko'rinishiga o'tish), saqlangan ma'lumot esa o'zgarmasin. Va isDirty: formatlangan matn har xil yozilishi mumkin, xom raqam esa bitta. Ko'rinish — value={formatPhone(...)} da, render paytida hisoblanadi.

3. Raqam: so'm formati

3.1 Intl.NumberFormat — va tuzoq

So'm uchun minglik ajratuvchi kerak: 35 000. Brauzerda buning vositasi — Intl.NumberFormat. Lekin uning natijasi brauzer va Node'da, hatto brauzer versiyalari orasida farq qilishi mumkin: ajratuvchi oddiy bo'shliq, "uzilmas bo'shliq" (U+00A0), "ingichka uzilmas bo'shliq" (U+202F) yoki vergul bo'ladi. Bizning tekshiruvchimiz (Node) va saytdagi Chrome ham bir xil chiqarmasligi mumkin — shuning uchun bu darsda ekranga chiqadigan formatni o'zimiz yozamiz:

jsx
import { Controller, useForm } from "react-hook-form";

const groupDigits = (s) => s.replace(/\B(?=(\d{3})+(?!\d))/g, " ");
const showPrice = (n) => (n === null ? "" : groupDigits(String(n)));

export default function App() {
  const { control, handleSubmit } = useForm({
    defaultValues: { price: null },
  });
  return (
    <form onSubmit={handleSubmit((d) => console.log(d))}>
      <label htmlFor="price">Narxi</label>
      <Controller
        control={control}
        name="price"
        render={({ field }) => (
          <span>
            <input
              id="price"
              ref={field.ref}
              inputMode="numeric"
              autoComplete="off"
              value={showPrice(field.value)}
              onChange={(e) => {
                const digits = e.target.value.replace(/\D/g, "");
                field.onChange(digits === "" ? null : Number(digits));
              }}
              onBlur={field.onBlur}
            />{" "}
            so'm
          </span>
        )}
      />
      <button>Saqlash</button>
    </form>
  );
}

Konsolda:

text
{price: 35000}

groupDigits — muntazam ifoda (showPrice — null uchun bo'sh satr): "o'ng tomonida uch xonali guruhlar qolgan har chegaraga bo'shliq qo'y" (RegExp). Formada son (35000), bo'sh maydon — null (Zod chuqurroq dagi "" → 0 tuzog'iga tushmaslik uchun). inputMode="numeric" — telefonda faqat raqamlar klaviaturasi; type="number" emas, chunki unda bo'shliqli matn ko'rinmaydi.

Saytning boshqa joyida (ro'yxat, chek) — Intl.NumberFormat("uz-UZ") ishlatish to'g'ri: u faqat ko'rsatadi, tekshiruvchi yo'q. Lekin solishtirish yoki testda uning natijasiga tayanmang.

3.2 react-number-format

Kasr qism (12 500,50), manfiy son, valyuta belgisi, kursor — react-number-format ning NumericFormat komponenti:

jsx
<NumericFormat
  thousandSeparator=" "
  suffix=" so'm"
  allowNegative={false}
  decimalScale={0}
  value={field.value}
  onValueChange={(v) => field.onChange(v.floatValue ?? null)}
/>

v.floatValue — xom son, v.formattedValue — ekrandagi matn. Xuddi o'sha qoida: formaga — xom qiymat.

4. OTP: bir martalik kod

4.1 Bitta maydon

OTP (one-time password) — SMS yoki ilovaga keladigan bir martalik kod (masalan, 6 raqam). Ko'p saytlar uni 6 ta alohida katakcha qilib chizadi. Chiroyli, lekin muammoli: nusxalab qo'yish (paste) ishlamaydi, ekran o'quvchi "6 ta tahrirlash maydoni" ni o'qiydi, SMS'dan avto-to'ldirish bitta katakka tushadi.

Ishonchli yo'l — bitta maydon, to'g'ri atributlar bilan:

jsx
import { useForm } from "react-hook-form";

export default function App() {
  const { register, handleSubmit, setValue } = useForm({
    defaultValues: { code: "" },
  });
  const verify = handleSubmit((d) => console.log("kod:", d.code));
  const field = register("code", {
    pattern: /^\d{6}$/,
    onChange: (e) => {
      const digits = e.target.value.replace(/\D/g, "").slice(0, 6);
      setValue("code", digits);
      if (digits.length === 6) void verify();
    },
  });
  return (
    <form onSubmit={verify}>
      <label htmlFor="code">SMS kod</label>
      <input
        id="code"
        inputMode="numeric"
        autoComplete="one-time-code"
        maxLength={6}
        {...field}
      />
      <button>Tasdiqlash</button>
    </form>
  );
}

Konsolda:

text
kod: 123456

Atributlar ishni qiladi:

  • autoComplete="one-time-code" — iPhone'da SMS'dagi kod klaviatura ustida taklif bo'lib chiqadi, Android'da ham shunga o'xshash. Bir bosish — kod maydonda.
  • inputMode="numeric" — raqamli klaviatura.
  • maxLength={6} — ortiqcha belgi yozilmaydi.

register ning onChange opsiyasi — RHF ning o'z ishlovidan keyin chaqiriladi: RHF qiymatni ("12 34 56") allaqachon olgan. Shuning uchun tozalangan raqamlarni setValue bilan qaytaramiz — u forma qiymatini ham, maydonning o'zini ham yangilaydi (RHF II). 6 raqam to'lganda forma o'zi yuboriladi — "Tasdiqlash" ni bosish shart emas. Biz avval e.target.value = digits deb yozib ko'rdik: maydonda raqamlar to'g'ri ko'rindi, lekin RHF eski qiymatni tekshirdi va pattern xatosi bilan yubormadi.

Brauzerda yana bir imkoniyat bor — WebOTP API (navigator.credentials.get({ otp: … })): Android Chrome SMS'ni ruxsat bilan o'zi o'qiydi. SMS maxsus formatda bo'lishi kerak (@sizning-sayt.uz #123456 qatori bilan) — bu backend bilan kelishiladi.

4.2 Katakchalar baribir kerak bo'lsa

Dizayner 6 ta katakchani talab qilsa, input-otp kutubxonasi yaxshi yo'l: u ichida bitta ko'rinmas <input> ishlatadi, katakchalar esa faqat chizma. Paste, avto-to'ldirish va ekran o'quvchi ishlaydi:

jsx
import { OTPInput } from "input-otp";

<OTPInput
  maxLength={6}
  value={field.value}
  onChange={field.onChange}
  onComplete={() => void verify()}
  render={({ slots }) =>
    slots.map((slot, i) => <Slot key={i} {...slot} />)
  }
/>

shadcn/ui ning InputOTP komponenti aynan shu kutubxona ustiga qurilgan (shadcn/ui I).

Diqqat: OTP kodini brauzerda tekshirib bo'lmaydi — kodni faqat server biladi. Brauzer qiladigan ish: format (6 raqam) va yuborish. Urinishlar sonini cheklash ("5 marta noto'g'ri — 10 daqiqa kuting") ham — server ishi (CAPTCHA va bot himoyasi).

5. Sana

5.1 Avval brauzerning o'zi

<input type="date"> — brauzerning o'z kalendari: telefonda tizim tanlagichi, kompyuterda ochiladigan oyna, klaviatura bilan ishlaydi, ekran o'quvchiga tanish. Qiymati — doim "YYYY-MM-DD" satri, ko'rinishi esa brauzer tiliga qarab (14.10.2026 yoki 10/14/2026):

jsx
import { useForm } from "react-hook-form";

const today = "2026-10-14";

export default function App() {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm({ defaultValues: { date: "" } });
  return (
    <form noValidate onSubmit={handleSubmit((d) => console.log(d))}>
      <label htmlFor="date">Bron sanasi</label>
      <input
        id="date"
        type="date"
        min={today}
        {...register("date", {
          required: "Sanani tanlang",
          validate: (v) =>
            v >= today || "O'tgan kunni tanlab bo'lmaydi",
        })}
      />
      <p role="alert">{errors.date?.message}</p>
      <button>Bron qilish</button>
    </form>
  );
}

Konsolda:

text
{date: '2026-10-20'}

"YYYY-MM-DD" satrlarni oddiy >= bilan solishtirish mumkin — tartibi kalendar tartibiga mos (Taqqoslash operatorlari). Kalendardagi min o'tgan kunlarni tanlatmaydi. Lekin bu faqat maslahat: qo'lda yozilgan yoki skript bilan qo'yilgan qiymat uchun validate kerak (server ham baribir tekshiradi). Bu yerda today o'zgarmas, toki misol har kuni bir xil natija bersin. Loyihada uni joriy sanadan hisoblaysiz.

5.2 Vaqt mintaqasi tuzog'i

"2026-10-20" ni Date ga aylantirmoqchi bo'lsangiz, ehtiyot bo'ling:

js
const date = new Date("2026-10-20");
console.log(date.toISOString()); // 2026-10-20T00:00:00.000Z
console.log(new Date("2026-10-20T00:00").getDate()); // 20

new Date("2026-10-20") — UTC yarim tuni. Toshkentda (UTC+5) u 05:00 bo'ladi, AQShda esa 19-oktabr kechqurun. Sana "kun" bo'lsa (bron, tug'ilgan kun), uni satr holida saqlang va serverga satr qilib yuboring. Date ga aylantirish kerak bo'lsa — vaqt qismi bilan, mahalliy vaqtda ("…T00:00"). Sanalar bilan ishlash chuqur — Temporal darsida.

5.3 Qachon kutubxona

Brauzer kalendari yetmaydigan joylar: band kunlarni kulrang qilish, sanalar oralig'i (kelish — ketish), bir nechta oyni yonma-yon ko'rsatish, o'z dizayni. Unda — react-day-picker (shadcn/ui ning Calendar i shu):

jsx
import { DayPicker } from "react-day-picker";

<DayPicker
  mode="range"
  selected={field.value}
  onSelect={field.onChange}
  disabled={[{ before: new Date() }, ...bookedDays]}
/>

Kalendar va bron UI'i alohida darsda — Kalendar va bron qilish UI.

6. Combobox: takliflar ro'yxatli maydon

6.1 Naqsh

Combobox — matn maydoni + ochiladigan takliflar ro'yxati. Mehmon yozadi, mos takliflar chiqadi, birini tanlaydi yoki o'zinikini yozib qoldiradi. Bu <select> emas (o'z qiymatini yozish mumkin) va <datalist> emas (uning ko'rinishi va klaviaturasi brauzerga bog'liq, ekran o'quvchilarda notekis).

Uni to'g'ri qurish uchun WAI-ARIA 1.2 combobox naqshi (APG — ARIA Authoring Practices Guide) bor. Asosiy qoidalar:

Atribut / xulq Nima uchun
maydonda role="combobox" ekran o'quvchi "takliflar bor maydon" deb biladi
aria-expanded ro'yxat ochiqmi
aria-controls qaysi ro'yxatni boshqaradi (id)
aria-autocomplete="list" takliflar yozilganiga qarab filtrlanadi
ro'yxat role="listbox", bandlar role="option" tanlanadigan variantlar
fokus doim maydonda mehmon yozishda davom eta oladi
aria-activedescendant ↓/↑ bilan "faol" bo'lgan variant id'si — ekran o'quvchi uni o'qiydi
↓ ↑ Enter Esc ↓/↑ — variant, Enter — tanlash (forma yuborilmaydi), Esc — yopish

Eng nozik nuqta — fokus. Takliflar ro'yxatiga fokus o'tmaydi (u Tab bilan o'tadigan narsa emas). Fokus maydonda qoladi, "qaysi variant faol" esa aria-activedescendant bilan aytiladi. Bobur ↓ bosganda ham yozishda davom eta oladi.

6.2 Kichik combobox

«Bahor» teglari uchun. Klaviatura qismi ham yozilgan, lekin bizning tekshiruvchi tugma bosa olmaydi — ↓ va Enter ni «Ishga tushir» da o'zingiz sinang. Tekshiruvchi yozadi va sichqoncha bilan tanlaydi:

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

const TAGS = ["#bozor", "#bolalar", "#uy", "#ta'mir"];

function TagCombobox({ value, onChange }) {
  const listId = useId();
  const [open, setOpen] = useState(false);
  const [active, setActive] = useState(-1);
  const query = value.toLowerCase();
  const matches = TAGS.filter(
    (t) => t.startsWith(query) && t !== value,
  );
  const shown = open && matches.length > 0;

  function choose(tag) {
    onChange(tag);
    setOpen(false);
    setActive(-1);
  }

  function onKeyDown(e) {
    if (e.key === "ArrowDown" || e.key === "ArrowUp") {
      e.preventDefault();
      if (matches.length === 0) return;
      const step = e.key === "ArrowDown" ? 1 : -1;
      setOpen(true);
      setActive((a) => (a + step + matches.length) % matches.length);
    } else if (e.key === "Enter" && shown && active >= 0) {
      e.preventDefault(); // forma yuborilmasin — faqat tanlash
      choose(matches[active]);
    } else if (e.key === "Escape" && shown) {
      e.preventDefault();
      setOpen(false);
    }
  }

  return (
    <span>
      <label htmlFor="tag">Teg</label>
      <input
        id="tag"
        role="combobox"
        aria-autocomplete="list"
        aria-expanded={shown}
        aria-controls={listId}
        aria-activedescendant={
          shown && active >= 0 ? `${listId}-${active}` : undefined
        }
        autoComplete="off"
        value={value}
        onChange={(e) => {
          onChange(e.target.value);
          setOpen(true);
          setActive(-1);
        }}
        onKeyDown={onKeyDown}
        onBlur={() => setOpen(false)}
      />
      <ul id={listId} role="listbox" hidden={!shown}>
        {matches.map((tag, i) => (
          <li
            key={tag}
            id={`${listId}-${i}`}
            role="option"
            aria-selected={i === active}
            onMouseDown={(e) => e.preventDefault()}
            onClick={() => choose(tag)}
          >
            {tag}
          </li>
        ))}
      </ul>
    </span>
  );
}

export default function App() {
  const [tag, setTag] = useState("");
  return (
    <form>
      <TagCombobox value={tag} onChange={setTag} />
      <p>Tanlandi: {tag}</p>
    </form>
  );
}

Ekranda:

text
Teg
Tanlandi: #bolalar

#b yozildi — ikki taklif (#bozor, #bolalar) ro'yxatda (tekshiruvchi #bozor ni ko'rdi). #bolalar bosildi — tanlandi, ro'yxat yopildi va endi hidden (ekranga kirmaydi). Uch nozik joy:

  • onMouseDown={(e) => e.preventDefault()} — sichqoncha bosilganda fokus maydondan ketmasin. Aks holda avval blur ishlaydi, ro'yxat yopiladi va click "yo'q" elementga tushadi. Bu — combobox'larning eng ko'p uchraydigan xatosi.
  • hidden={!shown} — ro'yxat DOM'da turadi (aria-controls uni topsin), lekin ko'rinmaydi.
  • Enter faqat tanlaganda preventDefault — faol variant yo'q bo'lsa, Enter odatdagidek formani yuboradi.

6.3 Kutubxonalar

Combobox — a11y jihatdan eng qiyin vidjetlardan biri: brauzer va ekran o'quvchi juftliklari har xil. Real loyihada ko'pincha tayyor yechim:

Kutubxona Nima
Downshift headless combobox/select hook'lari (useCombobox) — ARIA'ni o'zi qo'yadi, ko'rinish sizniki
cmdk "buyruqlar palitrasi" (K) — qidiruvli ro'yxat; shadcn/ui Command
React Aria, Radix, Headless UI to'liq a11y komponentlar (Radix Primitives)

Downshift ning ko'rinishi — headless komponentlar naqshi:

jsx
const { isOpen, getInputProps, getMenuProps, getItemProps } =
  useCombobox({ items: matches, onInputValueChange: … });

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

getInputProps() — role, aria-*, klaviatura ishlovchilari hammasi bir yerda (prop getters). Bizning 60 qatorlik kodimiz — Downshift ichida shu qadar ish.

Tekshirib ko'ring: Nega takliflar ro'yxatiga fokus o'tkazmaymiz (masalan, ↓ bosilganda birinchi <li> ga focus())?

Javob

Fokus ro'yxatga o'tsa, mehmon yozishni davom ettira olmaydi — harflar endi maydonga tushmaydi. Ekran o'quvchida ham kontekst yo'qoladi ("maydon" dan "ro'yxat" ga sakrash). ARIA combobox naqshi shuning uchun fokusni maydonda qoldiradi va faol variantni aria-activedescendant bilan bildiradi. vazifalar dagi eski teg takliflari (14-qismda) aynan fokusni tugmalarga o'tkazar edi — TEXNIK-QARZ'dagi 20-band.

7. Nega vazifalar da telefon, sana va OTP yo'q

Bu darsdagi beshta inputdan vazifalar ga faqat combobox kirdi. Telefon, sana va OTP — yo'q, va bu ataylab.

Sabab — ma'lumot modeli. Vazifada uch maydon bor: id, matn, bajarildi. Telefon yoki muddat (sana) qo'shish — modelni o'zgartirish. U ko'p joyga tegadi: localStorage dagi saqlash formati (PAKET_VERSIYASI), keshdagi nusxa, «Bahor» mashq serverining API'si, import/eksport fayli, 13–19-qismlarning brauzer tekshiruvlari. Heap (navbat) qadami ham 14-qismda aynan shu sabab bilan qilinmagan edi: "muddat maydoni ma'lumot modelini o'zgartiradi". Kursning loyihasi o'quv uchun — har dars uchun yangi maydon qo'shsak, model "hamma narsani saqlaydigan" bo'lib qoladi.

Teglar esa allaqachon bor (12-qismdan): ular matn ichida (#bozor). Combobox modelni o'zgartirmaydi — u faqat mavjud teglarni taklif qiladi. Telefon, sana va OTP o'z o'rnida chiqadi: OTP — haqiqiy auth bilan (28-qism), sana tanlagich — Kalendar va bron darsida.

8. Vazifalar qadami: teg combobox'i (holat 35)

8.1 ui/Combobox.tsx

Kanon, to'liq (izoh qatorlari telefonga sig'ishi uchun bo'lingan):

tsx
// Combobox.tsx — maxsus input (19/#35): matn maydoni + takliflar
// ro'yxati, ARIA 1.2 combobox (APG: "list autocomplete, manual
// selection"). Fokus DOIM maydonda qoladi; ro'yxatdagi "faol" taklif
// — aria-activedescendant (ekran o'quvchi uni o'qiydi). Klaviatura:
// ↓/↑ — taklif, Enter — tanlash (forma yuborilmaydi), Esc — yopish,
// Tab — yopib keyingi elementga.
// 14/#41 dagi teg takliflari (tugmalar guruhi) — to'liq combobox emas
// edi (TEXNIK-QARZ 20): yangi formalar shu komponentni ishlatadi
import { useId, useState } from "react";
import type { KeyboardEvent, Ref } from "react";

type Props = {
  id: string;
  qiymat: string;
  onOzgar: (qiymat: string) => void;
  onBlur?: () => void;
  ref?: Ref<HTMLInputElement>;
  /** hamma variant — tartibi bilan (filtrlash shu yerda) */
  takliflar: readonly string[];
  placeholder?: string;
  "aria-invalid"?: boolean | undefined;
  "aria-describedby"?: string | undefined;
};

const ENG_KOP = 8;

export function Combobox({
  id,
  qiymat,
  onOzgar,
  onBlur,
  ref,
  takliflar,
  placeholder,
  ...aria
}: Props) {
  const royxatId = useId();
  const [ochiq, setOchiq] = useState(false);
  const [faol, setFaol] = useState(-1); // -1 — hech biri
  const sorov = qiymat.trim().toLowerCase();
  const mos = takliflar
    .filter((t) => t.toLowerCase().startsWith(sorov) && t !== qiymat)
    .slice(0, ENG_KOP);
  const korinadi = ochiq && mos.length > 0;
  const variantId = (i: number) => `${royxatId}-${i}`;

  function tanla(taklif: string) {
    onOzgar(taklif);
    setOchiq(false);
    setFaol(-1);
  }

  function tugma(event: KeyboardEvent<HTMLInputElement>) {
    if (event.nativeEvent.isComposing) {
      return;
    }
    switch (event.key) {
      case "ArrowDown":
      case "ArrowUp": {
        event.preventDefault();
        if (mos.length === 0) {
          return;
        }
        setOchiq(true);
        const qadam = event.key === "ArrowDown" ? 1 : -1;
        setFaol((f) => (f + qadam + mos.length) % mos.length);
        return;
      }
      case "Enter": {
        const tanlangan = korinadi ? mos[faol] : undefined;
        if (tanlangan !== undefined) {
          // formani yubormasin — faqat tanlash
          event.preventDefault();
          tanla(tanlangan);
        }
        return;
      }
      case "Escape":
        if (korinadi) {
          event.preventDefault();
          setOchiq(false);
          setFaol(-1);
        }
        return;
      default:
    }
  }

  return (
    <span className="combobox">
      <input
        ref={ref}
        id={id}
        type="text"
        role="combobox"
        autoComplete="off"
        placeholder={placeholder}
        aria-autocomplete="list"
        aria-expanded={korinadi}
        aria-controls={royxatId}
        aria-activedescendant={
          korinadi && faol >= 0 ? variantId(faol) : undefined
        }
        value={qiymat}
        onChange={(event) => {
          onOzgar(event.target.value);
          setOchiq(true);
          setFaol(-1);
        }}
        onKeyDown={tugma}
        onBlur={() => {
          setOchiq(false);
          setFaol(-1);
          onBlur?.();
        }}
        {...aria}
      />
      <ul id={royxatId} role="listbox" hidden={!korinadi}>
        {mos.map((taklif, i) => (
          <li
            key={taklif}
            id={variantId(i)}
            role="option"
            aria-selected={i === faol}
            // mousedown'da fokus maydondan ketmasin (blur ro'yxatni
            // yopardi)
            onMouseDown={(event) => event.preventDefault()}
            onClick={() => tanla(taklif)}
          >
            {taklif}
          </li>
        ))}
      </ul>
    </span>
  );
}

«Kichik combobox» bo'limidagi kodning to'liq varianti. Qo'shimchalar:

  • event.nativeEvent.isComposing — kirill yoki xitoy kabi kiritish usullarida (IME) harf "yig'ilayotgan" paytda Enter va strelkalar shu usulga tegishli. Ularni ushlamaymiz.
  • ENG_KOP = 8 — 200 ta teg bo'lsa ham ro'yxat uzun bo'lmasin.
  • ...aria — forma bergan aria-invalid va aria-describedby to'g'ridan maydonga (komponent API'si).
  • ref — React 19 prop: Controller ning field.ref i maydonga yetadi, xatoda fokus shu yerga.

8.2 Wizard'ga ulash

YangiLoyiha.tsx dagi teg maydoni register dan Controller ga o'tdi (kanon diff):

diff
+  // 19/#35: mavjud teglar — ko'p ishlatilgani oldin (combobox
+  // takliflari)
+  const teglar = useMemo(
+    () =>
+      [...VazifalarRoyxati.dan(vazifalar).tegHisobi()]
+        .toSorted((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
+        .map(([teg]) => teg),
+    [vazifalar],
+  );
...
-              <input
-                id="usta-teg"
-                autoComplete="off"
-                placeholder="#ta'mir"
-                aria-invalid={
-                  tegXatosi === undefined ? undefined : true
-                }
-                aria-describedby={tegXatoId}
-                {...register("teg")}
+              {/* 19/#35: maxsus input — combobox (Controller orqali:
+                  qiymat va o'zgarish props bilan) */}
+              <Controller
+                control={control}
+                name="teg"
+                render={({ field }) => (
+                  <Combobox
+                    id="usta-teg"
+                    ref={field.ref}
+                    qiymat={field.value}
+                    onOzgar={field.onChange}
+                    onBlur={field.onBlur}
+                    takliflar={teglar}
+                    placeholder="#ta'mir"
+                    aria-invalid={
+                      tegXatosi === undefined ? undefined : true
+                    }
+                    aria-describedby={tegXatoId}
+                  />
+                )}
               />
  • Takliflar tartibi — eng ko'p ishlatilgan teg birinchi (tegHisobi() — teg → nechta vazifada), teng bo'lsa alifbo bo'yicha. toSorted — massivni o'zgartirmaydigan saralash (toSorted).
  • Controller — RHF II dagi Almashtirgich bilan bir xil ulanish: qiymat/onOzgar — komponentning o'z nomlari.
  • Sxema o'zgarmadi — combobox faqat yozishni osonlashtiradi, qoida (# bilan, bo'shliqsiz) o'sha.

CSS (vazifalar.css, 20 qator) — ro'yxat maydon ostida, position: absolute, faol variant — asosiy rang fonida.

8.3 Tekshiruvlar va commit

Yangi Vitest fayli tekshiruv/Combobox.test.tsx — 3 test: takliflar va aria-expanded; ↓ ↓ ↑ — aria-activedescendant, Enter tanlaydi va formani yubormaydi; Esc va sichqoncha bilan tanlash. Ikkinchisining yakuni (kanon):

tsx
    fireEvent.keyDown(maydon(), { key: "Enter" });
    expect(maydon().value).toBe("#bozor");
    expect(maydon().getAttribute("aria-expanded")).toBe("false");
    expect(yuborildi).not.toHaveBeenCalled();

yuborildi — formaning onSubmit "josusi" (vi.fn()): Enter tanladi, lekin forma yuborilmadi.

Natijalar (haqiqiy, holat 35):

  • npm test — node:test 187, Vitest Tests 67 passed (67) (+3). «Orqaga» tuzatishidan keyin sizda node:test — 195.
  • Build: index-….js 475.31 kB │ gzip: 151.40 kB — +0.6 KB (kutubxonasiz, o'z komponentimiz).
  • Brauzer: 159/159, 72/72, 19-qism — 89 (tuzatishlardan keyin 92). Combobox bo'limi (haqiqiy Chrome'da, klaviatura bilan):
text
✅ combobox: yozildi — takliflar (listbox), faol yo'q: {"rol":"combobox","ochiq":"true","takliflar":["#bozor"],"faol":null,"qiymat":"#b","fokus":true}
✅ combobox: ↓ — faol taklif (aria-activedescendant), fokus maydonda: {"rol":"combobox","ochiq":"true","takliflar":["#bozor"],"faol":"#bozor","qiymat":"#b","fokus":true}
✅ combobox: Enter — tanlandi, yopildi, qadam o'zgarmadi (forma yuborilmadi): [{"rol":"combobox","ochiq":"false","takliflar":[],"faol":null,"qiymat":"#bozor","fokus":true},"Teg"]
✅ combobox: CSP buzilishi yo'q: []

Har qatorda "fokus":true — fokus butun jarayonda maydonda qoldi. Diff: 4 fayl, +267 −11.

bash
git switch -c feat/teg-combobox
git add -A
git commit

Commit (kanon): feat: teg combobox (ARIA 1.2) — ko'p qadamli formada. Ro'yxat sahifasidagi eski teg takliflari hali o'zgarmagan (TEXNIK-QARZ 20 — "yangi formalar shu komponentni ishlatadi").

9. Ko'p uchraydigan xatolar

Belgi Sabab Davosi
formada "+998 (90) …" saqlanyapti formatlangan matn qiymat qilib berilgan formaga xom raqam, format — value da
telefonda harfli klaviatura inputMode yo'q inputMode="tel" / "numeric"
telefon maydonida strelkalar, 0 yo'qoldi type="number" type="tel"
SMS kod o'zi tushmaydi autoComplete yo'q autoComplete="one-time-code"
6 katakchaga paste ishlamaydi har katak — alohida input bitta input yoki input-otp
sana bir kun oldin chiqdi new Date("YYYY-MM-DD") — UTC sanani satr holida saqlang
taklif bosilganda tanlanmaydi blur ro'yxatni yopdi onMouseDown={(e) => e.preventDefault()}
Enter taklifni tanlash o'rniga formani yubordi preventDefault yo'q Enter — faol variant bo'lsa preventDefault
ekran o'quvchi faol variantni aytmaydi aria-activedescendant yo'q yoki id noto'g'ri variant id'si — useId asosida

10. Mashqlar

Mashqlar kurs/mashqlar/19/35-maxsus-inputlar/ da. Har yechimni «Ishga tushir» bilan sinang.

1-mashq (oson): qaysi atribut?

  • Telefonda raqamli klaviatura (telefon raqami uchun) → inputMode=""
  • SMS'dagi kodni taklif qilish → autoComplete=""
  • <input type="date"> qiymatining ko'rinishi →
  • Combobox'da faol variant id'si turadigan atribut →
  • Combobox ro'yxati ochiqligini bildiradigan atribut →
Yechim
  • tel — raqamlar va +, *, #. Faqat raqam kerak bo'lsa — numeric.
  • one-time-code — iOS va Android SMS'dan kodni taklif qiladi.
  • YYYY-MM-DD — ko'rinish brauzer tiliga qarab, qiymat doim shu.
  • aria-activedescendant — fokus maydonda, "faol" — shu id.
  • aria-expanded — true/false.

2-mashq (o'rta): karta raqami

To'lov formasiga karta raqami maydoni: ekranda to'rttalab (8600 1234 5678 9012), formada 16 raqam ("8600123456789012"). 16 raqamdan kam bo'lsa — "Karta raqami 16 ta raqam" (sxema yoki validate). «Telefon: +998 maskasi» bo'limidagi ikki funksiya g'oyasidan foydalaning.

Karta raqamlari — misol uchun (8600 1234 5678 9012 — soxta).

Yechim
jsx
import { Controller, useForm } from "react-hook-form";

const toCardDigits = (text) => text.replace(/\D/g, "").slice(0, 16);
const formatCard = (d) => d.replace(/(\d{4})(?=\d)/g, "$1 ");

export default function App() {
  const {
    control,
    handleSubmit,
    formState: { errors },
  } = useForm({ defaultValues: { card: "" } });
  return (
    <form noValidate onSubmit={handleSubmit((d) => console.log(d))}>
      <label htmlFor="card">Karta raqami</label>
      <Controller
        control={control}
        name="card"
        rules={{
          validate: (v) =>
            v.length === 16 || "Karta raqami 16 ta raqam",
        }}
        render={({ field }) => (
          <input
            id="card"
            ref={field.ref}
            inputMode="numeric"
            autoComplete="cc-number"
            value={formatCard(field.value)}
            onChange={(e) =>
              field.onChange(toCardDigits(e.target.value))
            }
            onBlur={field.onBlur}
          />
        )}
      />
      <p role="alert">{errors.card?.message}</p>
      <button>To'lash</button>
    </form>
  );
}

Konsolda:

text
{card: '8600123456789012'}

formatCard — "to'rtta raqamdan keyin, agar yana raqam bo'lsa, bo'shliq". autoComplete="cc-number" — brauzer saqlangan kartani taklif qiladi. Controller ning rules — register dagi qoidalar bilan bir xil (RHF I). Haqiqiy to'lovda karta raqami sizning serveringizga umuman kelmasligi kerak — to'lov tizimining (Click, Payme) o'z sahifasi yoki vidjeti uni oladi (29-qism).

3-mashq (qiyin): taom combobox'i — RHF bilan

«Kichik combobox» bo'limidagi TagCombobox ni buyurtma formasiga ulang:

  1. Takliflar — menyu: ["Osh", "Manti", "Lag'mon", "Mastava"], kichik harf bilan qidiriladi ("ma" → Manti, Mastava).
  2. dish maydoni — Controller orqali; ref va onBlur ham ulansin (komponent ularni qabul qilsin).
  3. Majburiy: "Taomni tanlang yoki yozing" (rules).
  4. Yuborishda — {dish: …} konsolga.

Ishora: onBlur ni komponentning o'z onBlur ishlovchisi ichida chaqiring — ro'yxatni yopgandan keyin.

Yechim
jsx
import { useId, useState } from "react";
import { Controller, useForm } from "react-hook-form";

const MENU = ["Osh", "Manti", "Lag'mon", "Mastava"];

function DishCombobox({ value, onChange, onBlur, ref }) {
  const listId = useId();
  const [open, setOpen] = useState(false);
  const query = value.toLowerCase();
  const matches = MENU.filter(
    (d) => d.toLowerCase().startsWith(query) && d !== value,
  );
  const shown = open && matches.length > 0;
  return (
    <span>
      <label htmlFor="dish">Taom</label>
      <input
        id="dish"
        ref={ref}
        role="combobox"
        aria-autocomplete="list"
        aria-expanded={shown}
        aria-controls={listId}
        autoComplete="off"
        value={value}
        onChange={(e) => {
          onChange(e.target.value);
          setOpen(true);
        }}
        onBlur={() => {
          setOpen(false);
          onBlur();
        }}
      />
      <ul id={listId} role="listbox" hidden={!shown}>
        {matches.map((d) => (
          <li
            key={d}
            role="option"
            aria-selected={false}
            onMouseDown={(e) => e.preventDefault()}
            onClick={() => {
              onChange(d);
              setOpen(false);
            }}
          >
            {d}
          </li>
        ))}
      </ul>
    </span>
  );
}

export default function App() {
  const {
    control,
    handleSubmit,
    formState: { errors },
  } = useForm({ defaultValues: { dish: "" } });
  return (
    <form noValidate onSubmit={handleSubmit((d) => console.log(d))}>
      <Controller
        control={control}
        name="dish"
        rules={{ required: "Taomni tanlang yoki yozing" }}
        render={({ field }) => (
          <DishCombobox
            ref={field.ref}
            value={field.value}
            onChange={field.onChange}
            onBlur={field.onBlur}
          />
        )}
      />
      <p role="alert">{errors.dish?.message}</p>
      <button>Buyurtma</button>
    </form>
  );
}

Konsolda:

text
{dish: 'Manti'}

Ekranda:

text
Taom
Buyurtma

Bo'sh yuborildi — xato; ma yozildi — Manti va Mastava; Manti bosildi — xato yo'qoldi (birinchi yuborishdan keyin reValidateMode: "onChange"), yuborildi. Klaviatura (↓ ↑ Enter Esc) — TagCombobox dagidek qo'shing; bu yechimda qisqalik uchun faqat sichqoncha.

4-mashq: Vazifalar qadami

feat/teg-combobox branch'ida ui/Combobox.tsx (testi bilan), YangiLoyiha.tsx dagi Controller va takliflar, CSS. Commit.

Yechim

Tekshiruv ro'yxati:

  • npm test — node:test 195 («Orqaga» tuzatishigacha — 187), Vitest 67.
  • npm run dev → /yangi: tegga #b yozing — ro'yxatda #bozor; ↓ — u faol (rangli), fokus maydonda; Enter — tanlandi, qadam o'zgarmadi. Yana Enter (taklif yo'q) — "Keyingi" ishlaydi.
  • DevTools Accessibility panelida maydon: combobox, expanded: true, activedescendant — variant id'si.
  • git log --oneline -1 — feat: teg combobox (ARIA 1.2) — ko'p qadamli formada.

11. Real ishda

  • Telefon va karta maskalari — har o'zbek internet-do'koni, yetkazib berish va bank ilovasida. Odatda react-imask yoki react-number-format.
  • OTP — Click, Payme, Uzum kabi ilovalarda kirish va to'lov tasdig'i. Shadcn/ui InputOTP (input-otp) keng tarqalgan.
  • Combobox — manzil qidirish, mahsulot qidirish, teg va kategoriya tanlash. A11y talablari kuchli bo'lgan joyda (davlat xizmatlari, bank) — Downshift, React Aria yoki Radix.
  • Intervyu savollari: "maskali maydonda formaga qaysi qiymat ketadi?", "nega telefon uchun type=number emas?", "combobox'da fokus qayerda bo'ladi va nega?", "aria-activedescendant nima?".

Xulosa

  • Maxsus input: formada xom qiymat (raqamlar, son, "YYYY-MM-DD"), ekranda — formatlangan; ulanish Controller bilan.
  • Telefon: type="tel", inputMode="tel", maska; kursor uchun real loyihada react-imask. So'm: o'z guruhlash yoki react-number-format; Intl natijasi muhitga qarab farq qiladi.
  • OTP: bitta maydon, autoComplete="one-time-code", inputMode="numeric"; katakchalar kerak bo'lsa input-otp.
  • Sana: avval <input type="date">; new Date("YYYY-MM-DD") — UTC tuzog'i; murakkab kalendar — react-day-picker.
  • Combobox: ARIA 1.2 — fokus maydonda, aria-activedescendant, onMouseDown + preventDefault; vazifalar da teg combobox'i, telefon/sana/OTP esa ma'lumot modeli o'zgarmasligi uchun yo'q.

Keyingi dars: Fayl yuklash UI — fayl tanlash va sudrab tashlash, oldindan ko'rish, progress va bekor qilish; vazifalar ga profil rasmi va 19-qism yakuni (v9).

Manbalar

  • WAI-ARIA APG: Combobox pattern — w3.org/WAI/ARIA/apg/patterns/combobox (ARIA 1.2)
  • MDN: inputmode, autocomplete (one-time-code, tel, cc-number) — developer.mozilla.org/docs/Web/HTML/Attributes/autocomplete
  • MDN: <input type="date">, WebOTP API — developer.mozilla.org/docs/Web/API/WebOTP_API
  • IMask — imask.js.org; react-number-format — s-yadav.github.io/react-number-format; input-otp — github.com/guilhermerodz/input-otp; react-day-picker — daypicker.dev; Downshift — downshift-js.com
Ulashish:Telegram'da

Izohlar (0)

Izoh yozish uchun kiring.

  • Hozircha izoh yo'q. Birinchi bo'ling!
React'da maxsus inputlar: telefon maskasi, so'm formati, OTP, sana va combobox — IlmHamroh