fix: unify search dialog focus and overlay behavior
This commit is contained in:
@@ -1224,3 +1224,11 @@ Taslak türü seçicisine erişilebilir ad eklendi. Üretim derlemesi geçti. Ö
|
|||||||
- Portal ile body altına açılan kılavuz da aynı sıraya katılıyor. Yeni DOM alanları eklendiğinde izolasyon yeniden uygulanıyor; son pencere kapanınca gözlemci kapatılıyor. Alttaki pencere kaldırılırken üst pencerenin odağı geri alınmıyor.
|
- Portal ile body altına açılan kılavuz da aynı sıraya katılıyor. Yeni DOM alanları eklendiğinde izolasyon yeniden uygulanıyor; son pencere kapanınca gözlemci kapatılıyor. Alttaki pencere kaldırılırken üst pencerenin odağı geri alınmıyor.
|
||||||
- Yedi tarayıcı kabulü geçti: ihale açıkken başlığın inert olması, analiz üstteyken ihale detayının inert olması, kılavuz üstteyken iki pencerenin inert olması, sırayla kapanışta doğru pencere/odağın geri gelmesi, tümü kapalıyken hiçbir artık inert alanın kalmaması ve ardından firma profilinin normal açılması. Kanıt `rapor/qa-modal-background-isolation.json`.
|
- Yedi tarayıcı kabulü geçti: ihale açıkken başlığın inert olması, analiz üstteyken ihale detayının inert olması, kılavuz üstteyken iki pencerenin inert olması, sırayla kapanışta doğru pencere/odağın geri gelmesi, tümü kapalıyken hiçbir artık inert alanın kalmaması ve ardından firma profilinin normal açılması. Kanıt `rapor/qa-modal-background-isolation.json`.
|
||||||
- 275 test ve build başarılı. Bu doğrulama tarayıcı DOM inert durumu ve gerçek klavye etkileşimini kapsar; belirli bir ekran okuyucuyla dinleme testi yapılmış değildir. Henüz ortak kancayı kullanmayan diğer pencerelerin kabulü ayrıca gereklidir. Genel plan açık.
|
- 275 test ve build başarılı. Bu doğrulama tarayıcı DOM inert durumu ve gerçek klavye etkileşimini kapsar; belirli bir ekran okuyucuyla dinleme testi yapılmış değildir. Henüz ortak kancayı kullanmayan diğer pencerelerin kabulü ayrıca gereklidir. Genel plan açık.
|
||||||
|
|
||||||
|
|
||||||
|
### 2026-09-06 — Detaylı arama ve ürün aramasında pencere tutarlılığı
|
||||||
|
|
||||||
|
- Detaylı aramadaki geçersiz inset renk değeri inset-0 ile değiştirildi; pencere yüksekliği görünümle sınırlandı ve iç kaydırma eklendi. Ortak modal odağı/arka plan izolasyonu, başlık bağlantısı, kapatma adı, beş arama alanının erişilebilir etiketi ve sektör düğmelerinin basılı durumu eklendi.
|
||||||
|
- Ürün araması native dialog üst katmanında, kılavuz ise özel portal penceresindeydi. ? ile kılavuz açılınca ortak izolasyon ürün aramasını inert yapıyor fakat native üst katman nedeniyle odak kılavuza geçemiyordu. Bu çakışma tarayıcıda tekrar üretildi. Ürün araması diğer pencerelerle aynı modal yığınını kullanıyor; gövde kaydırma kilidi ve arama durumunun korunması devam ediyor.
|
||||||
|
- On iki tarayıcı kabulü geçti: detaylı arama ilk odağı/ileri-geri Tab, görünüm sınırları, beş alan etiketi, Kayseri+laboratuvar tek sonucu; ürün araması odağı/Tab; ürün üstünde kılavuz ve geri dönüş; sentetik ürün sonucundan doğru ihale detayına geçiş; detay kapanınca başlıktaki ÜRÜN ARA düğmesine dönüş; yeniden açıldığında önceki sorgu ve sonucun korunması. Kanıt `rapor/qa-search-modals-focus.json`. Kılavuz kapanış ölçümü QA düzeneği sıcak yenilenirken alındığı için temiz oturumda tekrarlandı; son ölçüm geçti.
|
||||||
|
- QA sunucusu yalnız “QA pencere testi” sorgusuna bir sentetik ürün sonucu döndürüyor; bu kaynak arama kalitesinin testi değildir. 275 test ve build başarılı. Native dialog yerine ortak yığın kullanımı nedeniyle mobil tarayıcı/gerçek ekran okuyucu kabulü ayrıca gereklidir. Genel plan açık.
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useId } from 'react';
|
||||||
|
import {useModalFocus} from '../hooks/useModalFocus';
|
||||||
import { useApp } from '../context/AppContext';
|
import { useApp } from '../context/AppContext';
|
||||||
import { CITIES_LIST, INSTITUTION_TYPES_LIST } from '../data/constants';
|
import { CITIES_LIST, INSTITUTION_TYPES_LIST } from '../data/constants';
|
||||||
import { SlidersHorizontal, Search, RotateCcw, X } from 'lucide-react';
|
import { SlidersHorizontal, Search, RotateCcw, X } from 'lucide-react';
|
||||||
@@ -23,6 +24,8 @@ export const DetailedSearchModal = () => {
|
|||||||
setOnlyValidOnly,
|
setOnlyValidOnly,
|
||||||
setActiveTab
|
setActiveTab
|
||||||
} = useApp();
|
} = useApp();
|
||||||
|
const titleId=useId();
|
||||||
|
const modalFocus=useModalFocus(isDetailedSearchOpen,()=>setIsDetailedSearchOpen(false));
|
||||||
|
|
||||||
const [iknQuery, setIknQuery] = useState('');
|
const [iknQuery, setIknQuery] = useState('');
|
||||||
const [kurumQuery, setKurumQuery] = useState('');
|
const [kurumQuery, setKurumQuery] = useState('');
|
||||||
@@ -49,16 +52,17 @@ export const DetailedSearchModal = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-[#00000080] z-50 flex items-center justify-center p-4 bg-slate-900/60 backdrop-blur-xs animate-fade-in">
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/60 backdrop-blur-xs animate-fade-in">
|
||||||
<div className="bg-white rounded-2xl border border-slate-200 shadow-2xl w-full max-w-2xl overflow-hidden">
|
<div {...modalFocus} role="dialog" aria-modal="true" aria-labelledby={titleId} tabIndex={-1} className="bg-white rounded-2xl border border-slate-200 shadow-2xl w-full max-w-2xl max-h-[92vh] overflow-y-auto">
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="bg-[#1f2937] text-white px-6 py-4 flex items-center justify-between">
|
<div className="bg-[#1f2937] text-white px-6 py-4 flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2.5 font-bold text-base">
|
<div className="flex items-center gap-2.5 font-bold text-base">
|
||||||
<SlidersHorizontal className="w-5 h-5 text-blue-400" />
|
<SlidersHorizontal className="w-5 h-5 text-blue-400" />
|
||||||
<span>Detaylı İhale Arama</span>
|
<span id={titleId}>Detaylı İhale Arama</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
aria-label="Detaylı aramayı kapat"
|
||||||
onClick={() => setIsDetailedSearchOpen(false)}
|
onClick={() => setIsDetailedSearchOpen(false)}
|
||||||
className="text-slate-400 hover:text-white p-1 rounded-lg transition-colors"
|
className="text-slate-400 hover:text-white p-1 rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
@@ -74,6 +78,7 @@ export const DetailedSearchModal = () => {
|
|||||||
<div>
|
<div>
|
||||||
<label className="block font-bold text-slate-700 mb-1">Anahtar Kelime</label>
|
<label className="block font-bold text-slate-700 mb-1">Anahtar Kelime</label>
|
||||||
<input
|
<input
|
||||||
|
aria-label="Anahtar Kelime"
|
||||||
type="text"
|
type="text"
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
@@ -85,6 +90,7 @@ export const DetailedSearchModal = () => {
|
|||||||
<div>
|
<div>
|
||||||
<label className="block font-bold text-slate-700 mb-1">Alım Tipi</label>
|
<label className="block font-bold text-slate-700 mb-1">Alım Tipi</label>
|
||||||
<select
|
<select
|
||||||
|
aria-label="Alım Tipi"
|
||||||
value={selectedAlimTuru}
|
value={selectedAlimTuru}
|
||||||
onChange={(e) => setSelectedAlimTuru(e.target.value)}
|
onChange={(e) => setSelectedAlimTuru(e.target.value)}
|
||||||
className="w-full bg-slate-50 border border-slate-300 rounded-lg px-3 py-2 text-slate-800"
|
className="w-full bg-slate-50 border border-slate-300 rounded-lg px-3 py-2 text-slate-800"
|
||||||
@@ -99,6 +105,7 @@ export const DetailedSearchModal = () => {
|
|||||||
<div>
|
<div>
|
||||||
<label className="block font-bold text-slate-700 mb-1">Kurum Adı</label>
|
<label className="block font-bold text-slate-700 mb-1">Kurum Adı</label>
|
||||||
<input
|
<input
|
||||||
|
aria-label="Kurum Adı"
|
||||||
type="text"
|
type="text"
|
||||||
value={kurumQuery}
|
value={kurumQuery}
|
||||||
onChange={(e) => setKurumQuery(e.target.value)}
|
onChange={(e) => setKurumQuery(e.target.value)}
|
||||||
@@ -110,6 +117,7 @@ export const DetailedSearchModal = () => {
|
|||||||
<div>
|
<div>
|
||||||
<label className="block font-bold text-slate-700 mb-1">Kayıt No (İKN / DT No)</label>
|
<label className="block font-bold text-slate-700 mb-1">Kayıt No (İKN / DT No)</label>
|
||||||
<input
|
<input
|
||||||
|
aria-label="Kayıt No (İKN / DT No)"
|
||||||
type="text"
|
type="text"
|
||||||
value={iknQuery}
|
value={iknQuery}
|
||||||
onChange={(e) => setIknQuery(e.target.value)}
|
onChange={(e) => setIknQuery(e.target.value)}
|
||||||
@@ -131,6 +139,7 @@ export const DetailedSearchModal = () => {
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
aria-pressed={secili}
|
||||||
key={s}
|
key={s}
|
||||||
onClick={() => toggleSektor(s)}
|
onClick={() => toggleSektor(s)}
|
||||||
className={`px-2.5 py-1 rounded-full border text-[11px] font-semibold transition-colors ${
|
className={`px-2.5 py-1 rounded-full border text-[11px] font-semibold transition-colors ${
|
||||||
@@ -149,6 +158,7 @@ export const DetailedSearchModal = () => {
|
|||||||
<div>
|
<div>
|
||||||
<label className="block font-bold text-slate-700 mb-1">Şehir</label>
|
<label className="block font-bold text-slate-700 mb-1">Şehir</label>
|
||||||
<select
|
<select
|
||||||
|
aria-label="Şehir"
|
||||||
value={selectedCity}
|
value={selectedCity}
|
||||||
onChange={(e) => setSelectedCity(e.target.value)}
|
onChange={(e) => setSelectedCity(e.target.value)}
|
||||||
className="w-full bg-slate-50 border border-slate-300 rounded-lg px-3 py-2 text-slate-800"
|
className="w-full bg-slate-50 border border-slate-300 rounded-lg px-3 py-2 text-slate-800"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { deadlineInfo } from '../services/tenderCalendar';
|
import { deadlineInfo } from '../services/tenderCalendar';
|
||||||
import React, { useState, useEffect, useRef, useId } from 'react';
|
import React, { useState, useEffect, useRef, useId } from 'react';
|
||||||
|
import {useModalFocus} from '../hooks/useModalFocus';
|
||||||
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,18 +28,14 @@ export const UrunAramaModal = () => {
|
|||||||
isUrunAramaOpen, setIsUrunAramaOpen, setActiveTenderDetail, openSearchTender, tenders,
|
isUrunAramaOpen, setIsUrunAramaOpen, setActiveTenderDetail, openSearchTender, tenders,
|
||||||
urunAramaBaslangic, setUrunAramaBaslangic,
|
urunAramaBaslangic, setUrunAramaBaslangic,
|
||||||
} = useApp();
|
} = useApp();
|
||||||
const modal = useRef(null);
|
const modalFocus=useModalFocus(isUrunAramaOpen,()=>setIsUrunAramaOpen(false));
|
||||||
const titleId = useId();
|
const titleId = useId();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isUrunAramaOpen) return;
|
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;
|
const previous = document.body.style.overflow;
|
||||||
document.body.style.overflow = 'hidden';
|
document.body.style.overflow = 'hidden';
|
||||||
return () => {
|
return () => {
|
||||||
dialog.close(); document.body.style.overflow = previous;
|
document.body.style.overflow = previous;
|
||||||
if (opener?.isConnected && opener.getClientRects().length) opener.focus();
|
|
||||||
};
|
};
|
||||||
}, [isUrunAramaOpen]);
|
}, [isUrunAramaOpen]);
|
||||||
const requestVersion = useRef(0);
|
const requestVersion = useRef(0);
|
||||||
@@ -128,16 +125,8 @@ 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 (
|
||||||
<dialog ref={modal} aria-labelledby={titleId} onKeyDown={event => {
|
<div {...modalFocus} role="dialog" aria-modal="true" aria-labelledby={titleId} tabIndex={-1}
|
||||||
if (event.key !== 'Tab') return;
|
className="fixed inset-0 z-50 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">
|
||||||
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-3 sm:px-6 py-4 flex items-center justify-between gap-2">
|
<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">
|
||||||
@@ -152,7 +141,7 @@ export const UrunAramaModal = () => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button autoFocus aria-label="Ürün aramasını kapat" onClick={() => setIsUrunAramaOpen(false)} className="text-slate-400 hover:text-white p-1 rounded-lg">
|
<button 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>
|
||||||
@@ -346,6 +335,6 @@ export const UrunAramaModal = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -57,6 +57,9 @@ const {createTelegramLinker}=await import('../server/telegram-link.mjs');
|
|||||||
const qaTelegramLinker=createTelegramLinker(qaTelegramStore,()=>Date.now()+telegramClockOffset);
|
const qaTelegramLinker=createTelegramLinker(qaTelegramStore,()=>Date.now()+telegramClockOffset);
|
||||||
const ui=await createServer({server:{host:'127.0.0.1',port:0,proxy:{'/api':{target:'http://127.0.0.1:'+port}}},plugins:[{name:'qa-fixtures',configureServer(server){server.middlewares.use(async(req,res,next)=>{
|
const ui=await createServer({server:{host:'127.0.0.1',port:0,proxy:{'/api':{target:'http://127.0.0.1:'+port}}},plugins:[{name:'qa-fixtures',configureServer(server){server.middlewares.use(async(req,res,next)=>{
|
||||||
if(req.url==='/qa-print-cetvel'){res.setHeader('Content-Type','text/html; charset=utf-8');res.end(printBidFixture().html);return;}
|
if(req.url==='/qa-print-cetvel'){res.setHeader('Content-Type','text/html; charset=utf-8');res.end(printBidFixture().html);return;}
|
||||||
|
if(req.url?.startsWith('/api/urun-ara?')&&new URL(req.url,'http://localhost').searchParams.get('q')==='QA pencere testi'){
|
||||||
|
res.writeHead(200,{'Content-Type':'application/json'});res.end(JSON.stringify({success:true,totalCount:1,list:[{...tenders[0],ihaleTarihSaat:tenders[0].tarih+' '+tenders[0].saat,tur:'QA'}]}));return;
|
||||||
|
}
|
||||||
if(req.url==='/qa-responsive'){res.setHeader('Content-Type','text/html; charset=utf-8');res.end(`<html lang="tr"><h1>Yerel ekran boyutu testi</h1><p>Gerçek uygulama ayrı bir görünüm alanında yüklenir.</p><button data-width="390">390 piksel</button><button data-width="768">768 piksel</button><button data-width="1440">1440 piksel</button><pre id="metrics" aria-label="Görünüm ölçümleri"></pre><iframe title="İhaleist test görünümü" src="/" style="display:block;width:390px;height:900px;border:0"></iframe><script>document.querySelectorAll('button').forEach(button=>button.onclick=()=>document.querySelector('iframe').style.width=button.dataset.width+'px');setInterval(()=>{const doc=document.querySelector('iframe').contentDocument;if(!doc?.body)return;const width=doc.documentElement.clientWidth;const groups=[...doc.querySelectorAll('nav[aria-label="Çalışma alanları"] button')].map(el=>({label:el.textContent.trim(),left:Math.round(el.getBoundingClientRect().left),right:Math.round(el.getBoundingClientRect().right)}));document.getElementById('metrics').textContent=JSON.stringify({width,scrollWidth:doc.documentElement.scrollWidth,groups},null,2);},400);</script></html>`);return;}
|
if(req.url==='/qa-responsive'){res.setHeader('Content-Type','text/html; charset=utf-8');res.end(`<html lang="tr"><h1>Yerel ekran boyutu testi</h1><p>Gerçek uygulama ayrı bir görünüm alanında yüklenir.</p><button data-width="390">390 piksel</button><button data-width="768">768 piksel</button><button data-width="1440">1440 piksel</button><pre id="metrics" aria-label="Görünüm ölçümleri"></pre><iframe title="İhaleist test görünümü" src="/" style="display:block;width:390px;height:900px;border:0"></iframe><script>document.querySelectorAll('button').forEach(button=>button.onclick=()=>document.querySelector('iframe').style.width=button.dataset.width+'px');setInterval(()=>{const doc=document.querySelector('iframe').contentDocument;if(!doc?.body)return;const width=doc.documentElement.clientWidth;const groups=[...doc.querySelectorAll('nav[aria-label="Çalışma alanları"] button')].map(el=>({label:el.textContent.trim(),left:Math.round(el.getBoundingClientRect().left),right:Math.round(el.getBoundingClientRect().right)}));document.getElementById('metrics').textContent=JSON.stringify({width,scrollWidth:doc.documentElement.scrollWidth,groups},null,2);},400);</script></html>`);return;}
|
||||||
if(req.url==='/qa-telegram-controls'){res.setHeader('Content-Type','text/html; charset=utf-8');res.end(`<html lang="tr"><h1>Yerel Telegram testi</h1><p>Gerçek Telegram mesajı gönderilmez.</p><button id="recover">Telegram durum hatasını kapat</button><button id="enable">Sentetik bağlamayı aç</button><button id="receive">Son kodu sentetik sohbete ulaştır</button><button id="expire">Kod süresini bitir</button><p role="status"></p><script>for(const mode of ['recover','enable','receive','expire'])document.getElementById(mode).onclick=async()=>{const r=await fetch('/qa-telegram-fixture?mode='+mode,{method:'POST'});document.querySelector('[role=status]').textContent=await r.text();};</script></html>`);return;}
|
if(req.url==='/qa-telegram-controls'){res.setHeader('Content-Type','text/html; charset=utf-8');res.end(`<html lang="tr"><h1>Yerel Telegram testi</h1><p>Gerçek Telegram mesajı gönderilmez.</p><button id="recover">Telegram durum hatasını kapat</button><button id="enable">Sentetik bağlamayı aç</button><button id="receive">Son kodu sentetik sohbete ulaştır</button><button id="expire">Kod süresini bitir</button><p role="status"></p><script>for(const mode of ['recover','enable','receive','expire'])document.getElementById(mode).onclick=async()=>{const r=await fetch('/qa-telegram-fixture?mode='+mode,{method:'POST'});document.querySelector('[role=status]').textContent=await r.text();};</script></html>`);return;}
|
||||||
if(req.url?.startsWith('/qa-telegram-fixture?')&&req.method==='POST'){
|
if(req.url?.startsWith('/qa-telegram-fixture?')&&req.method==='POST'){
|
||||||
|
|||||||
Reference in New Issue
Block a user