İçeriğe geç
academia.sh

Ders 19 / 25

Başsız Bileşenler

Davranışın görünümden koparılması; seçenek listesinin saf durum makinesi olarak yazılması, erişilebilirlik özniteliklerinin durumdan türetilmesi, olay dizisiyle sınanması ve aynı davranışın iki görünümle kullanılması.

İçindekiler

Süzgeç panelinde istasyon seçmek için bir liste var. Listenin davranışı ayrıntılıdır: ok tuşlarıyla gezinme, kapalı öğeyi atlama, listenin ucunda başa dönme, ilk ve son öğeye atlama, harfe basınca eşleşen öğeye gitme, seçim, iptal. Bu davranış aynı zamanda bir erişilebilirlik sözleşmesi taşır — hangi öğenin etkin olduğu, listenin açık olup olmadığı ve hangi öğenin seçili olduğu yardımcı teknolojiye bildirilmek zorundadır.

Görünüm ise her yerde farklı. Süzgeç panelinde düğmeye bağlı açılır bir liste, geniş ekranda yan panelde her zaman açık duran bir liste, ölçüm tablosunun başlığında satır içi bir seçici. Davranışı bir bileşene gömmek, bu üç görünümü tek bir görünüme indirmek demektir.

Davranış Bir Bileşen Değildir

Başsız bileşen (headless component), davranışı ve erişilebilirlik sözleşmesini taşıyan ama hiçbir görünüm üretmeyen birimdir. Dışarıya iki şey verir: okunacak bir durum ve işaretlemeye takılacak öznitelikler. Ne öge seçer, ne sınıf yazar, ne biçem tanımlar.

Önceki dersteki ayrımın bir adım ilerisidir. Sunum ve konteyner ayrımı veriyi görünümden ayırmıştı; burada ayrılan şey davranıştır. Sonuçta üç bağımsız katman kalır: veriyi elde eden, davranışı yürüten ve görünümü üreten.

Davranışın taşıyıcısı bir durum makinesidir. Programlama Temelleri kursundaki saf fonksiyon tanımına uyan bir indirgeyici — bir durum ve bir olay alır, yeni durum döndürür — davranışın tamamını taşıyabilir. Zamanlayıcı, DOM erişimi ve ağ çağrısı bu işlevin dışında kalır.

// bassiz.mjs — secenek listesinin davranisi: saf durum makinesi, gorunum yok
export const SECENEKLER = [
  { deger: "ust", metin: "Üst yamaç" },
  { deger: "orta", metin: "Orta teras" },
  { deger: "alt", metin: "Alt yamaç", pasif: true },
  { deger: "zirve", metin: "Zirve" },
];

export const baslangic = (secili = null) =>
  ({ acik: false, etkin: -1, secili, arabellek: "", sonHarfZamani: -Infinity });

const secilebilir = (s, i) => i >= 0 && i < s.length && !s[i].pasif;
const uc = (s, bastan) =>
  (bastan ? s.map((_, i) => i) : s.map((_, i) => s.length - 1 - i)).find((i) => secilebilir(s, i)) ?? -1;
const ilerle = (s, baslangicDizini, yon) => {
  if (baslangicDizini < 0) return uc(s, yon > 0);
  for (let adim = 1; adim <= s.length; adim++) {
    const i = (baslangicDizini + yon * adim + s.length * adim) % s.length;
    if (secilebilir(s, i)) return i;
  }
  return -1;
};

