Blok 6 · Ders 45
Stil, erişilebilirlik ve kullanıcı deneyimi
Tailwind CSS ile hızlı stil, duyarlı tasarım, erişilebilirlik kontrolleri ve küçük ama fark yaratan arayüz ayrıntıları.
Bu dersin sonunda
- Tailwind CSS'in yardımcı sınıf mantığını düz CSS'le karşılaştırıp artısını ve eksisini anlatabilirsin
sm:,lg:önekleriyle mobil öncelikli,dark:önekiyle koyu temaya uyan sayfalar yazabilirsinhover:,focus-visible:vedisabled:durumlarıyla düğme, kart ve form bileşenleri kurabilirsin- Bir sayfayı etiket, odak, kontrast ve klavye açısından denetleyip ARIA'yı yalnız gerektiğinde kullanabilirsin
- Lighthouse'u geliştirici araçlarında çalıştırıp puanları ve erişilebilirlik bulgularını okuyabilirsin
- Boş, yükleniyor (iskelet) ve hata durumlarını kullanıcıya ne olduğunu ve ne yapacağını söyleyen biçimde tasarlayabilirsin
Bu sayfada
- Yeni proje: stil atölyesi
- Tailwind'in mantığı: yardımcı sınıflar
- Önekler: koşula bağlı sınıflar
- Düğme: durumları olan bir bileşen
- Kart: bir maçın bütün bilgisi
- Form: etiket, girdi ve hata mesajı
- Erişilebilirlik: dört denetim
- Lighthouse ile ölçmek
- Boş, yükleniyor ve hata durumları
- Görevler
- Kendini sına
- Kayıt noktası
Uygulaman çalışıyor; ama çalışan bir uygulama ile kullanılan bir uygulama arasında fark var. Kullanıcı bir düğmenin düğme olduğunu anlamalı, telefonda yazıları büyütmeden okuyabilmeli, fareyi kullanamıyorsa klavyeyle her yere ulaşabilmeli, internet yavaşken ekranın donmadığını görmeli. Bu derste Tailwind CSS ile hızlı stil yazmayı, sayfayı telefona ve koyu temaya uydurmayı, düğme, kart ve form bileşenleri kurmayı, sayfanı erişilebilirlik açısından denetleyip Lighthouse ile ölçmeyi ve boş, yükleniyor, hata durumlarını tasarlamayı öğreneceksin. Dersin sonunda bir maç geçmişi sayfan olacak: kartlar, form, iskelet yükleyici, boş ve hata ekranlarıyla. Ders 46'daki projen aynı parçaları kullanacak.
Yeni proje: stil atölyesi
Önceki projelerinle karışmasın diye yeni bir Next.js projesi açıyorsun. Komut Ders 43'tekiyle aynı; yalnız adı farklı. VS Code'da bir terminal aç, cd C:\kod ile kod klasörüne geç ve yaz:
PS C:\kod> npx create-next-app@latest stil-atolyesi --ts --app --tailwind --eslint --no-src-dir --import-alias "@/*" --no-react-compiler --no-cache-components --use-npmÇıktı Ders 43'tekiyle aynı akar; en sonda Success! Created stil-atolyesi at C:\kod\stil-atolyesi yazar. Bu ders create-next-app 16.4.0 ve Tailwind CSS 4.3.3 ile denendi (npm view tailwindcss version dersi yazarken 4.3.3 diyordu).
--tailwind bayrağı Tailwind'i kurdu ve ayarladı; sen bir şey kurmayacaksın. Ne kurduğuna bakmak için package.json'daki devDependencies bölümünde tailwindcss ve @tailwindcss/turbopack satırlarını, next.config.ts'te de @tailwindcss/turbopack yazan turbopack bölümünü görebilirsin: Next'in derleme aracı Turbopack, CSS dosyalarını bu eklentiden geçiriyor.
Klasörü aç (code stil-atolyesi) ve geliştirme sunucusunu başlat (npm run dev, adres http://localhost:3000). Şimdi şablonu temizle. app/globals.css'te yalnız ilk satır kalsın:
@import "tailwindcss";app/layout.tsx'in içini tamamen sil ve şunu yaz. Şablonun yazı tipi satırlarını bu derste kullanmıyoruz; sayfalar sistemin yazı tipiyle çizilecek:
import type { Metadata } from "next"import "./globals.css" export const metadata: Metadata = { title: "Stil Atölyesi", description: "Tailwind CSS ve erişilebilirlik denemeleri",} export default function RootLayout({ children }: LayoutProps<"/">) { return ( <html lang="tr"> <body>{children}</body> </html> )}app/page.tsx'e de hiç stil vermeden bir taslak yaz:
export default function Home() { return ( <main> <h1>Maç geçmişi</h1> <p>Son maçlarına bak, yenisini ekle.</p> <button>Maç ekle</button> <ul> <li>Ahri · Zafer · 8/2/11</li> <li>Jinx · Bozgun · 4/7/6</li> </ul> <label htmlFor="sampiyon">Şampiyon</label> <input id="sampiyon" /> </main> )}
Dikkatle bak: "Maç ekle" bir düğme ama düz yazı gibi duruyor. Listenin madde işaretleri yok. "Şampiyon" yazısının sağında bir giriş kutusu var ama görünmüyor; üstüne tıklarsan içine yazabilirsin. Tailwind'in Preflight adlı sıfırlama katmanı tarayıcıların birbirinden farklı varsayılan stillerini siliyor: boşluklar sıfır, başlıklar sıradan yazı boyutunda, listeler işaretsiz, kenarlıklar sıfır kalınlıkta. Her tarayıcıda aynı, boş bir tuvalden başlıyorsun. Bu dersin işi o tuvali boyamak.
Tailwind'in mantığı: yardımcı sınıflar
Ders 43'te globals.css'e 140 satıra yakın CSS koymuştun: .ust, .kart, .harf, .favori… Her yeni görünüm için bir ad uydurup CSS dosyasına geçiyor, kuralı yazıp bileşene dönüyordun. Bu düzenin üç derdi var. Ad bulmak zor (.kart mı, .mac-karti mı, .kart-kucuk mu?). İki dosya arasında gidip gelirsin. Bir bileşeni silince CSS'teki kuralları da silmeyi unutursun; dosya hiç kullanılmayan kurallarla şişer, hangisinin hâlâ lazım olduğunu kimse bilemez.
Tailwind CSS başka bir yol önerir: her biri tek bir iş yapan hazır, küçük sınıflar. p-4 iç boşluk verir, font-bold yazıyı kalınlaştırır, bg-white zemini beyaz yapar. Görünümü, bu sınıfları doğrudan JSX'teki className'e dizerek kurarsın.
İlk sınıflar
app/page.tsx'teki taslağın düğmesini, listesini ve formunu şimdilik sil; her birini bu derste bir bileşen olarak geri getireceksin. Kalan başlığa ve paragrafa sınıf ver:
export default function Home() { return ( <main className="mx-auto max-w-5xl px-4 py-8"> <h1 className="text-3xl font-bold">Maç geçmişi</h1> <p className="mt-1 text-zinc-600">Son maçlarına bak, yenisini ekle.</p> </main> )}layout.tsx'teki <body>'ye de sayfanın zemin ve yazı rengini ver:
<body className="bg-zinc-50 text-zinc-900">{children}</body>Kaydet: başlık büyüdü ve kalınlaştı, paragraf griye döndü, içerik ortalanıp kenarlardan boşluk aldı. Her sınıf tek bir CSS satırının kısaltması:
| Sınıf | Ürettiği CSS (değerler açılmış) | Anlamı |
|---|---|---|
mx-auto | margin-inline: auto | sağdan ve soldan eşit boşluk: ortala |
max-w-5xl | max-width: 64rem | en çok 1024 piksel genişlik |
px-4 | padding-inline: 1rem | sağa ve sola 16 piksel iç boşluk |
py-8 | padding-block: 2rem | üste ve alta 32 piksel iç boşluk |
mt-1 | margin-top: 0.25rem | üstte 4 piksel dış boşluk |
text-3xl | font-size: 1.875rem | 30 piksel yazı (satır yüksekliğiyle birlikte) |
font-bold | font-weight: 700 | kalın |
text-zinc-600 | color: … | zinc (çinko grisi) paletinin 600 tonu |
bg-zinc-50 | background-color: … | aynı paletin en açık tonu |
İki kalıp hemen göze çarpıyor:
- Boşluk ölçeği: Sınıftaki sayının bir birimi
0.25rem, yani 4 piksel.p-14,p-28,p-416,p-832 piksel.piç boşluk (padding),mdış boşluk (margin);xyatay,ydikey,tüst,balt,lsol,rsağ demek.mt-6"üstten 24 piksel dış boşluk"tur. Ders 20'deki kutu modelinin aynısı, yalnız kısaltılmış. - Renk paleti:
zinc,violet,red,emeraldgibi renk adları ve 50'den 950'ye tonlar. 50 neredeyse beyaz, 950 neredeyse siyah.text-yazı rengi,bg-zemin rengi,border-kenar rengi verir.
Hangi sınıfın hangi CSS'e karşılık geldiğini ezberlemene gerek yok. Tarayıcıda başlığa sağ tıkla, İncele (Inspect) de; Styles panelinde sınıfların ürettiği kuralları görürsün. Tailwind sayıları CSS değişkenleriyle (Ders 21) yazar; panelde şöyle bir şey okursun:
.text-3xl { font-size: var(--text-3xl); line-height: var(--tw-leading, var(--text-3xl--line-height));} .px-4 { padding-inline: calc(var(--spacing) * 4);}--spacing değişkeninin değeri 0.25rem; calc(var(--spacing) * 4) yani 1rem. Bütün boşluklar aynı ölçekten geldiği için sayfanda 13 piksel, 17 piksel gibi gelişigüzel değerler olmaz; tasarım kendiliğinden düzenli durur.
Tailwind CSS'i nasıl üretir?
Tailwind'de binlerce olası sınıf var; hepsinin CSS'i sitene girse dosya devasa olurdu. Bunun yerine Tailwind projendeki dosyaları düz metin olarak tarar, içlerinde sınıf adına benzeyen sözcükleri toplar ve yalnız onların CSS'ini üretir. Kodunu çalıştırmaz; yalnız okur. Bu ayrıntı birazdan önemli bir hatanın sebebi olacak.
Tailwind node_modules'u, .gitignore'daki dosyaları, resimleri ve CSS dosyalarını taramaz; geri kalan her şeye bakar. Sitenin CSS'i bu yüzden küçük kalır: bu dersin bütün sayfalarının (görevler dahil) yayın derlemesindeki CSS dosyası yaklaşık 17 KB tuttu.
Artıları, eksileri
| Düz CSS (Ders 20–21, 43) | Tailwind | |
|---|---|---|
| Ad bulmak | Her görünüme bir sınıf adı uydurursun | Ad yok; hazır sınıfları dizersin |
| Dosya | JSX ile CSS dosyası arasında gidip gelirsin | Görünüm bileşenin içinde |
| Silmek | Bileşeni silince ölü CSS kalır | Bileşenle birlikte stili de gider |
| Tutarlılık | Her değeri sen seçersin (13 px, 17 px…) | Ortak ölçek: boşluk, renk, yazı boyutu |
| Okunurluk | JSX sade | className uzar, ilk bakışta kalabalık |
| Öğrenmek | CSS bilmen yeter | Sınıf adlarını öğrenmen gerekir |
Tailwind CSS'in yerine geçmez; CSS bilmeden Tailwind yazamazsın. flex, grid, justify-between, rounded Ders 20–21'de öğrendiğin özelliklerin kısa adları. Uzayan className sorununun çözümü de tanıdık: aynı sınıf listesini iki yerde tekrarlıyorsan onu bir React bileşenine taşırsın. Birazdan düğmeyle yapacağın şey bu.
@tailwind satırıNe zaman çıkar? Tailwind 3 anlatan bir yazıdan globals.css'in başına şu üç satırı kopyalayınca:
@tailwind base;@tailwind components;@tailwind utilities;Ne terminalde ne tarayıcıda bir hata mesajı çıkar. Sayfa açılır ama tuhaftır: flex ve grid gibi düzen sınıfları çalışır, renkler, boşluklar ve köşeler gelmez; başlıklar tarayıcının varsayılan büyük, kalın hâline, listeler madde işaretlerine döner. Bu dersin sonunda yazacağın maç kartlarıyla denediğimizde kartın (article) iç boşluğu ve zemini konsolda şöyle çıktı:
> getComputedStyle(document.querySelector("article")).padding'0px'> getComputedStyle(document.querySelector("article")).backgroundColor'rgba(0, 0, 0, 0)'p-4 yazdığın kartın iç boşluğu 0, zemini saydam. Tailwind 4 bu satırlardan yalnız utilities'i tanıyor; renkleri ve ölçüleri tanımlayan tema ile Preflight hiç yüklenmiyor. Tema olmayınca Tailwind, değerini temadan alan p-4, bg-white, rounded-xl, font-bold gibi sınıfların CSS'ini hiç üretmiyor; yalnız temaya ihtiyacı olmayan flex ve grid gibi sınıfları üretiyor. Aynı üç satırı Tailwind 4.3.3'ün derleyicisinden geçirdik: çıkan CSS'te .flex ve .grid vardı, .p-4 ve .bg-white yoktu.
Nasıl düzelir? Üç satırı sil, yerine tek satır yaz: @import "tailwindcss";. Değişiklik görünmezse dosyayı bir kez daha kaydet ya da sunucuyu Ctrl+C ile durdurup yeniden başlat.
npm error could not determine executable to runNe zaman çıkar? Eski bir yazıdaki npx tailwindcss init -p komutunu Tailwind 4 kurulu bir projede çalıştırınca.
PS C:\kod\stil-atolyesi> npx tailwindcss init -pnpm error could not determine executable to runnpm error A complete log of this run can be found in: C:\Users\Deniz\AppData\Local\npm-cache\_logs\2026-10-12T01_49_51_124Z-debug-0.log"Çalıştırılacak programı bulamadım." Tailwind 4'ün tailwindcss paketinde komut satırı programı yok; ayar dosyası da artık gerekmediği için init komutu kaldırıldı.
Nasıl düzelir? Bir şey yapmana gerek yok: create-next-app --tailwind her şeyi kurdu. Bu komutu öneren yazının Tailwind 3 için yazıldığını anla ve kurulum için resmî belgeye dön.
Önekler: koşula bağlı sınıflar
Ders 20'de :hover yazmıştın: "fare üstündeyken şu kural geçerli". Ders 21'de @media (min-width: 640px): "pencere en az 640 piksel genişse şu kural geçerli". Tailwind ikisini de aynı yolla yazar: sınıfın başına bir önek koyarsın. hover:bg-violet-600 "fare üstündeyken zemini mor yap", sm:grid-cols-2 "640 pikselden itibaren iki sütun", dark:bg-zinc-900 "koyu temadaysa zemini koyu gri yap" demek.
Önekler üst üste de binebilir:
Duyarlı sınıflar: sm:, md:, lg:
Tailwind de Ders 21'deki gibi mobil öncelikli çalışır. Öneksiz sınıf her genişlikte geçerlidir; önekli sınıf o genişlikten itibaren devreye girer ve öncekini ezer:
| Önek | Başladığı genişlik | Ürettiği medya sorgusu |
|---|---|---|
sm: | 40rem (640 px) | @media (width >= 40rem) |
md: | 48rem (768 px) | @media (width >= 48rem) |
lg: | 64rem (1024 px) | @media (width >= 64rem) |
xl: | 80rem (1280 px) | @media (width >= 80rem) |
2xl: | 96rem (1536 px) | @media (width >= 96rem) |
Sayfaya maçları ekleyerek dene. Önce bir veri dosyası aç: proje kökünde lib klasörü ve içine maclar.ts:
export type Mac = { id: number sampiyon: string sonuc: "zafer" | "bozgun" oldurme: number olum: number asist: number dakika: number} export const maclar: Mac[] = [ { id: 1, sampiyon: "Ahri", sonuc: "zafer", oldurme: 8, olum: 2, asist: 11, dakika: 31 }, { id: 2, sampiyon: "Jinx", sonuc: "bozgun", oldurme: 4, olum: 7, asist: 6, dakika: 27 }, { id: 3, sampiyon: "Garen", sonuc: "zafer", oldurme: 6, olum: 3, asist: 4, dakika: 24 }, { id: 4, sampiyon: "Lux", sonuc: "zafer", oldurme: 3, olum: 1, asist: 17, dakika: 35 },]Sonra page.tsx'te paragrafın altına bir ızgara koy (en üste import { maclar } from "@/lib/maclar" satırını eklemeyi unutma):
<ul className="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> {maclar.map((mac) => ( <li key={mac.id} className="rounded-xl bg-white p-4"> {mac.sampiyon} </li> ))} </ul>grid ızgarayı açar ve öneksiz hâlde tek sütundur; sm:grid-cols-2 640 pikselden itibaren iki, lg:grid-cols-3 1024 pikselden itibaren üç sütun yapar. Geliştirici araçlarında Ctrl+Shift+M ile cihaz araç çubuğunu aç ve genişliği değiştir. Sınırların iki yanını ölçtük:
| Pencere genişliği | Sütun |
|---|---|
| 639 px | 1 |
| 640 px | 2 |
| 1023 px | 2 |
| 1024 px | 3 |
grid-cols-1 sm:grid-cols-2 lg:grid-cols-3'ün sonucu.Koyu tema: dark:
Windows'ta Ayarlar → Kişiselleştirme → Renkler → Modunuzu seçin bölümünde (İngilizce Windows'ta Settings → Personalization → Colors → Choose your mode) koyu modu seçen biri, sitelerin de koyu olmasını bekler. Tarayıcı bu tercihi sayfalara prefers-color-scheme medya özelliğiyle bildirir. Tailwind'in dark: öneki varsayılan olarak tam bunu dinler: kullanıcının sistemi koyuysa dark: önekli sınıflar devreye girer.
layout.tsx'teki iki satırı şöyle değiştir:
<html lang="tr" className="scheme-light-dark"> <body className="bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-zinc-100">{children}</body>page.tsx'te de paragrafa ve liste ögelerine koyu karşılıklarını ekle:
<p className="mt-1 text-zinc-600 dark:text-zinc-400">Son maçlarına bak, yenisini ekle.</p><li key={mac.id} className="rounded-xl bg-white p-4 dark:bg-zinc-900">dark:bg-zinc-950koyu temada zemini neredeyse siyah,dark:text-zinc-100yazıyı neredeyse beyaz yapar. Açık temada bu sınıflar yokmuş gibidir.scheme-light-dark, Ders 25'tekicolor-schemeözelliğinin Tailwind karşılığı: tarayıcıya "bu sayfa iki temayı da destekliyor" der. Kaydırma çubuklarını, açılır listeleri ve onay kutularını tarayıcı da temaya uygun çizer.
Windows'un temasını değiştirmeden denemek için geliştirici araçlarında Ctrl+Shift+P ile komut menüsünü aç, rendering yaz ve Show Rendering'i seç. Açılan Rendering sekmesinde Emulate CSS media feature prefers-color-scheme listesinden prefers-color-scheme: dark'ı seç (arayüz değişmiş olabilir). Sayfa anında koyulaşır.
Kural basit: renk veren her sınıfın bir koyu karşılığı olmalı. bg-white yazıp dark:bg-… yazmayı unutursan koyu temada beyaz bir kart parlar; daha kötüsü, body'den gelen açık renkli yazı beyaz kartın üstünde görünmez olur.
Düğme: durumları olan bir bileşen
Taslaktaki düğme düz yazı gibi duruyordu. Bir düğme kullanıcıya üç şey söylemeli: "bana basılabilir" (biçim ve imleç), "şu an üstümdesin" (fare ya da klavye odağı) ve "şu an basılamam" (devre dışı). Her biri bir önek. Aynı düğmeyi sayfanın birçok yerinde kullanacağın için onu baştan bir bileşen yap: proje kökünde components klasörü ve içine Dugme.tsx:
const turSiniflari = { birincil: "bg-violet-700 text-white hover:bg-violet-600", ikincil: "border border-zinc-500 bg-white text-zinc-900 hover:bg-zinc-100 dark:bg-zinc-900 dark:text-zinc-100 dark:hover:bg-zinc-800", tehlike: "bg-red-700 text-white hover:bg-red-600",} type DugmeProps = { tur?: "birincil" | "ikincil" | "tehlike" type?: "button" | "submit" disabled?: boolean onClick?: () => void children: React.ReactNode} export default function Dugme({ tur = "birincil", type = "button", disabled = false, onClick, children }: DugmeProps) { return ( <button type={type} disabled={disabled} onClick={onClick} className={`cursor-pointer rounded-lg px-4 py-2 font-semibold focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-violet-500 disabled:cursor-not-allowed disabled:opacity-50 ${turSiniflari[tur]}`} > {children} </button> )}Uzun className satırları Tailwind'de olağan. VS Code'da uzun satırları pencereye sığdırmak için View → Word Wrap (Alt+Z).
Parça parça:
turSiniflari: düğmenin üç çeşidi (birincil, ikincil, tehlike), her biri için tam yazılmış bir sınıf listesi (boşlukla ayrılmış sınıf adlarından oluşan tek bir metin).turSiniflari[tur]seçilen çeşidin metnini verir (Ders 4'teki köşeli parantezle alan okuma). Neden renk adını değişkenden kurmadığımızı birazdan bir hatayla göreceksin.ReactNode: React'in çizebildiği her şeyin tipi (Ders 40).childrendüğmenin içine yazdığın şey:<Dugme>Kaydet</Dugme>'de "Kaydet".tur = "birincil",type = "button": varsayılan parametreler.typeönemli: form içindeki bir<button>'ın türü yazılmazsasubmitsayılır ve her basışta formu gönderir. Göndermesi gereken düğmeyetype="submit"'i bilerek vereceksin.cursor-pointer: Tarayıcılar düğmenin üstünde sıradan ok imlecini gösterir; Tailwind 4 de bunu değiştirmez (Tailwind 3 değiştiriyordu). El imleci "buraya basılır" ipucunu verir.hover:bg-violet-600: fare üstündeyken bir ton açık. Tailwind 4hover:'ı yalnız fareyle gezilen cihazlarda çalıştırır; telefonda parmakla dokununca düğme açık tonda takılı kalmaz.focus-visible:outline-2 …: klavyeyle gelindiğinde 2 piksel kalınlığında, 2 piksel dışarıda, mor bir çerçeve. Ders 25'teki:focus-visibleile aynı: fareyle tıklayınca çıkmaz, Tab ile gelince çıkar.disabled:opacity-50 disabled:cursor-not-allowed:disabledolan düğme yarı saydam görünür ve üstüne gelince yasak imleci çıkar. Tarayıcı devre dışı düğmeye basılmasına zaten izin vermez; bu sınıflar bunu gözle görünür yapar.
Dört çeşidi yan yana görmek için page.tsx'te paragrafın altına geçici olarak şunu koy (en üste import Dugme from "@/components/Dugme"):
<div className="mt-6 flex flex-wrap gap-3"> <Dugme>Kaydet</Dugme> <Dugme tur="ikincil">Vazgeç</Dugme> <Dugme tur="tehlike">Sil</Dugme> <Dugme disabled>Kaydediliyor…</Dugme></div>Sayfayı yenile ve fareye dokunmadan bir kez Tab'a bas:

Fareyi düğmelerin üstünde gezdir: renkler bir ton açılıyor. Son düğmenin üstünde imleç yasak işaretine dönüyor ve Tab onu atlıyor: devre dışı düğme odak almaz. Düğmeleri gördükten sonra bu div'i sil; düğmeyi formda ve hata ekranında kullanacaksın.
Sınıf adını parça parça kurmak
Düğmeye yeni bir renk eklemek istediğini düşün: onay için yeşil. Akla gelen ilk yol, renk adını tutan bir obje ve şablon metinle kurulan bir sınıf. Önce Node'da ne ürettiğine bak. C:\kod\ogren\ders45.ts dosyasına yaz ve o klasörde node ders45.ts ile çalıştır:
const renk = "emerald"const sinif = `bg-${renk}-700`console.log(sinif)bg-emerald-700JavaScript doğru metni üretiyor. Şimdi aynı fikri bir bileşende dene:
bg-${renk}-700Ne zaman çıkar? Sınıf adının bir parçasını değişkenden, prop'tan ya da şablon metinle kurunca. Şu bileşeni components/RenkliDugme.tsx olarak yaz ve sayfada iki kez kullan: <RenkliDugme tur="birincil">Kaydet</RenkliDugme> ve <RenkliDugme tur="onay">Onayla</RenkliDugme>.
const renkler = { birincil: "violet", onay: "emerald",} type RenkliDugmeProps = { tur: "birincil" | "onay" children: React.ReactNode} export default function RenkliDugme({ tur, children }: RenkliDugmeProps) { return ( <button className={`cursor-pointer rounded-lg px-4 py-2 font-semibold text-white bg-${renkler[tur]}-700`}> {children} </button> )}Ne terminal ne tarayıcı hata verir. Ama sayfada şu çıkar:

Onayla'ya sağ tıkla, İncele de. Elements'te class="… bg-emerald-700" yazıyor, yani sınıf öğenin üstünde. Styles panelinde ise .bg-emerald-700 diye bir kural yok. Elements'te seçili öğe Console'da $0 adıyla durur; zeminine sor:
> getComputedStyle($0).backgroundColor'rgba(0, 0, 0, 0)'Saydam. Kaydet düğmesinde aynı soru 'lab(35.2783 67.9912 -88.793)' döndü: mor. Kaydet neden çalışıyor? Çünkü bg-violet-700 sözcüğü projede başka bir yerde, Dugme.tsx'te tam olarak yazılı; Tailwind onu orada görüp CSS'ini üretti. bg-emerald-700 ise hiçbir dosyada tam olarak geçmiyor; Tailwind yalnız bg-${renkler[tur]}-700 metnini okudu ve bundan bir sınıf çıkaramadı. Kodun çalışınca ne üreteceğini bilemez, çünkü kodu çalıştırmaz.
Bu yüzden bu hata sinsidir: aynı renk başka bir dosyada geçiyorsa her şey çalışır görünür, o dosyayı sildiğin gün düğme kaybolur.
Nasıl düzelir? Sınıf adını her zaman tam yaz; değişkenle yalnız hangi tam adın seçileceğini belirle. Dugme.tsx'teki turSiniflari tam olarak bunu yapıyor:
const renkler = { birincil: "bg-violet-700 hover:bg-violet-600", onay: "bg-emerald-700 hover:bg-emerald-600",}Denedikten sonra RenkliDugme.tsx'i ve sayfadaki iki satırı sil.
Kart: bir maçın bütün bilgisi
Listedeki ögeler şimdilik yalnız şampiyonun adını gösteriyor. Bir maç kartında sonuç, skor (öldürme / ölüm / asist) ve süre de olsun. components/MacKarti.tsx:
import type { Mac } from "@/lib/maclar" const sonucSiniflari = { zafer: "bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-300", bozgun: "bg-red-100 text-red-800 dark:bg-red-950 dark:text-red-300",} export default function MacKarti({ mac }: { mac: Mac }) { const zafer = mac.sonuc === "zafer" return ( <article className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900"> <div className="flex items-center justify-between gap-3"> <h2 className="text-lg font-bold">{mac.sampiyon}</h2> <span className={`rounded-full px-2.5 py-0.5 text-sm font-semibold ${sonucSiniflari[mac.sonuc]}`}> <span aria-hidden="true">{zafer ? "✓ " : "✗ "}</span> {zafer ? "Zafer" : "Bozgun"} </span> </div> <p className="mt-3 font-mono text-xl">{mac.oldurme} / {mac.olum} / {mac.asist}</p> <p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">{mac.dakika} dakika</p> </article> )}- Kartın kendisi:
rounded-xl12 piksel yuvarlak köşe,border border-zinc-200ince açık gri kenar,shadow-smhafif gölge,p-4iç boşluk. Koyu temada kenar ve zemin koyulaşıyor. - Üst satır:
flex items-center justify-between(Ders 21): ad solda, rozet sağda, ikisi dikeyde ortalı.gap-3ad uzarsa ikisinin birbirine yapışmasını önler. - Rozet:
rounded-fullhap biçimi,px-2.5 py-0.5küçük bir iç boşluk;2.5gibi buçuklu sayılar da ölçekte var (10 piksel). Renkler yine tam yazılmış sınıf listelerinden geliyor. aria-hidden="true": ✓ ve ✗ işaretleri gözle bakan için. Ekran okuyucu onları "onay işareti" diye okumasın; zaten hemen yanında "Zafer" yazıyor. Bu küçük nitelik bu dersin ARIA bölümünde yeniden karşına çıkacak.font-mono: skoru eş genişlikli yazı tipiyle yazar; rakamlar alt alta kartlarda hizalı durur.
page.tsx'te listeyi kartlara çevir: <li>'nin sınıflarını sil, içine kartı koy (en üste import MacKarti from "@/components/MacKarti"):
import MacKarti from "@/components/MacKarti"import { maclar } from "@/lib/maclar" export default function Home() { return ( <main className="mx-auto max-w-5xl px-4 py-8"> <h1 className="text-3xl font-bold">Maç geçmişi</h1> <p className="mt-1 text-zinc-600 dark:text-zinc-400">Son maçlarına bak, yenisini ekle.</p> <ul className="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> {maclar.map((mac) => ( <li key={mac.id}> <MacKarti mac={mac} /> </li> ))} </ul> </main> )}Telefon genişliğinde (390 px) kartlar alt alta, dizüstü genişliğinde (1280 px) üçerli dizilir:


Koyu temada da kontrol et (Rendering sekmesi ya da Windows ayarı):

Form: etiket, girdi ve hata mesajı
Formun bir girdi kutusu var ve kullanıcı ona yazınca state değişecek; bu yüzden form bir istemci bileşeni olur. components/YeniMacFormu.tsx:
"use client" import { useState } from "react"import Dugme from "@/components/Dugme" export default function YeniMacFormu() { const [sampiyon, setSampiyon] = useState("") const [hata, setHata] = useState("") const [mesaj, setMesaj] = useState("") function gonder(olay: React.SubmitEvent<HTMLFormElement>) { olay.preventDefault() if (sampiyon.trim() === "") { setHata("Şampiyonun adını yaz.") setMesaj("") return } setHata("") setMesaj(`${sampiyon} maçı kaydedildi.`) setSampiyon("") } return ( <form id="yeni-mac" onSubmit={gonder} noValidate className="mt-10 max-w-md rounded-xl border border-zinc-200 bg-white p-6 dark:border-zinc-800 dark:bg-zinc-900"> <h2 className="text-xl font-bold">Yeni maç</h2> <label htmlFor="sampiyon" className="mt-4 block font-medium">Şampiyon</label> <input id="sampiyon" value={sampiyon} onChange={(olay) => setSampiyon(olay.target.value)} placeholder="Örn. Ahri" required aria-invalid={hata !== ""} aria-describedby={hata ? "sampiyon-hata" : undefined} className={`mt-1 block w-full rounded-lg border bg-white px-3 py-2 placeholder:text-zinc-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-violet-500 dark:bg-zinc-950 dark:placeholder:text-zinc-400 ${hata ? "border-red-700 dark:border-red-400" : "border-zinc-500"}`} /> {hata && ( <p id="sampiyon-hata" role="alert" className="mt-1 text-sm text-red-700 dark:text-red-400"> {hata} </p> )} <label htmlFor="sonuc" className="mt-4 block font-medium">Sonuç</label> <select id="sonuc" className="mt-1 block w-full rounded-lg border border-zinc-500 bg-white px-3 py-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-violet-500 dark:bg-zinc-950"> <option value="zafer">Zafer</option> <option value="bozgun">Bozgun</option> </select> <div className="mt-6 flex items-center gap-3"> <Dugme type="submit">Kaydet</Dugme> <p role="status" className="text-sm text-emerald-700 dark:text-emerald-400">{mesaj}</p> </div> </form> )}Mantık Ders 41'deki kontrollü girdiyle aynı; yeni olan görünüm ve erişilebilirlik ayrıntıları:
- Etiket: Her kutunun gerçek bir
<label htmlFor>'u var (JSX'teforyerinehtmlFor, Ders 40).blocketiketi kendi satırına alır; etikete tıklayınca imleç kutuya gider. - Kutu: Preflight kutunun kenarını sıfırlamıştı;
borderkenarı geri getirir,w-fullkutuyu formun genişliğine yayar. Kenar rengizinc-500: Ders 25'teki kurala göre bir girdi kutusunun kenarı zeminle en az 3:1 kontrast taşımalı. Beyaz zemindezinc-5004,83:1, daha açıkzinc-400ise 2,62:1'de kalır. placeholder:text-zinc-500 dark:placeholder:text-zinc-400:placeholder:öneki, kutu boşken görünen silik yazıyı boyar. İpucu yazısının da okunabilir olması gerekir. Koyu temada bir ton açığını seçtik:dark:placeholder:iki önek üst üste, "koyu temadaysa ve ipucu yazısıysa" demek. Nedenini kontrast bölümünde ölçeceksin.- Hata hâli: Kenar rengi hataya göre değişiyor; dikkat et, iki renk de tam ad.
border-zinc-500ileborder-red-700'ü aynı anda yazmak yerine biri seçiliyor: aynı özelliği ayarlayan iki sınıf bir arada olursa hangisinin kazanacağıclassName'deki sıraya değil, Tailwind'in ürettiği CSS dosyasındaki sıraya bağlıdır. aria-invalidvearia-describedby: İlki ekran okuyucuya "bu kutunun değeri geçersiz" der. İkincisi kutuyu altındaki hata paragrafına bağlar: kullanıcı kutuya geldiğinde ekran okuyucu etiketin ardından "Şampiyonun adını yaz." cümlesini de okur. HTML'de bu bağı kuran başka bir yol yok; ARIA'nın gerçekten gerektiği yerlerden biri.role="alert"verole="status": Ders 23–25'te gördüğün gibi: hata çıkınca hemen okunur, başarı mesajı değişince sırası gelince okunur.requiredvenoValidate:required(Ders 23) kutunun zorunlu olduğunu ekran okuyucuya da söyler; kutuya gelen kullanıcı "gerekli" sözcüğünü duyar.noValidateise tarayıcının kendi doğrulama balonunu kapatır: boş gönderimde tarayıcının "Lütfen bu alanı doldurun." (İngilizce tarayıcıda "Please fill out this field.") balonu yerine bizim tasarladığımız hata cümlesi çıkar vegonderher basışta çalışır.
Aynı üç odak sınıfı girdi kutusunda, açılır listede, düğmede ve birazdan yazacağın bağlantıda tekrar ediyor. Görev 3'te onları tek bir sabite toplayacaksın.
Formu sayfaya koy ve başlığın yanına forma götüren bir "Maç ekle" bağlantısı ekle. page.tsx'in son hâli:
import MacKarti from "@/components/MacKarti"import YeniMacFormu from "@/components/YeniMacFormu"import { maclar } from "@/lib/maclar" export default function Home() { return ( <main className="mx-auto max-w-5xl px-4 py-8"> <div className="flex flex-wrap items-center justify-between gap-4"> <div> <h1 className="text-3xl font-bold">Maç geçmişi</h1> <p className="mt-1 text-zinc-600 dark:text-zinc-400">Son maçlarına bak, yenisini ekle.</p> </div> <a href="#yeni-mac" className="rounded-lg bg-violet-700 px-4 py-2 font-semibold text-white hover:bg-violet-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-violet-500"> Maç ekle </a> </div> <ul className="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> {maclar.map((mac) => ( <li key={mac.id}> <MacKarti mac={mac} /> </li> ))} </ul> <YeniMacFormu /> </main> )}"Maç ekle" düğmeye benziyor ama bir bağlantı (<a>): sayfada bir yere götürüyor, bir iş yapmıyor. Kural: götüren şey bağlantı, iş yapan şey düğme. Ekran okuyucu ikisini farklı duyurur, klavyede Enter ikisinde de çalışır ama Space yalnız düğmede çalışır. Görünüş aynı olabilir; etiket doğru olmalı. #yeni-mac adresi sayfayı id="yeni-mac" olan forma kaydırır (Ders 19'daki sayfa içi bağlantı).
Boş formla Kaydet'e bas:

Hata yalnız kırmızı renkle değil, bir cümleyle de anlatılıyor. Bunun neden önemli olduğunu az sonra göreceksin.
Erişilebilirlik: dört denetim
Erişilebilirlik temellerini Ders 19'da, kontrol listesini Ders 25'te gördün. Tailwind ve React'te kurallar değişmez; yalnız onları bozmanın yeni yolları çıkar. Her bileşeni bitirdiğinde dört soruyu sor: her şeyin bir adı var mı, odak görünüyor mu, kontrast yetiyor mu, klavyeyle her yere ulaşılıyor mu?
Etiket: her şeyin bir adı olsun
Ekran okuyucu bir kutuya ya da düğmeye geldiğinde onun adını okur. Ad nereden gelir?
- Girdi kutusunda: ona bağlı
<label>.placeholderetiket değildir: kullanıcı yazmaya başlayınca kaybolur, kutuya ne yazdığını unutan biri geri dönüp bakamaz. - Düğmede ve bağlantıda: içindeki yazı. "Kaydet" yazan düğmenin adı "Kaydet".
- Yalnız simgeli bir düğmede ad yoktur. Adı ya
aria-labelile ya da gözle görünmeyen bir yazıyla verirsin:
<button type="button" aria-label="Aramayı temizle"> <svg aria-hidden="true" viewBox="0 0 24 24">…</svg></button> <button type="button"> <span className="sr-only">Aramayı temizle</span> <svg aria-hidden="true" viewBox="0 0 24 24">…</svg></button>sr-only Tailwind'in "yalnız ekran okuyucu için" sınıfı: yazıyı sayfada tutar ama 1×1 piksellik görünmez bir kutuya sıkıştırır. Gözle bakan görmez, ekran okuyucu okur. İki yol da aynı adı verir. Simgedeki aria-hidden="true" ise SVG'nin ayrıca okunmasını önler.
Resimlerde ad, alt metnidir (Ders 19). Bilgi taşıyan resme ne gösterdiğini yaz; yalnız süs olan resme alt="" ver ki ekran okuyucu atlasın. Ders 43'teki next/image'ın Image bileşeni alt'ı zorunlu tutar; yazmazsan tsc Property 'alt' is missing diye uyarır.
Odak: nerede olduğunu göster
Fareyi kullanamayan biri sayfada Tab ile gezer. Odak çerçevesi onun fare imlecidir. Bizim sayfamızda Tab sırası şöyle çıktı:
- "Maç ekle" bağlantısı
- Şampiyon kutusu
- Sonuç listesi
- Kaydet düğmesi
Geliştirme sunucusunda (npm run dev) beşinci Tab ekranın sol alt köşesindeki yuvarlak N düğmesine gider: o Next'in geliştirici aracı, yayın hâlinde (npm start) yoktur. Kartlarda tıklanacak bir şey yok; o yüzden Tab onları atlıyor. Sıra, öğelerin HTML'deki sırası; görsel olarak yerlerini değiştirsen bile (ör. flex-row-reverse) Tab HTML sırasını izler.
outline-noneNe zaman çıkar? İnternetteki hazır düğme örneklerinin çoğunda focus:outline-none ya da outline-none yazar: tarayıcının odak çerçevesi "çirkin" bulunup kapatılır. Dugme'deki üç focus-visible: sınıfını silip yerine outline-none yazarsan formda Tab'a dört kez bastığında şunu görürsün:

outline-none: odak Kaydet'te ama hiçbir iz yok. Altta bizim düğmemiz.Hata mesajı yok; Lighthouse da bunu yakalamaz. Fareyle gezen biri hiçbir şey fark etmez. Klavyeyle gezen biri ise Enter'a basınca neyin olacağını bilemez: imleci görünmeyen bir fareyle tıklamak gibi.
Nasıl düzelir? Çerçeveyi silme, kendi tarzında çiz: focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-violet-500. Tarayıcının çerçevesini illa gizlemen gerekiyorsa (ör. odağı başka bir yolla, kalın bir gölgeyle gösterdiğinde) outline-none yerine outline-hidden kullan: çerçeveyi normalde gizler ama Windows'un yüksek karşıtlık temalarında (Ayarlar → Erişilebilirlik → Karşıtlık temaları; İngilizce Windows'ta Settings → Accessibility → Contrast themes) yine çizer. O temalarda tarayıcı gölgeleri siler, renkleri de sistemin renkleriyle değiştirir; geriye odağın tek ipucu olarak çerçeve kalır.
Kontrast: okunabiliyor mu?
Ders 20'de normal yazı için en az 4,5:1, Ders 25'te kutu kenarları için en az 3:1 sınırını gördün. Bir ayrıntı daha: büyük yazıda (24 piksel ve üstü ya da yaklaşık 18,7 piksel ve üstü kalın) da 3:1 yeter, çünkü iri harf daha kolay seçilir. Bu derste kullandığımız renkleri, sayfada üstünde durdukları zeminle ölçtük:
| Yazı | Zemin | Oran | Sonuç |
|---|---|---|---|
zinc-900 (ana yazı) | zinc-50 (sayfa) | 16,97:1 | Geçer |
zinc-600 (açıklama) | zinc-50 (sayfa) | 7,40:1 | Geçer |
zinc-400 (koyu temada açıklama) | zinc-950 (koyu sayfa) | 7,59:1 | Geçer |
| beyaz | violet-700 (düğme) | 7,30:1 | Geçer |
| beyaz | violet-600 (düğmenin üstündeyken) | 5,89:1 | Geçer |
emerald-800 | emerald-100 (Zafer rozeti) | 6,78:1 | Geçer |
red-800 | red-100 (Bozgun rozeti) | 6,85:1 | Geçer |
zinc-400 (açık temada açıklama) | zinc-50 (sayfa) | 2,51:1 | Kalır |
zinc-500 (koyu temada ipucu yazısı) | zinc-950 (koyu kutu) | 4,12:1 | Kalır |
Son iki satıra dikkat: aynı zinc-400, koyu zeminde rahatça geçerken açık zeminde kalıyor. Beyaz kutuda 4,83:1 ile geçen zinc-500 ise koyu kutuda kalıyor; formdaki ipucu yazısına koyu tema için dark:placeholder:text-zinc-400 (7,59:1) vermemizin sebebi bu. Kontrast bir rengin değil, bir çiftin özelliğidir; her rengi iki temada da kendi zeminiyle ölç. Devre dışı düğme (yarı saydam) bu kuralın dışındadır; basılamadığı için okunması beklenmez, ama yine de ne yazdığı ayırt edilebilmeli.
Ölçmek için Ders 20'deki yolu kullan: yazıya sağ tıkla, İncele de, Styles'ta renk karesine tıkla; renk seçicideki Contrast ratio satırı oranı gösterir.
Klavye: her şeye ulaşılıyor mu?
Fareyi bırak ve sayfayı yalnız klavyeyle kullan: Tab ile gez, Enter ve Space ile bas, açılır listede ok tuşlarıyla seç. Bir yere ulaşamıyorsan ya da odak bir yerde kayboluyorsa sorun var.
En sık sebep, tıklanacak şeyi div ile yapmaktır: <div onClick={…}>Sil</div>. Fareyle mükemmel çalışır. Ama div odak almaz; Tab onun üstünde durmaz, Enter ona ulaşmaz, ekran okuyucu onun bir düğme olduğunu bilmez. Ne ESLint ne Lighthouse bunu yakalar; Görev 3'te kendin göreceksin. Çözüm her zaman aynı: iş yapan şey <button type="button">, götüren şey <a href> ya da Next'in Link'i. İkisi de klavyeyi, odağı ve ekran okuyucu adını hazır getirir. Görünüşü Tailwind'le istediğin gibi değiştirebilirsin.
Bilgiyi yalnız renkle verme
Ne zaman çıkar? Bir durumu yalnız renk farkıyla anlatınca. Örneğin kartta "Zafer/Bozgun" yazısını kaldırıp sonucu yalnız kartın sol kenarının rengiyle göstermek:
<article className={`rounded-xl border-l-8 bg-white p-4 shadow-sm ${zafer ? "border-emerald-600" : "border-red-600"}`}>Erkeklerin yaklaşık 12'de birinde bir tür renk körlüğü var; en yaygını kırmızıyla yeşili ayırt edememek. Chrome bunu gösterebiliyor: Rendering sekmesinde Emulate vision deficiencies listesinden Achromatopsia (no color) (hiç renk görmeyen) ya da Deuteranopia (no green) seç. Kartlarımızdan ikisi (Ahri kazandı, Jinx kaybetti) şöyle göründü:

Deuteranopia'da yeşil kenar gri-zeytine, kırmızı kenar koyu sarıya döndü: iki kart yan yana dururken fark seçiliyor, ama tek bir karta bakan hangisinin kazandığını renkten çıkaramaz. Hiç renk görmeyen için iki kenar neredeyse aynı gri. Hata mesajı yok, Lighthouse da uyarmıyor. Formdaki kırmızı kenar da aynı tuzak olurdu; o yüzden altına bir hata cümlesi yazdık.
Nasıl düzelir? Rengi bırakma, yanına ikinci bir işaret ekle: yazı ("Zafer"), simge (✓ ✗), biçim (dolu ve boş yıldız) ya da desen. Renk görenler için hız, görmeyenler için bilgi.
ARIA: ne zaman, ne zaman değil
Bu derste birkaç aria- niteliği kullandın. Hepsi aynı ailenin üyesi:
ARIA güçlüdür ve tam bu yüzden tehlikelidir: yanlış ARIA, hiç ARIA olmamasından kötüdür. W3C'nin ARIA kullanım notlarındaki ilk kural şu: doğru HTML öğesi varsa onu kullan, ARIA ekleme.
| Yanlış | Neden | Doğru |
|---|---|---|
<div role="button" tabIndex={0} onClick={…}> | Klavyeyle basma, odak, disabled hepsini elle yazman gerekir | <button type="button"> |
<span role="heading">Maçlar</span> | Başlık sırası ve stil kayıp | <h2>Maçlar</h2> |
<button aria-label="düğme">Kaydet</button> | aria-label görünen yazıyı ezer; ekran okuyucu "düğme" der | <button>Kaydet</button> |
<div aria-hidden="true"> içinde bir <button> | Düğme klavyeyle odak alır ama ekran okuyucu için yoktur | Gizleyeceksen tamamen gizle ya da hiç gizleme |
Her şeye aria-label | Görünen yazıyla duyulan yazı ayrışır | Önce görünen yazıyı düzelt |
Gerçekten gerektiği yerler ise bu derste gördüklerin: yalnız simgeli düğmenin adı (aria-label), hatayı kutuya bağlamak (aria-describedby, aria-invalid), değişen mesajı duyurmak (role="alert", role="status", aria-live), aç-kapa düğmesinin durumu (aria-pressed) ve süs öğesini gizlemek (aria-hidden). Bir de menüde o an açık olan sayfanın bağlantısı: Ders 43'ün menü görevinde onu yalnız beyaz renkle (.aktif) ayırmıştın; bağlantıya aria-current="page" eklersen ekran okuyucu "geçerli sayfa" der. Bu da "bilgiyi yalnız renkle verme" kuralının ARIA'lı hâli.
aria-hiden: This attribute is an invalid ARIA attribute.Ne zaman çıkar? Bir ARIA niteliğinin adını yanlış yazınca. MacKarti.tsx'te aria-hidden'ı aria-hiden yaz ve npm run lint çalıştır:
PS C:\kod\stil-atolyesi> npm run lint > stil-atolyesi@0.1.0 lint> eslint C:\kod\stil-atolyesi\components\MacKarti.tsx 15:27 warning aria-hiden: This attribute is an invalid ARIA attribute. Did you mean to use aria-hidden? jsx-a11y/aria-props ✖ 1 problem (0 errors, 1 warning)"aria-hiden geçersiz bir ARIA niteliği. aria-hidden mı demek istedin?" Tarayıcı tanımadığı niteliği sessizce yok sayar; ✓ işareti yine okunur. TypeScript de yakalamaz: JSX, içinde tire olan her nitelik adını kabul eder; npx tsc --noEmit bu dosyada hiçbir şey söylemedi. Yakalayan, create-next-app'in kurduğu ESLint'in erişilebilirlik kuralları (jsx-a11y). 15:27 15. satırın 27. sütunu demek.
Nasıl düzelir? Adı düzelt: aria-hidden. ARIA nitelikleri yazdığın her projede commit'ten önce npm run lint çalıştırmayı alışkanlık edin.
Lighthouse ile ölçmek
Şimdiye kadar gözle ve klavyeyle denetledin. Bir de makineye ölçtür. Lighthouse, Chrome'un (Edge ve Brave'de de var) geliştirici araçlarına gömülü bir denetçi: sayfayı yükler, onlarca kurala karşı sınar ve her kategoriye 0 ile 100 arası bir puan verir.
- Yayın hâlini çalıştır. Hız puanı geliştirme sunucusunda anlamsızdır; Next orada her şeyi yavaş, denetimli derler. Sunucuyu Ctrl+C ile durdur,
npm run buildve ardındannpm startyaz. Sayfa yinehttp://localhost:3000'de açılır. - Gizli pencere aç. Ctrl+Shift+N. Tarayıcı eklentileri sayfaya kod ekleyip sonucu bozabilir; gizli pencerede çoğu eklenti çalışmaz.
- Lighthouse sekmesini bul. F12, üstteki sekmelerden Lighthouse. Görünmüyorsa sekmelerin sağındaki » menüsünde.
- Ayarları seç. Mode: Navigation (sayfayı baştan yükleyerek ölçer). Device: Mobile (telefon gibi yavaş bir bağlantı ve küçük ekran taklit eder) ya da Desktop. Categories: dördü de işaretli kalsın.
- Çalıştır. Analyze page load düğmesine bas ve 30–60 saniye bekle. Türkçe arayüzde adlar çevrilmiş olabilir; Chrome sürümüne göre düğmelerin yeri değişmiş olabilir.
Rapor en üstte dört daire gösterir. Her biri bir kategori; renk, puanın hangi aralıkta olduğunu söyler:
Erişilebilirlik puanında yarım not yok: bir kural ya geçilir ya kalınır. Bir sayfada on düğmeden biri adsızsa "düğmelerin adı var" kuralı tümüyle kalır. Her kuralın bir ağırlığı vardır; ağır kurallardan kalmak puanı daha çok düşürür. Yeni Chrome sürümlerinde beşinci bir kategori, Agentic Browsing (Türkçesi Ajan Tabanlı Tarama: yapay zekâ ajanlarının sayfayı ne kadar rahat kullanabildiği) görebilirsin; Lighthouse onu hâlâ geliştirme aşamasında diye işaretliyor, bu derste ona bakmıyoruz.
Bilerek bozup ölçmek
Sayfamız iki temada da dört kategoride 99–100 aldı (Performans 99, diğerleri 100). Neyi yakaladığını görmek için üç şeyi bilerek boz: layout.tsx'te lang="tr"'yi sil, page.tsx'te açıklamanın sınıfını yalnız text-zinc-400 yap, formda Şampiyon'un <label> satırını sil. Yeniden derleyip ölçtüğümüzde:
| Sürüm | Açık tema | Koyu tema | Lighthouse'un bulduğu |
|---|---|---|---|
| Düzgün sayfa | 100 | 100 | — |
| Üç bozma | 92 | 96 | açıkta: kontrast ve lang; koyuda: yalnız lang |
Üç bozma + placeholder da silindi | 86 | 90 | yukarıdakiler ve etiket |
Kalınan kuralların raporda göründüğü adlar (Türkçe arayüzde ikincisi):
| İngilizce | Türkçe |
|---|---|
| Background and foreground colors do not have a sufficient contrast ratio. | Arka plan ve ön plan renkleri yeterli kontrast oranına sahip değil. |
<html> element does not have a [lang] attribute | <html> öğesinin [lang] özelliği yok |
| Form elements do not have associated labels | Form öğelerinin ilişkili etiketleri yok |
Her satırı açınca sorunlu öğe ve açıklaması çıkar. Kontrastta Lighthouse oranı da yazdı: Element has insufficient color contrast of 2.51 (foreground color: #9f9fa9, background color: #fafafa …). Expected contrast ratio of 4.5:1. Tablodaki 2,51 ile aynı.
Bu tablodan üç ders çıkıyor:
- Lighthouse, sayfayı bilgisayarının o anki temasında ölçer. Windows'un koyu temadaysa açık temadaki kontrast hatasını görmez. Önemli sayfaları iki temada da ölç: Windows'un modunu değiştirip Lighthouse'u yeniden çalıştır.
- Lighthouse
placeholder'ı ad sayar. Etiketi sildik, Lighthouse sustu; çünkü kutunun hâlâ "Örn. Ahri" diye bir adı var. Gözle bakan kullanıcı ise yazmaya başlayınca kutunun ne için olduğunu göremez. Kuralı geçmek, sorunu çözmek değil. - 100 puan "erişilebilir" demek değildir. Odak çerçevesini silmek,
div'eonClickvermek, bilgiyi yalnız renkle vermek bu derste hiçbir puanı düşürmedi. Erişilebilirlik bölümünün altında Additional items to manually check (Türkçe arayüzde Manuel olarak kontrol edilecek ek öğeler) başlığıyla on maddelik bir liste var: etkileşimli öğeler klavyeyle odaklanabiliyor mu, sekme sırası mantıklı mı, odak bir yerde tuzağa düşüyor mu… Puanı etkilemezler; onları sen denetlersin.
Boş, yükleniyor ve hata durumları
Ders 24'te bir sayfanın dört durumunu gördün: yükleniyor, dolu, boş, hata. Orada her durum tek satırlık bir yazıydı. İyi bir arayüz bundan fazlasını yapar: her durumda kullanıcıya ne olduğunu ve ne yapabileceğini söyler. Bu bölümde maçları bir "veritabanından" bekleyen sayfa yapacak ve üç durumu tasarlayacaksın.
Önce hazırlık. Başlık üç ekranda da (sayfa, yükleniyor, hata) aynı kalacak; üç yere kopyalamak yerine bir bileşene taşı. components/SayfaBasi.tsx:
export default function SayfaBasi() { return ( <div className="flex flex-wrap items-center justify-between gap-4"> <div> <h1 className="text-3xl font-bold">Maç geçmişi</h1> <p className="mt-1 text-zinc-600 dark:text-zinc-400">Son maçlarına bak, yenisini ekle.</p> </div> <a href="#yeni-mac" className="rounded-lg bg-violet-700 px-4 py-2 font-semibold text-white hover:bg-violet-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-violet-500"> Maç ekle </a> </div> )}Sonra lib/maclar.ts'in sonuna, veritabanını taklit eden bir fonksiyon ekle. Fonksiyonun ilk satırı Ders 44'teki bekle ile aynı işi görür: bir buçuk saniye bekler. Ardından verilen durum'a göre hata fırlatır ya da boş liste döndürür; böylece üç durumu da istediğin an görebileceksin:
export async function maclariGetir(durum?: string) { await new Promise((coz) => setTimeout(coz, 1500)) if (durum === "hata") { throw new Error("Veritabanına bağlanılamadı") } if (durum === "bos") { return [] } return maclar}durum'u adresten okuyacağız: localhost:3000/?durum=bos gibi. Adresin ?'ten sonraki kısmını (Ders 18) Next sayfaya searchParams prop'uyla verir. Ders 43'teki params gibi bu da bir Promise; await ile açarsın. page.tsx'in son hâli:
import BosDurum from "@/components/BosDurum"import MacKarti from "@/components/MacKarti"import SayfaBasi from "@/components/SayfaBasi"import YeniMacFormu from "@/components/YeniMacFormu"import { maclariGetir } from "@/lib/maclar" export default async function Home({ searchParams }: PageProps<"/">) { const { durum } = await searchParams const liste = await maclariGetir(typeof durum === "string" ? durum : undefined) return ( <main className="mx-auto max-w-5xl px-4 py-8"> <SayfaBasi /> {liste.length === 0 ? ( <BosDurum /> ) : ( <ul className="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> {liste.map((mac) => ( <li key={mac.id}> <MacKarti mac={mac} /> </li> ))} </ul> )} <YeniMacFormu /> </main> )}PageProps<"/">: Next'in senin için ürettiği tip; içindesearchParams: Promise<Record<string, string | string[] | undefined>>var.?durum=bos&durum=hatagibi aynı ad iki kez yazılırsa değer dizi olur, hiç yazılmazsaundefined.typeof durum === "string": daraltma. Yalnız düz metinse fonksiyona veriyoruz.BosDurum,loading.tsxveerror.tsx'i aşağıda yazacaksın.BosDurum.tsxdosyası yokken sayfayı açarsan Next onu bulamadığı için hata gösterir; üçünü de yazdıktan sonra dene.
Yükleniyor: iskelet
Sayfa artık 1,5 saniye bekliyor. Bu sürede ne görünecek? Ders 44'te loading.tsx'e bir "yükleniyor" yazısı ve üç gri kutu koymuştun. Yalnız yazı iki kusur taşır: kullanıcı ne geleceğini bilmez ve veri gelince sayfa birden zıplar. Gri kutular ikisini de çözebilir, ama yalnız gelecek içerikle aynı boyda olurlarsa. Bu bölümde iskeleti ölçüp tasarlayacaksın.
app/loading.tsx:
import SayfaBasi from "@/components/SayfaBasi" export default function Yukleniyor() { return ( <main className="mx-auto max-w-5xl px-4 py-8"> <SayfaBasi /> <p role="status" className="sr-only">Maçlar yükleniyor…</p> <ul aria-hidden="true" className="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> {[1, 2, 3].map((n) => ( <li key={n} className="rounded-xl border border-zinc-200 bg-white p-4 dark:border-zinc-800 dark:bg-zinc-900"> <div className="animate-pulse motion-reduce:animate-none"> <div className="flex items-center justify-between"> <div className="h-7 w-24 rounded bg-zinc-200 dark:bg-zinc-700" /> <div className="h-6 w-16 rounded-full bg-zinc-200 dark:bg-zinc-700" /> </div> <div className="mt-3 h-7 w-32 rounded bg-zinc-200 dark:bg-zinc-700" /> <div className="mt-1 h-5 w-20 rounded bg-zinc-200 dark:bg-zinc-700" /> </div> </li> ))} </ul> </main> )}- Aynı iskelet: Başlık gerçek
SayfaBasi; zaten bildiğimiz şeyi gri kutuyla göstermeye gerek yok. Kartlar gerçek kartın kabuğu (kenar, zemin,p-4), içleri gri çubuklar. Çubukların yükseklikleri gerçek satırların yüksekliğine göre seçildi:h-7(28 piksel)text-lgbaşlığın,h-5(20 piksel)text-smsürenin satır yüksekliği. Ölçtük: iskelet kartı da gerçek kart da 126 piksel yüksekliğinde. Veri gelince hiçbir şey kaymıyor. animate-pulse: Tailwind'in hazır animasyonu; kutular yavaşça solup koyulaşır, "bir şey oluyor" der.motion-reduce:animate-noneise Windows'ta Ayarlar → Erişilebilirlik → Görsel efektler → Animasyon efektleri'ni (İngilizce Windows'ta Settings → Accessibility → Visual effects → Animation effects) kapatan kullanıcılarda animasyonu durdurur; bunu genelde hareket görünce başı dönen ya da dikkati dağılan kişiler kapatır. Bu tercihi tarayıcıprefers-reduced-motionile bildirir; Rendering sekmesinden de taklit edebilirsin.- Ekran okuyucu için: Gri kutuların okunacak bir şeyi yok;
aria-hidden="true"onları gizler. Yerinesr-onlybirrole="status"paragrafı "Maçlar yükleniyor…" der.
Sayfayı yenile:

Lighthouse bu sayfaya Performans'tan 99 ile 100 arası puan verdi; 1,5 saniyelik beklemeye rağmen. Çünkü sayfanın ilk görünen hâli (başlık ve iskelet) beklemeden geliyor ve sonra hiçbir şey kaymıyor.
Boş durum: yol göster
localhost:3000/?durum=bos'u aç. Liste boş olunca kullanıcıya boş bir alan değil, bir sonraki adımı göster.
components/BosDurum.tsx:
export default function BosDurum() { return ( <div className="mt-6 rounded-xl border-2 border-dashed border-zinc-300 px-6 py-12 text-center dark:border-zinc-700"> <svg aria-hidden="true" viewBox="0 0 24 24" className="mx-auto size-12 text-zinc-500" fill="none" stroke="currentColor" strokeWidth="1.5"> <rect x="4" y="3" width="16" height="18" rx="2" /> <path d="M8 8h8M8 12h8M8 16h5" /> </svg> <h2 className="mt-4 text-lg font-bold">Henüz maç yok</h2> <p className="mt-1 text-zinc-600 dark:text-zinc-400">Oynadığın maçları ekledikçe burada kart kart görünecek.</p> <a href="#yeni-mac" className="mt-6 inline-block rounded-lg bg-violet-700 px-4 py-2 font-semibold text-white hover:bg-violet-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-violet-500"> İlk maçını ekle </a> </div> )}border-dashed kesik kenar "burası henüz boş bir yer" der. size-12 simgenin hem genişliğini hem yüksekliğini 48 piksel yapar; stroke="currentColor" SVG'nin çizgisini text-zinc-500'ün rengine bağlar. Simge süs; adı yok, aria-hidden'la gizli.

İki çeşit boşluğu karıştırma. "Hiç maç yok" ile "aramana uyan maç yok" farklı durumlardır: ilkinde "İlk maçını ekle" dersin, ikincisinde "Filtreyi temizle". İkisine aynı ekranı gösterirsen, filtresi açık kalmış kullanıcı bütün maçlarının silindiğini sanır.
Hata durumu: ne oldu, ne yapmalı?
localhost:3000/?durum=hata'yı aç. maclariGetir bir hata fırlatıyor; Next sayfanın yerine error.tsx'i çizer. Ders 44'teki gibi bu dosya bir istemci bileşenidir ve retry fonksiyonunu prop olarak alır: çağırınca Next sayfanın verisini sunucudan yeniden ister ve çizmeyi bir kez daha dener. Bu sefer error prop'unu almıyoruz; ekranda teknik ayrıntı göstermeyeceğiz.
"use client" import Dugme from "@/components/Dugme"import SayfaBasi from "@/components/SayfaBasi" export default function HataSayfasi({ retry }: { retry: () => void }) { return ( <main className="mx-auto max-w-5xl px-4 py-8"> <SayfaBasi /> <div role="alert" className="mt-6 rounded-xl border border-red-300 bg-red-50 p-6 dark:border-red-900 dark:bg-red-950"> <h2 className="text-lg font-bold text-red-800 dark:text-red-200">Maçlar yüklenemedi</h2> <p className="mt-1 text-red-800 dark:text-red-200">Sunucuya şu an ulaşamıyoruz. Bilgilerin silinmedi; birazdan yeniden dene.</p> <div className="mt-4"> <Dugme onClick={() => retry()}>Yeniden dene</Dugme> </div> </div> </main> )}
İyi bir hata ekranının dört özelliği:
- Sayfa çökmüş gibi görünmez. Başlık ve "Maç ekle" bağlantısı yerinde; kullanıcı nerede olduğunu biliyor.
- Ne olduğunu insan diliyle söyler. "Maçlar yüklenemedi",
Error: ECONNREFUSEDdeğil. - Kaygıyı giderir. "Bilgilerin silinmedi" cümlesi, maç kayıtlarının kaybolduğunu sanan birini sakinleştirir.
- Bir çıkış yolu sunar. "Yeniden dene" düğmesi. Sorun geçiciyse tek basış yeter.
Teknik ayrıntıyı kullanıcıya göstermiyoruz. Yayın hâlinde Next zaten göstermez: sunucu bileşeninde fırlatılan hatanın mesajı tarayıcıya hiç gitmez. Bu denemeyi npm run build ve npm start ile yaptık; tarayıcının konsolunda yalnız kısaltılmış bir React satırı çıktı, gerçek hata sunucunun terminalindeydi:
PS C:\kod\stil-atolyesi> npm start...⨯ Error: Veritabanına bağlanılamadı at i (C:\kod\stil-atolyesi\.next\server\chunks\ssr\_1fsjxgo00dktp._.js:1:2254) ... { digest: '3517635533'}Error: Minified React error #441; visit https://react.dev/errors/441 for the full message or use the non-minified dev environment for full errors and additional helpful warnings.digest hatanın kimliği: kullanıcı "bir hata aldım" dediğinde terminaldeki ya da barındırıcının loglarındaki (Ders 32) satırı bu sayıyla bulursun. Geliştirme sunucusunda (npm run dev) ise Next, hatayı ekranın köşesindeki kırmızı N düğmesinde de gösterir; o yalnız senin içindir.
Görevler
Görevleri C:\kod\stil-atolyesi projesinde yap. Her görev yeni bir sayfa ya da bileşen; dersin dosyalarını bozmadan yan yana durabilirler. Bitirince sayfayı hem açık hem koyu temada, hem telefon hem bilgisayar genişliğinde aç.
Rol rozetleri
Şampiyonların rolü için renkli rozetler istiyoruz. Önce mantığı Node'da kur: C:\kod\ogren\ders45.ts dosyasında rolSinifi(rol: string) adında bir fonksiyon yaz. Tank için bg-sky-100 text-sky-800, Suikastçı için bg-red-100 text-red-800, Büyücü için bg-violet-100 text-violet-800, Nişancı için bg-amber-100 text-amber-800, Destek için bg-emerald-100 text-emerald-800 döndürsün; tanımadığı her rol için gri bg-zinc-100 text-zinc-800. Fonksiyonu sırasıyla "Büyücü", "Nişancı", "Orman", "tank" (küçük harf) ve "" (boş metin) ile çağırıp her sonucu console.log ile yazdır. Beklenen çıktı:
bg-violet-100 text-violet-800bg-amber-100 text-amber-800bg-zinc-100 text-zinc-800bg-zinc-100 text-zinc-800bg-zinc-100 text-zinc-800Sonra fonksiyonu export ile projeye, lib/roller.ts'e taşı ve rolü bir hap biçimli rozette gösteren bir RolRozeti bileşeninde kullan.
İpucu 1
Rolden sınıfa giden bir obje kur. Anahtarlarda Türkçe harf var; anahtarları tırnak içinde yaz: "Büyücü": "…". Objenin tipi Ders 42'deki Record<string, string> olabilir.
İpucu 2
Objede olmayan bir anahtarı okumak undefined verir. "Yoksa şunu kullan" demenin kısa yolu Ders 12'deki ??.
İpucu 3
Sınıfları bg-${renk}-100 diye kurma: o zaman Tailwind onları göremez. Her rolün tam sınıf listesi objede yazılı dursun.
Çözümü göster
const rolSiniflari: Record<string, string> = { "Tank": "bg-sky-100 text-sky-800", "Suikastçı": "bg-red-100 text-red-800", "Büyücü": "bg-violet-100 text-violet-800", "Nişancı": "bg-amber-100 text-amber-800", "Destek": "bg-emerald-100 text-emerald-800",} function rolSinifi(rol: string) { return rolSiniflari[rol] ?? "bg-zinc-100 text-zinc-800"} console.log(rolSinifi("Büyücü"))console.log(rolSinifi("Nişancı"))console.log(rolSinifi("Orman"))console.log(rolSinifi("tank"))console.log(rolSinifi(""))bg-violet-100 text-violet-800bg-amber-100 text-amber-800bg-zinc-100 text-zinc-800bg-zinc-100 text-zinc-800bg-zinc-100 text-zinc-800"tank" gri çıktı: anahtarlar büyük-küçük harfe duyarlı. Veride roller hep büyük harfle başlıyorsa sorun yok; emin değilsen anahtarları tek biçime çevirmek gerekir (Türkçe harflerde toLocaleLowerCase("tr"), Ders 9).
Projeye taşınmış hâli; lib/roller.ts'te fonksiyonun başına export gelir, console.log satırları gelmez:
import { rolSinifi } from "@/lib/roller" export default function RolRozeti({ rol }: { rol: string }) { return <span className={`rounded-full px-2.5 py-0.5 text-sm font-semibold ${rolSinifi(rol)}`}>{rol}</span>}Sınıf listeleri lib/roller.ts'te tam yazılı olduğu için Tailwind onları bulur. Beş rozetin hepsinde yazı, rozetin kendi açık zemininde 6:1'in üstünde kontrast taşır (en düşüğü Nişancı: 6,36:1); koyu tema için her listeye dark: karşılığını eklemek bir sonraki adım.
TFT dükkânı
app/dukkan/page.tsx adında bir sayfa aç. Beş birimlik bir TFT dükkânını ızgarada göster: telefonda 2, 640 pikselden itibaren 3, 1024 pikselden itibaren 5 sütun. Her birim beyaz (koyu temada koyu gri) bir kutu; içinde ortalanmış ad ve altında "N altın". Veri:
const birimler = [ { ad: "Garen", maliyet: 1 }, { ad: "Lux", maliyet: 2 }, { ad: "Jinx", maliyet: 3 }, { ad: "Teemo", maliyet: 4 }, { ad: "Annie", maliyet: 5 },]Sınırları dene: geliştirici araçlarının cihaz çubuğunda genişliği 639, 640, 1023 ve 1024 yap.
İpucu 1
Öneksiz sınıf telefon içindir: grid-cols-2. Üstüne sm: ve lg: önekli iki sınıf eklenir.
İpucu 2
Ortalamak için text-center. Koyu tema için bg-white'ın yanına dark:bg-zinc-900, kenar rengine de bir dark: karşılığı.
Çözümü göster
const birimler = [ { ad: "Garen", maliyet: 1 }, { ad: "Lux", maliyet: 2 }, { ad: "Jinx", maliyet: 3 }, { ad: "Teemo", maliyet: 4 }, { ad: "Annie", maliyet: 5 },] export default function Dukkan() { return ( <main className="mx-auto max-w-5xl px-4 py-8"> <h1 className="text-2xl font-bold">Dükkân</h1> <ul className="mt-4 grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5"> {birimler.map((birim) => ( <li key={birim.ad} className="rounded-lg border border-zinc-200 bg-white p-3 text-center dark:border-zinc-800 dark:bg-zinc-900"> <p className="font-semibold">{birim.ad}</p> <p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">{birim.maliyet} altın</p> </li> ))} </ul> </main> )}Ölçtüğümüz sütun sayıları:
| Genişlik | Sütun |
|---|---|
| 390 px | 2 |
| 639 px | 2 |
| 640 px | 3 |
| 1023 px | 3 |
| 1024 px | 5 |
640'ta üç sütun olunca beşinci birim ikinci satıra tek başına iner; 1024'te beşi tek satıra sığar. sm:grid-cols-3 yerine md:grid-cols-3 yazsaydın 640 ile 767 arasında iki sütun kalırdı.
Erişilebilirlik avı
Aşağıdaki bileşen bir şampiyon arama kutusu ve favori seçimi. Fareyle mükemmel çalışıyor ama içinde en az beş erişilebilirlik sorunu var. components/SampiyonArama.tsx olarak yaz, app/arama/page.tsx'ten çağır, sonra sorunları bul ve düzelt.
"use client" import { useState } from "react" const sampiyonlar = ["Ahri", "Annie", "Garen", "Jinx", "Lux", "Teemo"] export default function SampiyonArama() { const [arama, setArama] = useState("") const [favori, setFavori] = useState("") const sonuclar = sampiyonlar.filter((ad) => ad.toLowerCase().includes(arama.toLowerCase())) return ( <div className="max-w-sm rounded-xl bg-white p-6 dark:bg-zinc-900"> <div className="flex gap-2"> <input value={arama} onChange={(olay) => setArama(olay.target.value)} placeholder="Şampiyon ara" className="w-full rounded-lg border border-zinc-500 px-3 py-2 outline-none" /> <button type="button" onClick={() => setArama("")} className="cursor-pointer rounded-lg bg-violet-700 px-3 text-white outline-none"> <svg viewBox="0 0 24 24" className="size-5" fill="none" stroke="currentColor" strokeWidth="2"> <path d="M6 6l12 12M18 6L6 18" /> </svg> </button> </div> <p className="mt-2 text-sm text-zinc-400">Yazdıkça liste süzülür.</p> <ul className="mt-4 space-y-2"> {sonuclar.map((ad) => ( <li key={ad}> <div onClick={() => setFavori(ad)} className={`cursor-pointer rounded-lg px-3 py-2 ${favori === ad ? "bg-amber-200 dark:bg-amber-900" : "hover:bg-zinc-100 dark:hover:bg-zinc-800"}`}> {ad} </div> </li> ))} </ul> </div> )}import SampiyonArama from "@/components/SampiyonArama" export default function Arama() { return ( <main className="mx-auto max-w-5xl px-4 py-8"> <h1 className="text-2xl font-bold">Favori şampiyonun</h1> <div className="mt-4"> <SampiyonArama /> </div> </main> )}Bitince Lighthouse'u açık ve koyu temada çalıştır (Erişilebilirlik 100 olmalı), sonra fareye dokunmadan bütün listeyi gez ve bir favori seç. space-y-2 yeni bir sınıf: liste ögelerinin arasına 8 piksel dikey boşluk koyar.
İpucu 1
Önce Lighthouse'u bozuk hâliyle çalıştır. Biz açık temada 89, koyu temada 94 aldık; iki sorun buldu. Kalanları dersin dört sorusuyla ara: ad, odak, kontrast, klavye. Bir de favorinin nasıl gösterildiğine bak.
İpucu 2
Fareyi bırakıp Tab'a bas. Bizde sıra kutu, çarpı düğmesi ve ardından tarayıcının adres çubuğu oldu (npm run dev açıksa araya köşedeki Next N düğmesi girer): liste hiç odak almadı. Odak çerçevesi de kutuda ve çarpı düğmesinde hiç görünmedi.
İpucu 3
Altı düzeltme: kutuya görünen bir label; çarpı düğmesine bir ad; outline-none yerine focus-visible: çerçevesi; ipucu yazısına daha koyu bir gri; div yerine aria-pressed'li bir button; favoriye renkten başka bir işaret (★ ve ☆). Odak sınıflarını üç kez yazmamak için onları bir sabite koy: const odak = "focus-visible:outline-2 …". Sabitte tam sınıf adları yazdığı için Tailwind onları görür.
Çözümü göster
"use client" import { useState } from "react" const sampiyonlar = ["Ahri", "Annie", "Garen", "Jinx", "Lux", "Teemo"]const odak = "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-violet-500" export default function SampiyonArama() { const [arama, setArama] = useState("") const [favori, setFavori] = useState("") const sonuclar = sampiyonlar.filter((ad) => ad.toLowerCase().includes(arama.toLowerCase())) return ( <div className="max-w-sm rounded-xl bg-white p-6 dark:bg-zinc-900"> <label htmlFor="arama" className="block font-medium">Şampiyon ara</label> <div className="mt-1 flex gap-2"> <input id="arama" value={arama} onChange={(olay) => setArama(olay.target.value)} aria-describedby="arama-ipucu" className={`w-full rounded-lg border border-zinc-500 px-3 py-2 ${odak}`} /> <button type="button" onClick={() => setArama("")} aria-label="Aramayı temizle" className={`cursor-pointer rounded-lg bg-violet-700 px-3 text-white ${odak}`}> <svg aria-hidden="true" viewBox="0 0 24 24" className="size-5" fill="none" stroke="currentColor" strokeWidth="2"> <path d="M6 6l12 12M18 6L6 18" /> </svg> </button> </div> <p id="arama-ipucu" className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">Yazdıkça liste süzülür.</p> <ul className="mt-4 space-y-2"> {sonuclar.map((ad) => ( <li key={ad}> <button type="button" onClick={() => setFavori(ad)} aria-pressed={favori === ad} className={`w-full cursor-pointer rounded-lg px-3 py-2 text-left ${odak} ${favori === ad ? "bg-amber-200 font-semibold dark:bg-amber-900" : "hover:bg-zinc-100 dark:hover:bg-zinc-800"}`}> <span aria-hidden="true">{favori === ad ? "★ " : "☆ "}</span> {ad} </button> </li> ))} </ul> </div> )}| Sorun | Lighthouse buldu mu? | Düzeltme |
|---|---|---|
| Çarpı düğmesinin adı yok | Evet: "Düğmelerin erişilebilir adları yok" | aria-label="Aramayı temizle", SVG'ye aria-hidden |
İpucu yazısı zinc-400 (beyazda 2,62:1) | Yalnız açık temada | text-zinc-600 dark:text-zinc-400 |
Kutunun etiketi yok, yalnız placeholder | Hayır (placeholder'ı ad saydı) | <label htmlFor="arama"> |
outline-none: odak görünmüyor | Hayır | focus-visible: sınıfları |
div onClick: liste klavyeyle kullanılamıyor | Hayır | <button type="button"> |
| Favori yalnız sarı zeminle belli | Hayır | ★/☆ işareti, font-semibold, aria-pressed |
Düzeltilmiş hâl iki temada da 100 aldı. Tab sırası: kutu, "Aramayı temizle", sonra altı şampiyonun düğmesi tek tek. Bir düğmede Space'e basınca yıldız doluyor ve ekran okuyucu "basılı" diyor. Lighthouse altı sorunun yalnız ikisini buldu; geri kalanını sen buldun.
Duyarlı ve koyu şampiyon kartı
Şu kart yalnız açık tema için ve her genişlikte yan yana tasarlanmış:
export default function Sampiyon() { return ( <main className="mx-auto max-w-2xl px-4 py-8"> <article className="flex gap-4 rounded-xl border border-zinc-200 bg-white p-4"> <div aria-hidden="true" className="grid size-24 shrink-0 place-items-center rounded-lg bg-violet-700 text-4xl font-bold text-white">A</div> <div> <h2 className="text-xl font-bold text-zinc-900">Ahri</h2> <p className="text-sm text-zinc-500">Dokuz Kuyruklu Tilki</p> <p className="mt-2 text-zinc-700">Ruhları büyüleyen, hızlı ve hareketli bir büyücü. Yakın dövüşe girip çıkmayı sever.</p> </div> </article> </main> )}Değiştir: telefonda harf kutusu üstte, yazılar altında dursun; 640 pikselden itibaren yan yana geçsin. Koyu temada kart koyu, yazılar okunur olsun ve her yazı kendi zemininde en az 4,5:1 kontrast taşısın.
İpucu 1
flex öğeleri varsayılan olarak yan yana dizer. Telefon için öneksiz flex-col, 640'tan itibaren sm:flex-row.
İpucu 2
Koyu temada text-zinc-500'ün karşılığına dikkat: zinc-500 koyu kart zemininde (zinc-900) yalnız 3,67:1 verir. Bir ton açığını seç.
Çözümü göster
export default function Sampiyon() { return ( <main className="mx-auto max-w-2xl px-4 py-8"> <article className="flex flex-col gap-4 rounded-xl border border-zinc-200 bg-white p-4 sm:flex-row dark:border-zinc-800 dark:bg-zinc-900"> <div aria-hidden="true" className="grid size-24 shrink-0 place-items-center rounded-lg bg-violet-700 text-4xl font-bold text-white">A</div> <div> <h2 className="text-xl font-bold text-zinc-900 dark:text-zinc-100">Ahri</h2> <p className="text-sm text-zinc-500 dark:text-zinc-400">Dokuz Kuyruklu Tilki</p> <p className="mt-2 text-zinc-700 dark:text-zinc-300">Ruhları büyüleyen, hızlı ve hareketli bir büyücü. Yakın dövüşe girip çıkmayı sever.</p> </div> </article> </main> )}Ölçüm: flex-direction 390 ve 639 pikselde column, 640 pikselde row. Lighthouse'un erişilebilirlik puanının arkasındaki kural motoruyla (axe) iki temada da denetledik: kontrast sorunu yok. zinc-500 beyaz kartta 4,83:1 ile geçiyor; koyu kartta zinc-400 6,75:1. Harf kutusu süs olduğu için aria-hidden; ekran okuyucu "A, Ahri" yerine yalnız "Ahri" der. shrink-0, yan yana dizildiğinde uzun yazının kutuyu sıkıştırmasını önler.
Favoriler sayfasının üç durumu
/favoriler adresinde favori şampiyonları listeleyen bir sayfa yap; üç durumu da tasarla. Veri için lib/favoriler.ts:
export type Favori = { id: number ad: string rol: string} const favoriler: Favori[] = [ { id: 1, ad: "Ahri", rol: "Büyücü" }, { id: 2, ad: "Jinx", rol: "Nişancı" }, { id: 3, ad: "Garen", rol: "Tank" },] export async function favorileriGetir(durum?: string) { await new Promise((coz) => setTimeout(coz, 1500)) if (durum === "hata") { throw new Error("Favoriler okunamadı") } if (durum === "bos") { return [] } if (durum === "tek") { return favoriler.slice(0, 1) } return favoriler}İstenenler:
- Dolu: Her favori bir satır: solda içinde adın baş harfi olan mor bir daire, yanında ad ve altında rol.
- Yükleniyor: Satırların iskeleti. İskelet satırı gerçek satırla aynı yükseklikte olsun; hareketi azaltma tercihi olanlarda yanıp sönmesin; ekran okuyucu "Favoriler yükleniyor…" duysun.
- Boş: Neden boş olduğunu ve ne yapılacağını söyleyen bir kutu; ana sayfaya giden "Şampiyonlara göz at" bağlantısıyla.
- Hata: Ne olduğunu söyleyen, kaygıyı gideren bir mesaj ve "Yeniden dene" düğmesi.
?durum=bos, ?durum=tek ve ?durum=hata ile her durumu aç.
İpucu 1
Üç dosya: app/favoriler/page.tsx, app/favoriler/loading.tsx, app/favoriler/error.tsx. Sayfa searchParams'ı okur; tipi PageProps<"/favoriler">. Next bu tipi sayfayı ilk kez gördüğünde üretir; VS Code önce kızarsa npm run dev çalışırken dosyayı bir kez kaydet.
İpucu 2
Satır: flex items-center gap-3 … p-3. Daire: grid size-10 place-items-center rounded-full bg-violet-700 font-bold text-white. Ad (font-semibold, normal boyut) 24, rol (text-sm) 20 piksel yükseklik tutar; iskelette h-6 ve h-5 çubuklar kullan.
İpucu 3
Ana sayfaya bağlantı için <a href="/"> yazarsan npm run lint bir hata verir: "Do not use an <a> element to navigate to /. Use <Link /> from next/link instead." Ders 43'teki Link'i kullan. Hata ekranındaki düğme için dersin Dugme'si ve retry hazır.
Çözümü göster
import Link from "next/link"import { favorileriGetir } from "@/lib/favoriler" export default async function Favoriler({ searchParams }: PageProps<"/favoriler">) { const { durum } = await searchParams const liste = await favorileriGetir(typeof durum === "string" ? durum : undefined) return ( <main className="mx-auto max-w-md px-4 py-8"> <h1 className="text-2xl font-bold">Favori şampiyonların</h1> {liste.length === 0 ? ( <div className="mt-4 rounded-xl border-2 border-dashed border-zinc-300 p-8 text-center dark:border-zinc-700"> <h2 className="font-bold">Henüz favorin yok</h2> <p className="mt-1 text-zinc-600 dark:text-zinc-400">Bir şampiyonun yıldızına basınca burada görünür.</p> <Link href="/" className="mt-4 inline-block rounded-lg bg-violet-700 px-4 py-2 font-semibold text-white hover:bg-violet-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-violet-500"> Şampiyonlara göz at </Link> </div> ) : ( <ul className="mt-4 space-y-3"> {liste.map((favori) => ( <li key={favori.id} className="flex items-center gap-3 rounded-xl border border-zinc-200 bg-white p-3 dark:border-zinc-800 dark:bg-zinc-900"> <div aria-hidden="true" className="grid size-10 place-items-center rounded-full bg-violet-700 font-bold text-white">{favori.ad[0]}</div> <div> <p className="font-semibold">{favori.ad}</p> <p className="text-sm text-zinc-600 dark:text-zinc-400">{favori.rol}</p> </div> </li> ))} </ul> )} </main> )}export default function FavorilerYukleniyor() { return ( <main className="mx-auto max-w-md px-4 py-8"> <h1 className="text-2xl font-bold">Favori şampiyonların</h1> <p role="status" className="sr-only">Favoriler yükleniyor…</p> <ul aria-hidden="true" className="mt-4 space-y-3"> {[1, 2, 3].map((n) => ( <li key={n} className="flex animate-pulse items-center gap-3 rounded-xl border border-zinc-200 bg-white p-3 motion-reduce:animate-none dark:border-zinc-800 dark:bg-zinc-900"> <div className="size-10 rounded-full bg-zinc-200 dark:bg-zinc-700" /> <div> <div className="h-6 w-24 rounded bg-zinc-200 dark:bg-zinc-700" /> <div className="h-5 w-16 rounded bg-zinc-200 dark:bg-zinc-700" /> </div> </li> ))} </ul> </main> )}"use client" import Dugme from "@/components/Dugme" export default function FavorilerHata({ retry }: { retry: () => void }) { return ( <main className="mx-auto max-w-md px-4 py-8"> <h1 className="text-2xl font-bold">Favori şampiyonların</h1> <div role="alert" className="mt-4 rounded-xl border border-red-300 bg-red-50 p-6 dark:border-red-900 dark:bg-red-950"> <h2 className="font-bold text-red-800 dark:text-red-200">Favorilerin yüklenemedi</h2> <p className="mt-1 text-red-800 dark:text-red-200">Listen yerinde duruyor, yalnız şu an okuyamadık. Birazdan yeniden dene.</p> <div className="mt-4"> <Dugme onClick={() => retry()}>Yeniden dene</Dugme> </div> </div> </main> )}Sınır denemeleri: iskelet satırı da gerçek satır da 70 piksel çıktı, veri gelince liste kaymadı. ?durum=tek tek satır gösterdi; ?durum=bos boş kutuyu, ?durum=hata hata kutusunu. Aynı kural motoruyla (axe) iki temada kontrast, etiket ve düğme adı denetimi temiz geçti. "Yeniden dene"ye ?durum=hata açıkken basarsan hata yine gelir; yapay hatamız kalıcı. Gerçek bir geçici kopmada ikinci deneme çoğu zaman başarır.
Kendini sına
Soru 1
Telefonda (390 px) bu başlık kaç piksel olur?
<h1 className="text-xl md:text-3xl">Maç geçmişi</h1>Cevabı göster
20 piksel (text-xl). md:text-3xl ancak 768 pikselden itibaren devreye girer. Mobil öncelikli düzende öneksiz sınıf telefonun, önekli sınıf büyük ekranın kuralıdır.
Soru 2
Bu rozet bazen renkli, bazen renksiz çıkıyor. Neden? Nasıl düzeltirsin?
<span className={`bg-${sonuc === "zafer" ? "emerald" : "red"}-100`}>{sonuc}</span>Cevabı göster
Tailwind dosyaları metin olarak okur, kodu çalıştırmaz. Bu satırda bg-emerald-100 ya da bg-red-100 diye tam bir sözcük yok; Tailwind bu sınıfların CSS'ini yalnız başka bir dosyada tam adlarını görürse üretir. "Bazen" çalışmasının sebebi bu: başka bir bileşen o sınıfı kullanıyorsa. Düzeltme: tam adları yaz, koşulla yalnız seçimi yap: sonuc === "zafer" ? "bg-emerald-100" : "bg-red-100".
Soru 3
Bu paragraf hangi renkte görünür?
<p className="text-zinc-600 text-red-700">Hata!</p>Cevabı göster
zinc-600 (gri). İki sınıf aynı özelliği (color) ayarlıyor ve özgüllükleri eşit (Ders 20); kazanan, CSS dosyasında sonra yazılan. Tailwind'in ürettiği dosyada .text-red-700 kuralı .text-zinc-600'dan önce geliyor; className'deki sıranın hiçbir etkisi yok. Ders: aynı özellik için iki sınıf yazma; koşula göre birini seç.
Soru 4
Bir arkadaşın düğmesine focus:outline-none yazmış, "fareyle tıklayınca çıkan çerçeve çirkin" diyor. Ne önerirsin?
Cevabı göster
Çerçeveyi silmek klavyeyle gezenlerin odağı görmesini engeller. Çözüm focus-visible:: tarayıcı çerçeveyi fareyle tıklamada göstermez, klavyeyle gelince gösterir. focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-violet-500 gibi kendi tarzında bir çerçeve yaz; tarayıcınınkini gizlemek gerekiyorsa outline-none yerine outline-hidden kullan.
Soru 5
Sayfan Lighthouse'ta erişilebilirlikten 100 aldı. Sayfan erişilebilir mi?
Cevabı göster
Bilemezsin. Lighthouse yalnız makinenin ölçebildiği kuralları denetler. Bu derste odak çerçevesini silmek, div'e onClick vermek ve bilgiyi yalnız renkle vermek puanı hiç düşürmedi; placeholder'ı da etiket yerine saydı. Ayrıca yalnız o anki temada ölçer. 100, "makinenin bulabileceği sorun kalmadı" demektir; klavyeyle gezmek, iki temada bakmak ve raporun elle denetlenecek listesini yapmak sana kalır.
Soru 6
İskelet kartlarını neden gerçek kartla aynı yükseklikte yaptık? Sadece "Yükleniyor…" yazsak ne olurdu?
Cevabı göster
Veri gelince içerik iskeletin yerine oturur ve sayfa kaymaz. Tek satırlık bir yazı 24 piksel kadar yer kaplar; yerine 126 piksellik kartlar gelince altındaki her şey (form, bağlantılar) aşağı zıplar. Kullanıcı tam o anda bir şeye basmak üzereyse yanlış yere basar. Lighthouse bu kaymayı Performans'ta CLS ile ölçer ve puandan düşer.
Soru 7
Hangisi doğru: <button aria-label="düğme">Kaydet</button> mı, <button>Kaydet</button> mı?
Cevabı göster
İkincisi. Düğmenin adı zaten görünen yazısı: "Kaydet". aria-label o adı ezer; ekran okuyucu "Kaydet" yerine "düğme" der, gözle görülenle kulakla duyulan ayrışır. aria-label'ı yalnız görünen bir adı olmayan öğelerde (simgeli düğme gibi) kullan. Kural: doğru HTML yetiyorsa ARIA ekleme.
Kayıt noktası
create-next-app projeyi açarken Git deposunu kurup ilk kaydı kendisi aldı. Senin işini kaydetme sırası:
Bir sonraki ders Blok 6'nın projesi: Next.js ve PostgreSQL ile girişli, veritabanlı bir maç notları uygulaması yazıp yayına alacak, bu dersteki düğmeyi, kartı, formu ve durum ekranlarını orada gerçek veriyle kullanacaksın.