React’te Klavye Erişilebilirliği: “Roving Tabindex” ile Menü ve Liste Gezintisi
Klavye ile sorunsuz gezilen menü/liste bileşenleri için roving tabindex desenini React’te uygulayalım.
Neden “Roving Tabindex”?
Bir menü, sekme listesi veya seçenek listesi düşünün: Kullanıcı Tab ile bileşene girer, sonra ok tuşları ile öğeler arasında dolaşır. Her öğeyi tabIndex=0 yapmak odak sırasını uzatır ve kullanıcıyı yorar.
Roving tabindex yaklaşımında:
- Sadece aktif öğe
tabIndex=0olur. - Diğerleri
tabIndex=-1olur. - Ok tuşlarıyla aktif indeks değişir, odak yeni öğeye taşınır.
Bu desen hem erişilebilirlik hem de UX açısından “doğru his” verir.
Hedef: Basit bir “Action List” bileşeni
Aşağıda, klavyeyle gezilebilen bir liste yapacağız:
Tab: listeye girer (aktif öğeye odaklanır)ArrowDown/ArrowUp: öğeler arasında dolaşırHome/End: başa/sona giderEnter/Space: seçimi tetikler
Not: Örnek, erişilebilir bir temel sunar. “Seçili” durum (aria-selected) gibi ihtiyaçlara göre genişletebilirsiniz.
Uygulama (React)
import { useEffect, useMemo, useRef, useState } from "react";
function clampIndex(next, len) {
if (len === 0) return 0;
return (next + len) % len; // wrap-around
}
export function ActionList({ items, onActivate }) {
const [activeIndex, setActiveIndex] = useState(0);
const refs = useRef([]);
// Items değişince index taşmasın
useEffect(() => {
setActiveIndex((i) => Math.min(i, Math.max(items.length - 1, 0)));
}, [items.length]);
const focusItem = (i) => {
const el = refs.current[i];
if (el) el.focus();
};
const onKeyDown = (e) => {
const len = items.length;
if (len === 0) return;
const key = e.key;
let next = activeIndex;
if (key === "ArrowDown") next = clampIndex(activeIndex + 1, len);
else if (key === "ArrowUp") next = clampIndex(activeIndex - 1, len);
else if (key === "Home") next = 0;
else if (key === "End") next = len - 1;
else if (key === "Enter" || key === " ") {
e.preventDefault();
onActivate?.(items[activeIndex], activeIndex);
return;
} else {
return; // ilgilenmiyoruz
}
e.preventDefault();
setActiveIndex(next);
// State güncellenmeden önce odak taşımak için microtask
queueMicrotask(() => focusItem(next));
};
// A11y: Liste konteyneri değil, öğeler odak alıyor.
// role=listbox yerine menü istiyorsanız role=menu + role=menuitem tercih edin.
return (
<div role="listbox" aria-label="Actions" onKeyDown={onKeyDown}>
{items.map((item, i) => (
<button
key={item.id}
ref={(el) => (refs.current[i] = el)}
type="button"
role="option"
tabIndex={i === activeIndex ? 0 : -1}
aria-selected={i === activeIndex}
onClick={() => onActivate?.(item, i)}
onFocus={() => setActiveIndex(i)}
style={{
display: "block",
width: "100%",
textAlign: "left",
padding: 10,
border: "1px solid #ddd",
background: i === activeIndex ? "#f5f5f5" : "white",
}}
>
{item.label}
</button>
))}
</div>
);
}
// kullanım
// <ActionList items={[{id:'1', label:'Arşivle'}]} onActivate={(item)=>...} />
İnce noktalar (sık yapılan hatalar)
-
Odak mı, aktif mi?
- Ok tuşlarıyla “aktif indeks” değişince odak da değişmeli. Aksi halde ekran okuyucu/klavye deneyimi kopuk olur.
-
onFocusile senkronizasyon- Mouse ile tıklayınca veya Shift+Tab ile geri gelince
activeIndexgüncellenmeli.
- Mouse ile tıklayınca veya Shift+Tab ile geri gelince
-
Wrap-around davranışı
- Yukarıda mod alma ile başa/sona sarma yaptık. İstemiyorsanız
clampile 0-len-1 arasında sabitleyin.
- Yukarıda mod alma ile başa/sona sarma yaptık. İstemiyorsanız
-
Doğru ARIA rolü
- Bu örnek “seçenek listesi” gibi davrandığı için
listbox/optionkullandı. - Gerçek bir uygulama menüsü için
menu/menuitemdaha uygun olabilir.
- Bu örnek “seçenek listesi” gibi davrandığı için
Kapanış
Roving tabindex, “küçük bir detay” gibi görünür ama klavye kullanıcıları için uygulamayı dramatik şekilde iyileştirir. Özellikle komut paleti, dropdown, sekme barı ve yan menü gibi alanlarda hızlıca değer üretir.