// Saf indirgeyici: (durum, olay) -> yeni durum. Yan etki, zamanlayici ve DOM yok.
export function indirge(durum, olay, secenekler = SECENEKLER) {
  const ac = () => {
    const seciliDizin = secenekler.findIndex((o) => o.deger === durum.secili);
    return {
      ...durum, acik: true, arabellek: "",
      etkin: secilebilir(secenekler, seciliDizin) ? seciliDizin : uc(secenekler, true),
    };
  };
  const kapat = () => ({ ...durum, acik: false, etkin: -1, arabellek: "" });

  switch (olay.t) {
    case "ac": return durum.acik ? durum : ac();
    case "kapat": return kapat();
    case "isaretle":
      return durum.acik && secilebilir(secenekler, olay.dizin) ? { ...durum, etkin: olay.dizin } : durum;
    case "tus":
      switch (olay.tus) {
        case "ArrowDown": return durum.acik ? { ...durum, etkin: ilerle(secenekler, durum.etkin, +1) } : ac();
        case "ArrowUp": return durum.acik ? { ...durum, etkin: ilerle(secenekler, durum.etkin, -1) } : ac();
        case "Home": return durum.acik ? { ...durum, etkin: uc(secenekler, true) } : durum;
        case "End": return durum.acik ? { ...durum, etkin: uc(secenekler, false) } : durum;
        case "Escape": return kapat();
        case "Enter": case " ":
          if (!durum.acik) return ac();
          if (!secilebilir(secenekler, durum.etkin)) return durum;
          return { ...kapat(), secili: secenekler[durum.etkin].deger };
        default: return durum;
      }
    case "harf": {
      const yeni = olay.zaman - durum.sonHarfZamani > 500 ? olay.harf : durum.arabellek + olay.harf;
      const kucuk = (m) => m.toLocaleLowerCase("tr");
      const bulunan = secenekler.findIndex((o, i) => secilebilir(secenekler, i) && kucuk(o.metin).startsWith(kucuk(yeni)));
      return {
        ...durum, acik: true, arabellek: yeni, sonHarfZamani: olay.zaman,
        etkin: bulunan === -1 ? durum.etkin : bulunan,
      };
    }
    default: return durum;
  }
}

// Durumdan turetilen erisilebilirlik oznitelikleri: yine gorunum degil, sozlesme.
export const aria = (durum, kok, secenekler = SECENEKLER) => ({
  tetikleyici: {
    "aria-haspopup": "listbox",
    "aria-expanded": String(durum.acik),
    "aria-controls": `${kok}-liste`,
  },
  liste: {
    id: `${kok}-liste`,
    role: "listbox",
    tabindex: -1,
    "aria-activedescendant": durum.acik && durum.etkin >= 0 ? `${kok}-secenek-${durum.etkin}` : "",
  },
  secenek: (i) => ({
    id: `${kok}-secenek-${i}`,
    role: "option",
    "aria-selected": String(secenekler[i].deger === durum.secili),
    ...(secenekler[i].pasif ? { "aria-disabled": "true" } : {}),
  }),
});

Durumda beş alan var ve hiçbiri görünüme ait değil: liste açık mı, hangi öğe etkin, hangi değer seçili, harfle arama arabelleği ne ve son harf ne zaman girildi. Zaman bile bir alandır — indirgeyici saati okumaz, olayın içinde alır. Bu, sınamayı zamandan bağımsız kılar.

Erişilebilirlik öznitelikleri durumun bir türevi olarak yazılır. Etkin öğe odağı almaz; liste odaklı kalır ve etkin öğenin kimliği aria-activedescendant ile bildirilir. Tarayıcı ve Web Platformu kursundaki dönen sekme durağı düzeninin bir başka biçimi budur: klavye sırasında tek durak, listenin içinde ok tuşlarıyla gezinme.

Aynı Davranış, İki Görünüm

Aşağıdaki dosya, önceki bloktaki bassiz.mjs ile aynı dizine konur.

// bassiz-izle.mjs — ayni durum makinesi, iki ayri gorunum
import { SECENEKLER, baslangic, indirge, aria } from "./bassiz.mjs";

const dizi = [
  { t: "tus", tus: "ArrowDown" },
  { t: "tus", tus: "ArrowDown" },
  { t: "tus", tus: "ArrowDown" },
  { t: "harf", harf: "z", zaman: 1000 },
  { t: "tus", tus: "Home" },
  { t: "tus", tus: "ArrowUp" },
  { t: "tus", tus: "Enter" },
  { t: "tus", tus: "ArrowDown" },
  { t: "tus", tus: "Escape" },
];

