Add modal keyboard navigation and focus restoration to product search
This commit is contained in:
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"reverseTab": {
|
||||||
|
"insideDialog": true,
|
||||||
|
"tag": "INPUT"
|
||||||
|
},
|
||||||
|
"escape": {
|
||||||
|
"open": 0,
|
||||||
|
"tag": "BUTTON",
|
||||||
|
"trigger": "true"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -401,3 +401,9 @@ Son ölçüm: mobil 384/384, tablet 762/762, masaüstü 1434/1434 (içerik/kulla
|
|||||||
Mobil filtre çekmecesi native dialog/showModal kullanıyor: adlandırılmış dialog, açılışta kapat düğmesine odak, Escape/kapat sonrası tetikleyiciye dönüş, arka sayfa kaydırma kilidi ve masaüstüne geçince otomatik kapanma eklendi. OKAS ürün arama penceresi açılırken drawer kapanıyor; native dialog üst katmanının diğer pencereyi örtmesi önleniyor.
|
Mobil filtre çekmecesi native dialog/showModal kullanıyor: adlandırılmış dialog, açılışta kapat düğmesine odak, Escape/kapat sonrası tetikleyiciye dönüş, arka sayfa kaydırma kilidi ve masaüstüne geçince otomatik kapanma eklendi. OKAS ürün arama penceresi açılırken drawer kapanıyor; native dialog üst katmanının diğer pencereyi örtmesi önleniyor.
|
||||||
|
|
||||||
Gerçek 390 px tarayıcı testinde dialog 337/337 px genişlikle taşmadı, ilk odak “Filtreleri kapat” oldu. Escape sonrası açık dialog sayısı 0 ve odak “Filtreler & Hızlı Erişim” düğmesiydi. Açık çekmecede 1440 px'e geçiş 1 açık dialogdan 0'a geçti. Kanıt: evidence/qa-mobile-filter-keyboard.json. 80 test ve üretim derlemesi geçti. OKAS pencere geçişinin gerçek sonuçla tarayıcı kabulü, tüm filtre kombinasyonları ve ekran okuyucu testi açık.
|
Gerçek 390 px tarayıcı testinde dialog 337/337 px genişlikle taşmadı, ilk odak “Filtreleri kapat” oldu. Escape sonrası açık dialog sayısı 0 ve odak “Filtreler & Hızlı Erişim” düğmesiydi. Açık çekmecede 1440 px'e geçiş 1 açık dialogdan 0'a geçti. Kanıt: evidence/qa-mobile-filter-keyboard.json. 80 test ve üretim derlemesi geçti. OKAS pencere geçişinin gerçek sonuçla tarayıcı kabulü, tüm filtre kombinasyonları ve ekran okuyucu testi açık.
|
||||||
|
|
||||||
|
### Ürün arama dialog erişilebilirliği
|
||||||
|
|
||||||
|
Ürün arama native dialog, başlık bağlantısı, açılış odağı, açık Tab sınırları ve Escape/kapat sonrası odak dönüşü kullanıyor. Mobil iç boşluklar azaltıldı; 390 px dialog içerik genişliği taşmadı. Metin/OKAS seçimleri aria-pressed taşıyor. Tarayıcı testinde ilk uygulamada Shift+Tab BODY'ye çıktı; açık odak döngüsü eklendi. AutoFocus nedeniyle kapanış kontrolünün yanlışlıkla opener kaydedilmesi de düzeltildi; gerekli durumda header ürün arama düğmesi hedefleniyor.
|
||||||
|
|
||||||
|
Son gerçek tarayıcı sonucu: ters Tab aktif INPUT ile dialog içinde; Escape sonrası 0 açık dialog ve data-product-search-trigger=true olan BUTTON odakta. Kanıt evidence/qa-product-dialog-keyboard.json. 80 test ve son üretim derlemesi geçti. Gerçek arama sonucu/ihale detay geçişi, OKAS çekmecesinden açılış ve diğer modallerin erişilebilirliği halen açık.
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ export const Header = () => {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
data-product-search-trigger="true"
|
||||||
onClick={() => setIsUrunAramaOpen(true)}
|
onClick={() => setIsUrunAramaOpen(true)}
|
||||||
title="Ürün Arama — ürün adınızı ihale dokümanlarının içinde arar"
|
title="Ürün Arama — ürün adınızı ihale dokümanlarının içinde arar"
|
||||||
className="bg-amber-400 hover:bg-amber-300 text-slate-950 font-black px-3 py-2 rounded-lg text-xs flex items-center gap-1.5 transition-colors shrink-0"
|
className="bg-amber-400 hover:bg-amber-300 text-slate-950 font-black px-3 py-2 rounded-lg text-xs flex items-center gap-1.5 transition-colors shrink-0"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { deadlineInfo } from '../services/tenderCalendar';
|
import { deadlineInfo } from '../services/tenderCalendar';
|
||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef, useId } from 'react';
|
||||||
import { useApp } from '../context/AppContext';
|
import { useApp } from '../context/AppContext';
|
||||||
import {
|
import {
|
||||||
X, Search, Loader2, Package, AlertTriangle, Building2, MapPin, CalendarDays, Info,
|
X, Search, Loader2, Package, AlertTriangle, Building2, MapPin, CalendarDays, Info,
|
||||||
@@ -27,6 +27,20 @@ export const UrunAramaModal = () => {
|
|||||||
isUrunAramaOpen, setIsUrunAramaOpen, setActiveTenderDetail, tenders,
|
isUrunAramaOpen, setIsUrunAramaOpen, setActiveTenderDetail, tenders,
|
||||||
urunAramaBaslangic, setUrunAramaBaslangic,
|
urunAramaBaslangic, setUrunAramaBaslangic,
|
||||||
} = useApp();
|
} = useApp();
|
||||||
|
const modal = useRef(null);
|
||||||
|
const titleId = useId();
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isUrunAramaOpen) return;
|
||||||
|
const dialog = modal.current;
|
||||||
|
const opener = (document.activeElement === document.body || dialog.contains(document.activeElement)) ? document.querySelector('[data-product-search-trigger]') : document.activeElement;
|
||||||
|
dialog.showModal();
|
||||||
|
const previous = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
return () => {
|
||||||
|
dialog.close(); document.body.style.overflow = previous;
|
||||||
|
if (opener?.isConnected && opener.getClientRects().length) opener.focus();
|
||||||
|
};
|
||||||
|
}, [isUrunAramaOpen]);
|
||||||
const requestVersion = useRef(0);
|
const requestVersion = useRef(0);
|
||||||
const [submitted, setSubmitted] = useState(null);
|
const [submitted, setSubmitted] = useState(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -114,32 +128,41 @@ export const UrunAramaModal = () => {
|
|||||||
const havuzda = (ikn) => tenders?.find((t) => t.ikn === ikn) || null;
|
const havuzda = (ikn) => tenders?.find((t) => t.ikn === ikn) || null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/75 backdrop-blur-xs animate-fade-in">
|
<dialog ref={modal} aria-labelledby={titleId} onKeyDown={event => {
|
||||||
|
if (event.key !== 'Tab') return;
|
||||||
|
const elements = [...event.currentTarget.querySelectorAll('button, input, select, textarea, a[href], [tabindex]')]
|
||||||
|
.filter(element => !element.disabled && element.tabIndex >= 0 && element.getClientRects().length);
|
||||||
|
const first = elements[0], last = elements[elements.length - 1];
|
||||||
|
if (event.shiftKey && document.activeElement === first) {event.preventDefault();last?.focus();}
|
||||||
|
else if (!event.shiftKey && document.activeElement === last) {event.preventDefault();first?.focus();}
|
||||||
|
}} onCancel={(event) => {event.preventDefault();event.stopPropagation();setIsUrunAramaOpen(false);}}
|
||||||
|
onClose={() => setIsUrunAramaOpen(false)}
|
||||||
|
className="fixed inset-0 m-0 h-dvh max-h-none w-full max-w-none border-0 flex items-center justify-center p-2 sm:p-4 bg-slate-900/75 backdrop-blur-xs animate-fade-in">
|
||||||
<div className="bg-white rounded-2xl border border-slate-200 shadow-2xl w-full max-w-4xl overflow-hidden flex flex-col max-h-[92vh]">
|
<div className="bg-white rounded-2xl border border-slate-200 shadow-2xl w-full max-w-4xl overflow-hidden flex flex-col max-h-[92vh]">
|
||||||
|
|
||||||
<div className="bg-linear-to-r from-indigo-900 via-blue-900 to-slate-900 text-white px-6 py-4 flex items-center justify-between">
|
<div className="bg-linear-to-r from-indigo-900 via-blue-900 to-slate-900 text-white px-3 sm:px-6 py-4 flex items-center justify-between gap-2">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-10 h-10 rounded-xl bg-amber-400 text-slate-900 flex items-center justify-center shadow-md">
|
<div className="w-10 h-10 rounded-xl bg-amber-400 text-slate-900 flex items-center justify-center shadow-md">
|
||||||
<Package className="w-6 h-6" />
|
<Package className="w-6 h-6" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="font-extrabold text-base">Ürün Arama</h2>
|
<h2 id={titleId} className="font-extrabold text-base">Ürün Arama</h2>
|
||||||
<p className="text-xs text-blue-200">
|
<p className="text-xs text-blue-200">
|
||||||
Ürün adınızı ihale <strong>dokümanlarının içinde</strong> arar — başlıkta geçmese bile bulur.
|
Ürün adınızı ihale <strong>dokümanlarının içinde</strong> arar — başlıkta geçmese bile bulur.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button aria-label="Ürün aramasını kapat" onClick={() => setIsUrunAramaOpen(false)} className="text-slate-400 hover:text-white p-1 rounded-lg">
|
<button autoFocus aria-label="Ürün aramasını kapat" onClick={() => setIsUrunAramaOpen(false)} className="text-slate-400 hover:text-white p-1 rounded-lg">
|
||||||
<X className="w-6 h-6" />
|
<X className="w-6 h-6" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-6 space-y-4 overflow-y-auto">
|
<div className="p-3 sm:p-6 space-y-4 overflow-y-auto">
|
||||||
|
|
||||||
<form onSubmit={(e) => { e.preventDefault(); ara(0); }} className="space-y-3">
|
<form onSubmit={(e) => { e.preventDefault(); ara(0); }} className="space-y-3">
|
||||||
<div className="inline-flex items-center gap-1 bg-slate-100 border border-slate-200 rounded-xl p-1">
|
<div className="inline-flex items-center gap-1 bg-slate-100 border border-slate-200 rounded-xl p-1">
|
||||||
{[['metin', 'Ürün adıyla'], ['okas', 'OKAS koduyla']].map(([m, ad]) => (
|
{[['metin', 'Ürün adıyla'], ['okas', 'OKAS koduyla']].map(([m, ad]) => (
|
||||||
<button key={m} type="button" onClick={() => setMod(m)}
|
<button key={m} type="button" aria-pressed={mod === m} onClick={() => setMod(m)}
|
||||||
className={`text-[11px] font-extrabold px-3 py-1.5 rounded-lg transition-colors ${
|
className={`text-[11px] font-extrabold px-3 py-1.5 rounded-lg transition-colors ${
|
||||||
mod === m ? 'bg-indigo-600 text-white' : 'text-slate-600 hover:bg-white'}`}>
|
mod === m ? 'bg-indigo-600 text-white' : 'text-slate-600 hover:bg-white'}`}>
|
||||||
{ad}
|
{ad}
|
||||||
@@ -323,6 +346,6 @@ export const UrunAramaModal = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</dialog>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user