diff --git a/docs/quality/uygulama-durumu.md b/docs/quality/uygulama-durumu.md index 531ca94..d8d02d6 100644 --- a/docs/quality/uygulama-durumu.md +++ b/docs/quality/uygulama-durumu.md @@ -1199,3 +1199,12 @@ Taslak türü seçicisine erişilebilir ad eklendi. Üretim derlemesi geçti. Ö - Güncel iki favorinin filtresi yalnız Giresun/Kayseri ihalelerini gösterdi. Güncel sürümden Giresun kaldırılınca filtre tek Kayseri sonucuna düştü; diğer sekmenin yenilenmesinden sonra da aynı kayıt görüldü. Çakışma mesajı kullanıcıya değişikliklerini koruyup yenilemesi gerektiğini belirtiyor. Dokuz kontrol geçti: `rapor/qa-favorite-two-tabs.json`. - Kartlardaki kurum yıldızları aynı erişilebilir ada sahipti. Ekran okuyucu ve klavye kullanıcılarının eylemin hangi kuruma ait olduğunu ayırt edebilmesi için erişilebilir ada kurum unvanı eklendi; ekle/çıkar ve basılı durum bilgisi korunuyor. Derleme başarılı. Bu etiket değişimi için davranışı tekrar eden birim testi eklenmedi; önceki 275 test sonucu geçerli, tüm paket yeniden çalıştırılmadı. - Bu kabul iki sekmede favori ekleme/kaldırma ve filtre kalıcılığını kapsar. Bildirim gönderimi, ayrı firma rolleri veya bütün ekran okuyucu/klavye akışının kabulü değildir. Genel plan açık. + + +### 2026-09-06 — İhale ve analiz pencerelerinde klavye odağı + +- İhale detayının açılış kodu ilk button öğesini seçiyordu. Kaynak geçmişi eklendikten sonra bu öğe kapalı details içindeki Geçmişi yükle düğmesi oldu; odaklanma başarısız kalıyor ve klavyede Shift+Tab arka plandaki Excel Hazırla düğmesine gidiyordu. Tarayıcıda doğrudan tekrar üretildi. Kapalı details içindeki öğenin getClientRects değeri de boş değildi; yalnız geometri filtresi yeterli değildi. +- Ortak useModalFocus kancası görünür/etkin kontrolleri seçiyor, kapalı details içindeki summary dışı öğeleri, inert alanları ve devre dışı kontrolleri atlıyor. Tab/Shift+Tab sınırları, Escape ve açan öğeye dönüş ortaklaştırıldı. İhale detayının ilk odağı görünür kaynak geçmişi summary öğesinde; kapalı yükleme düğmesine odaklanılmıyor. +- Teknik şartname analiz penceresinde eksik olan dialog rolü, modal bilgisi, başlık bağlantısı ve odak yönetimi eklendi. Escape devam eden AI işini iptal etmiyor; mevcut Kapat düğmesiyle aynı pencere kapanışını yapıyor. +- On gerçek tarayıcı kontrolü geçti: iki pencerede başlangıç odağı, ileri/geri sarma, Escape sonrası açana dönüş; ihale üstünde analiz açılması ve Escape ile yalnız analiz kapanıp odağın alttaki açan düğmeye dönmesi. Kanıt `rapor/qa-modal-keyboard-focus.json`. Analiz için ilk sıcak yenileme denemesi eski görünümü gösterdiğinden sunucu yeniden başlatıldı ve güncel kodla tekrar doğrulandı; başarısız ilk deneme kabul sayılmadı. +- 275 test ve build başarılı. Bu kabul odak/klavye davranışını kapsar; bütün modallar, mobil klavye ve gerçek ekran okuyucunun sanal imleç davranışı henüz tamamlanmış sayılmaz. Genel plan açık. diff --git a/src/components/AiSpecAnalyzerModal.jsx b/src/components/AiSpecAnalyzerModal.jsx index d41de2b..3c4f937 100644 --- a/src/components/AiSpecAnalyzerModal.jsx +++ b/src/components/AiSpecAnalyzerModal.jsx @@ -1,6 +1,7 @@ import { AiJobHistory } from './AiJobHistory'; import {getToken} from '../services/auth'; -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useId } from 'react'; +import {useModalFocus} from '../hooks/useModalFocus'; import { useApp } from '../context/AppContext'; import { Sparkles, Upload, FileText, CheckCircle2, AlertTriangle, X, Loader2, TrendingUp, Sliders, Info } from 'lucide-react'; import confetti from 'canvas-confetti'; @@ -14,6 +15,8 @@ export const AiSpecAnalyzerModal = () => { isAiSpecAnalyzerOpen, setIsAiSpecAnalyzerOpen, analyzeSpecPdfWithGemini, openSavedAnalysis, authUser, aiAnalysisResults, aiAnalyzing, aiJobStatus, activeAiModel, activeTenderDetail, } = useApp(); + const dialogTitleId=useId(); + const modalFocus=useModalFocus(isAiSpecAnalyzerOpen,()=>setIsAiSpecAnalyzerOpen(false)); const [isAnalyzing, setIsAnalyzing] = useState(false); const [selectedFile, setSelectedFile] = useState(null); @@ -96,7 +99,7 @@ export const AiSpecAnalyzerModal = () => { return (
-
+
{/* Header */}
@@ -106,7 +109,7 @@ export const AiSpecAnalyzerModal = () => {
-

Teknik Şartname & Teklif Analiz Asistanı

+

Teknik Şartname & Teklif Analiz Asistanı

{modelEtiketi} diff --git a/src/components/TenderDetailModal.jsx b/src/components/TenderDetailModal.jsx index 8a20d1f..509d2b3 100644 --- a/src/components/TenderDetailModal.jsx +++ b/src/components/TenderDetailModal.jsx @@ -1,5 +1,6 @@ import { TenderSourceHistory } from './TenderSourceHistory'; -import React, { useState, useEffect, useRef, useId } from 'react'; +import React, { useState, useEffect, useId } from 'react'; +import {useModalFocus} from '../hooks/useModalFocus'; import { useApp } from '../context/AppContext'; import { BidPackageBuilder } from './BidPackageBuilder'; import { TenderDocuments } from './TenderDocuments'; @@ -15,32 +16,9 @@ import { createTenderICS } from '../services/tenderCalendar'; export const TenderDetailModal = () => { const { activeTenderDetail, setActiveTenderDetail, toggleTrackTender, userProfile, setIsAiSpecAnalyzerOpen, gorevAc, gorevKontrol, aiAnalysisResults, tenders, mutateTender, savingCount, saveError, authUser, detailLoad, retryTenderDetail } = useApp(); - const dialogRef = useRef(null); const dialogTitleId = useId(); const isOpen = Boolean(activeTenderDetail); - useEffect(() => { - if (!isOpen) return; - const opener = document.activeElement; - const root = dialogRef.current; - root?.querySelector('button')?.focus(); - return () => { if (opener?.isConnected) opener.focus(); }; - }, [isOpen]); - const handleDialogKey = e => { - if (e.defaultPrevented || e.target.closest('[role="dialog"]') !== dialogRef.current) return; - if (e.key === 'Escape') { - e.preventDefault(); e.stopPropagation(); setActiveTenderDetail(null); return; - } - if (e.key !== 'Tab') return; - const focusable = [...dialogRef.current.querySelectorAll('button, a[href], input, select, textarea, summary, [tabindex]')] - .filter(el => el.tabIndex >= 0 && !el.disabled && el.getClientRects().length && !el.closest('[inert]')); - const first = focusable[0], last = focusable.at(-1); - if (!first) { e.preventDefault(); dialogRef.current.focus(); } - else if (e.shiftKey && (document.activeElement === first || document.activeElement === dialogRef.current)) { - e.preventDefault(); last.focus(); - } else if (!e.shiftKey && document.activeElement === last) { - e.preventDefault(); first.focus(); - } - }; + const {ref:dialogRef,onKeyDown:handleDialogKey}=useModalFocus(isOpen,()=>setActiveTenderDetail(null)); const [activeTab, setActiveTab] = useState('details'); // 'details', 'pdf_stamp', 'ai_assistant', 'checklist', 'kurum_tenders' useEffect(()=>{setActiveTab(activeTenderDetail?.preparationTarget ? 'pdf_stamp' : 'details');},[activeTenderDetail?.id,activeTenderDetail?.preparationTarget]); const noteOwner = authUser?.email || ''; diff --git a/src/hooks/useModalFocus.js b/src/hooks/useModalFocus.js new file mode 100644 index 0000000..f9e4a7c --- /dev/null +++ b/src/hooks/useModalFocus.js @@ -0,0 +1,35 @@ +import {useEffect,useRef} from 'react'; + +function focusableElements(root) { + return [...root.querySelectorAll('button, a[href], input, select, textarea, summary, [tabindex]')].filter(el=>{ + if(el.tabIndex<0 || el.matches(':disabled') || !el.getClientRects().length || el.closest('[inert]'))return false; + if(['hidden','collapse'].includes(getComputedStyle(el).visibility))return false; + // Closed details descendants can have rectangles but cannot receive focus. + for(let parent=el.parentElement;parent && parent!==root;parent=parent.parentElement){ + if(parent.tagName==='DETAILS' && !parent.open && !parent.querySelector(':scope > summary')?.contains(el))return false; + } + return true; + }); +} + +export function useModalFocus(open,onClose) { + const ref=useRef(null),close=useRef(onClose); + close.current=onClose; + useEffect(()=>{ + if(!open || !ref.current)return; + const opener=document.activeElement,root=ref.current; + (focusableElements(root)[0] || root).focus(); + return ()=>{if(opener?.isConnected)opener.focus();}; + },[open]); + const onKeyDown=e=>{ + const root=ref.current; + if(e.defaultPrevented || e.target.closest('[role="dialog"]')!==root)return; + if(e.key==='Escape'){e.preventDefault();e.stopPropagation();close.current();return;} + if(e.key!=='Tab')return; + const elements=focusableElements(root),first=elements[0],last=elements.at(-1); + if(!first){e.preventDefault();root.focus();} + else if(e.shiftKey && (document.activeElement===first || document.activeElement===root)){e.preventDefault();last.focus();} + else if(!e.shiftKey && document.activeElement===last){e.preventDefault();first.focus();} + }; + return {ref,onKeyDown}; +}