let durum = baslangic();
console.log("olay                 açık  etkin  etkin metin   seçili  arabellek");
console.log(`${"(başlangıç)".padEnd(20)} ${String(durum.acik).padEnd(5)} ${String(durum.etkin).padStart(5)}  ${"-".padEnd(12)} ${String(durum.secili).padEnd(7)} "${durum.arabellek}"`);
for (const olay of dizi) {
  durum = indirge(durum, olay);
  const ad = olay.t === "tus" ? `tuş ${olay.tus}` : olay.t === "harf" ? `harf "${olay.harf}"` : olay.t;
  const etkinMetin = durum.etkin >= 0 ? SECENEKLER[durum.etkin].metin : "-";
  console.log(`${ad.padEnd(20)} ${String(durum.acik).padEnd(5)} ${String(durum.etkin).padStart(5)}  ${etkinMetin.padEnd(12)} ${String(durum.secili).padEnd(7)} "${durum.arabellek}"`);
}

// --- Ayni durum, iki gorunum ---
const oge = (ad, ozn = {}, ...cocuklar) => ({ ad, ozn, cocuklar: cocuklar.flat().filter(Boolean) });
const metin = (d) => ({ ad: "#metin", ozn: {}, cocuklar: [], metin: d });
const yaz = (d, g = 0) => {
  if (d.metin !== undefined) return [`${"  ".repeat(g)}"${d.metin}"`];
  const o = Object.entries(d.ozn).filter(([, v]) => v !== "").map(([a, v]) => ` ${a}="${v}"`).join("");
  return [`${"  ".repeat(g)}<${d.ad}${o}>`, ...d.cocuklar.flatMap((c) => yaz(c, g + 1))];
};

// Gorunum A: dugmeye bagli acilir liste.
const acilirListe = (durum, kok) => {
  const a = aria(durum, kok);
  const secili = SECENEKLER.find((o) => o.deger === durum.secili);
  return oge("div", { class: "acilir" },
    oge("button", { type: "button", ...a.tetikleyici }, metin(secili?.metin ?? "İstasyon seçin")),
    durum.acik && oge("ul", { ...a.liste, class: "acilir-liste" },
      ...SECENEKLER.map((s, i) => oge("li", { ...a.secenek(i) }, metin(s.metin)))));
};

// Gorunum B: yan panelde her zaman acik liste.
const yanPanel = (durum, kok) => {
  const a = aria({ ...durum, acik: true }, kok);
  return oge("nav", { class: "yan-panel" },
    oge("h2", { id: `${kok}-baslik` }, metin("İstasyon")),
    oge("ul", { ...a.liste, "aria-labelledby": `${kok}-baslik`, class: "panel-liste" },
      ...SECENEKLER.map((s, i) => oge("li", { ...a.secenek(i) }, metin(s.metin)))));
};

let d2 = baslangic();
for (const olay of [{ t: "ac" }, { t: "tus", tus: "ArrowDown" }]) d2 = indirge(d2, olay);

console.log("\ngörünüm A (açılır liste), durum: açık, etkin =", d2.etkin);
console.log(yaz(acilirListe(d2, "istasyon")).join("\n"));

// Iki gorunumu karsilastir: rol ve durum oznitelikleri ayni, oge adlari ve siniflar farkli.
const dolas = (d, c = []) => { if (d.metin === undefined) { c.push(d); d.cocuklar.forEach((x) => dolas(x, c)); } return c; };
const imza = (kok, on) => dolas(kok).map((d) => {
  const rol = d.ozn.role ?? d.ad;
  const durumlar = Object.entries(d.ozn)
    .filter(([a]) => a.startsWith("aria-"))
    .map(([a, v]) => `${a}=${String(v).replace(on, "*")}`).sort().join(",");
  return `${rol}${durumlar ? "[" + durumlar + "]" : ""}`;
});
const a = imza(acilirListe(d2, "istasyon"), /istasyon/g);
const b = imza(yanPanel(d2, "istasyon-yan"), /istasyon-yan/g);
console.log("\ngörünüm A rol/durum imzası:", JSON.stringify(a));
console.log("görünüm B rol/durum imzası:", JSON.stringify(b));
console.log("öge adları:", JSON.stringify(dolas(acilirListe(d2, "istasyon")).map((d) => d.ad)),
  "/", JSON.stringify(dolas(yanPanel(d2, "istasyon-yan")).map((d) => d.ad)));
olay                 açık  etkin  etkin metin   seçili  arabellek
(başlangıç)          false    -1  -            null    ""
tuş ArrowDown        true      0  Üst yamaç    null    ""
tuş ArrowDown        true      1  Orta teras   null    ""
tuş ArrowDown        true      3  Zirve        null    ""
harf "z"             true      3  Zirve        null    "z"
tuş Home             true      0  Üst yamaç    null    "z"
tuş ArrowUp          true      3  Zirve        null    "z"
tuş Enter            false    -1  -            zirve   ""
tuş ArrowDown        true      3  Zirve        zirve   ""
tuş Escape           false    -1  -            zirve   ""

görünüm A (açılır liste), durum: açık, etkin = 1
<div class="acilir">
  <button type="button" aria-haspopup="listbox" aria-expanded="true" aria-controls="istasyon-liste">
    "İstasyon seçin"
  <ul id="istasyon-liste" role="listbox" tabindex="-1" aria-activedescendant="istasyon-secenek-1" class="acilir-liste">
    <li id="istasyon-secenek-0" role="option" aria-selected="false">
      "Üst yamaç"
    <li id="istasyon-secenek-1" role="option" aria-selected="false">
      "Orta teras"
    <li id="istasyon-secenek-2" role="option" aria-selected="false" aria-disabled="true">
      "Alt yamaç"
    <li id="istasyon-secenek-3" role="option" aria-selected="false">
      "Zirve"

görünüm A rol/durum imzası: ["div","button[aria-controls=*-liste,aria-expanded=true,aria-haspopup=listbox]","listbox[aria-activedescendant=*-secenek-1]","option[aria-selected=false]","option[aria-selected=false]","option[aria-disabled=true,aria-selected=false]","option[aria-selected=false]"]
görünüm B rol/durum imzası: ["nav","h2","listbox[aria-activedescendant=*-secenek-1,aria-labelledby=*-baslik]","option[aria-selected=false]","option[aria-selected=false]","option[aria-disabled=true,aria-selected=false]","option[aria-selected=false]"]
öge adları: ["div","button","ul","li","li","li","li"] / ["nav","h2","ul","li","li","li","li"]

İzleme tablosu davranışın tamamını on satırda gösteriyor. Üçüncü ok tuşunda etkin dizin 1’den 3’e atlıyor: kapalı öğe atlanmış. Home tuşu başa gidiyor, oradan yukarı ok yine kapalı öğeyi atlayarak sona sarıyor. Enter seçimi yapıp listeyi kapatıyor; ardından gelen ok tuşu listeyi seçili öğe etkin olacak biçimde açıyor. Son satırda Escape listeyi kapatıyor ama seçimi bozmuyor.

İki görünümün rol imzaları öğe düzeyinde birebir aynı: dört seçenek, aynı seçim durumları, aynı kapalılık bildirimi, aynı etkin öğe. Ayrım yalnızca kapsayan yapıda — biri düğmeli bir açılır liste, diğeri başlığa bağlanmış bir yan panel. Öge adları da farklı. Davranış aynı kaldığı hâlde görünüm bağımsız değişebiliyor.

Davranışın Görünümsüz Sınanması

Başsız bileşenin en somut kazancı burada görünür: davranış, hiçbir görünüm kurulmadan sınanır.

// bassiz.test.mjs — davranisin sinanmasi; hicbir gorunum, hicbir DOM kurulmuyor
import { test } from "node:test";
import { strictEqual, deepStrictEqual } from "node:assert";
import { SECENEKLER, baslangic, indirge, aria } from "./bassiz.mjs";

const oynat = (olaylar, durum = baslangic()) => olaylar.reduce((d, o) => indirge(d, o), durum);
const tus = (t) => ({ t: "tus", tus: t });

test("ok tuşu pasif seçeneği atlar", () => {
  const d = oynat([tus("ArrowDown"), tus("ArrowDown"), tus("ArrowDown")]);
  strictEqual(SECENEKLER[d.etkin].deger, "zirve");
});

test("son seçenekten sonra başa döner, başta yukarı sona gider", () => {
  const son = oynat([tus("End")], { ...baslangic(), acik: true });
  strictEqual(son.etkin, 3);
  strictEqual(oynat([tus("ArrowDown")], son).etkin, 0);
  strictEqual(oynat([tus("ArrowUp")], { ...son, etkin: 0 }).etkin, 3);
});

test("Enter seçer ve kapatır, Escape seçimi değiştirmez", () => {
  const secilmis = oynat([tus("ArrowDown"), tus("ArrowDown"), tus("Enter")]);
  strictEqual(secilmis.secili, "orta");
  strictEqual(secilmis.acik, false);
  const kacilmis = oynat([tus("ArrowDown"), tus("Escape")], secilmis);
  strictEqual(kacilmis.secili, "orta");
  strictEqual(kacilmis.acik, false);
});

test("açılışta seçili olan seçenek etkin olur", () => {
  strictEqual(oynat([{ t: "ac" }], baslangic("zirve")).etkin, 3);
  strictEqual(oynat([{ t: "ac" }], baslangic(null)).etkin, 0);
});

test("harf arabelleği 500 ms içinde birikir, sonra sıfırlanır", () => {
  const a = oynat([{ t: "harf", harf: "o", zaman: 1000 }]);
  strictEqual(SECENEKLER[a.etkin].deger, "orta");
  const b = indirge(a, { t: "harf", harf: "r", zaman: 1200 });   // "or" -> Orta teras
  strictEqual(b.arabellek, "or");
  const c = indirge(b, { t: "harf", harf: "z", zaman: 2000 });   // arabellek sifirlandi
  strictEqual(c.arabellek, "z");
  strictEqual(SECENEKLER[c.etkin].deger, "zirve");
});

test("pasif seçenek işaretlenemez ve seçilemez", () => {
  const acik = oynat([{ t: "ac" }]);
  strictEqual(indirge(acik, { t: "isaretle", dizin: 2 }).etkin, acik.etkin);
  const zorlanmis = { ...acik, etkin: 2 };
  strictEqual(indirge(zorlanmis, tus("Enter")).secili, null);
});

test("indirgeyici verilen durumu değiştirmez", () => {
  const once = oynat([{ t: "ac" }]);
  const kopya = structuredClone(once);
  indirge(once, tus("ArrowDown"));
  deepStrictEqual(once, kopya);
});

test("etkin betimleyici yalnızca liste açıkken bildirilir", () => {
  const kapali = baslangic("orta");
  strictEqual(aria(kapali, "k").liste["aria-activedescendant"], "");
  strictEqual(aria(kapali, "k").tetikleyici["aria-expanded"], "false");
  const acik = indirge(kapali, { t: "ac" });
  strictEqual(aria(acik, "k").liste["aria-activedescendant"], "k-secenek-1");
  strictEqual(aria(acik, "k").secenek(1)["aria-selected"], "true");
  strictEqual(aria(acik, "k").secenek(2)["aria-disabled"], "true");
});

Dosya node --test bassiz.test.mjs ile çalıştırılır. Aşağıdaki çıktıda süre değerleri makineye göre değişir; sınama sonuçları değişmez.

✔ ok tuşu pasif seçeneği atlar (0.43125ms)
✔ son seçenekten sonra başa döner, başta yukarı sona gider (0.064375ms)
✔ Enter seçer ve kapatır, Escape seçimi değiştirmez (0.05875ms)
✔ açılışta seçili olan seçenek etkin olur (0.349ms)
✔ harf arabelleği 500 ms içinde birikir, sonra sıfırlanır (0.123ms)
✔ pasif seçenek işaretlenemez ve seçilemez (0.059625ms)
✔ indirgeyici verilen durumu değiştirmez (0.343333ms)
✔ etkin betimleyici yalnızca liste açıkken bildirilir (0.093542ms)
ℹ tests 8
ℹ suites 0
ℹ pass 8
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 40.331

Sekiz sınama, hiçbir tarayıcı ve hiçbir sahte DOM olmadan çalışıyor. Harfle arama sınaması zamanı bir sayı olarak veriyor; gerçek bir zamanlayıcı beklenmediği için sınama milisaniyeler yerine mikrosaniyeler sürüyor. Yedinci sınama durumun değiştirilemezliğini denetliyor: indirgeyici verilen nesneyi değiştirirse, çerçevenin değişikliği saptama düzeneği de bozulur.

Başsız Bileşenin Sınırları

Başsız yaklaşımın bedeli, görünümü kuran tarafın işinin artmasıdır. Öznitelikleri işaretlemeye takmak, olayları doğru öğeye bağlamak ve etkin öğeyi görünür alana kaydırmak çağıranın sorumluluğuna geçer. Bu iş her kullanımda tekrar edilir ve bir yerde eksik yapılır.

Sınır şuradan geçer: davranış birden çok görünümle kullanılacaksa başsız yazmak karşılığını verir; tek bir görünümle kullanılacaksa aynı davranışı bir bileşenin içinde tutmak daha az yüzey demektir. Ara bir yol, başsız çekirdeğin üzerine varsayılan bir görünüm koymaktır: yaygın durumda hazır bileşen kullanılır, gerektiğinde çekirdeğe inilir.

İkinci sınır, bazı davranışların saf kalamamasıdır. Etkin öğenin görünür alana kaydırılması, açılır listenin ekran kenarına göre konumlandırılması ve odağın liste kapanınca tetikleyiciye dönmesi ölçüm ve DOM erişimi gerektirir. Bunlar durum makinesinin dışında, ayrı ve açıkça işaretlenmiş bir katmanda durur; makinenin saflığı korunur.

Özet

  • Başsız bileşen davranışı ve erişilebilirlik sözleşmesini taşır, görünüm üretmez; dışarıya okunacak bir durum ve işaretlemeye takılacak öznitelikler verir.
  • Davranışın taşıyıcısı saf bir indirgeyicidir; zaman bile olayın içinde verilir, böylece sınama zamandan bağımsız kalır.
  • Erişilebilirlik öznitelikleri durumun türevidir; etkin öğe odağı almaz, kimliği aria-activedescendant ile bildirilir.
  • Aynı durumdan iki farklı görünüm üretildiğinde rol ve durum imzaları aynı kalır, öge adları ve kapsayan yapı değişir.
  • Davranış hiçbir görünüm kurulmadan olay dizileriyle sınanır; indirgeyicinin durumu değiştirmemesi de sınanacak bir özelliktir.
  • Ölçüm ve odak taşıma gerektiren davranışlar makinenin dışında, ayrı bir katmanda kalır; başsız yaklaşım tek görünümlü kullanımda karşılığını vermez.

Sonraki Adım

Bu konuda kurulan her şey bileşenin dış yüzeyine dayanıyordu: hangi özellikleri aldığı, hangi değerleri verdiği, hangi deliği bıraktığı. Bu yüzeyin kendisi henüz tasarlanmadı. Çağıran bileşene tanımadığı bir öznitelik verdiğinde ne olacak? Bileşenin kendi sınıf adı ile çağıranın verdiği sınıf adı çakıştığında hangisi kazanacak? Bileşen bir olayı hem kendisi dinleyip hem çağıranın dinleyicisini çağırabilecek mi? Değerin sahibi bileşen mi, çağıran mı? Sonraki ders bu soruların yanıtını özellik yayılması, varsayılan birleştirmesi ve denetim sahipliği başlıkları altında toplar.

İlerlemeni kaydetmek ve not almak için Giriş yap

Notlarım

Not almak için giriş yapmalısın.

Aramak için yazmaya başlayın.

↑↓ Esc gezin · aç · kapat