fix: keep tender and analysis keyboard focus in dialogs

This commit is contained in:
2026-09-06 15:19:54 +03:00
parent ffec9bb54e
commit 1d00f4fa91
4 changed files with 53 additions and 28 deletions
+9
View File
@@ -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`. - 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ı. - 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. - 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.
+6 -3
View File
@@ -1,6 +1,7 @@
import { AiJobHistory } from './AiJobHistory'; import { AiJobHistory } from './AiJobHistory';
import {getToken} from '../services/auth'; 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 { useApp } from '../context/AppContext';
import { Sparkles, Upload, FileText, CheckCircle2, AlertTriangle, X, Loader2, TrendingUp, Sliders, Info } from 'lucide-react'; import { Sparkles, Upload, FileText, CheckCircle2, AlertTriangle, X, Loader2, TrendingUp, Sliders, Info } from 'lucide-react';
import confetti from 'canvas-confetti'; import confetti from 'canvas-confetti';
@@ -14,6 +15,8 @@ export const AiSpecAnalyzerModal = () => {
isAiSpecAnalyzerOpen, setIsAiSpecAnalyzerOpen, isAiSpecAnalyzerOpen, setIsAiSpecAnalyzerOpen,
analyzeSpecPdfWithGemini, openSavedAnalysis, authUser, aiAnalysisResults, aiAnalyzing, aiJobStatus, activeAiModel, activeTenderDetail, analyzeSpecPdfWithGemini, openSavedAnalysis, authUser, aiAnalysisResults, aiAnalyzing, aiJobStatus, activeAiModel, activeTenderDetail,
} = useApp(); } = useApp();
const dialogTitleId=useId();
const modalFocus=useModalFocus(isAiSpecAnalyzerOpen,()=>setIsAiSpecAnalyzerOpen(false));
const [isAnalyzing, setIsAnalyzing] = useState(false); const [isAnalyzing, setIsAnalyzing] = useState(false);
const [selectedFile, setSelectedFile] = useState(null); const [selectedFile, setSelectedFile] = useState(null);
@@ -96,7 +99,7 @@ export const AiSpecAnalyzerModal = () => {
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"> <div className="fixed inset-0 z-50 flex items-center justify-center 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 my-auto flex flex-col max-h-[92vh]"> <div {...modalFocus} role="dialog" aria-modal="true" aria-labelledby={dialogTitleId} tabIndex={-1} className="bg-white rounded-2xl border border-slate-200 shadow-2xl w-full max-w-4xl overflow-hidden my-auto flex flex-col max-h-[92vh]">
{/* Header */} {/* Header */}
<div className="bg-linear-to-r from-blue-900 via-indigo-900 to-purple-900 text-white px-6 py-4 flex items-center justify-between border-b border-indigo-800"> <div className="bg-linear-to-r from-blue-900 via-indigo-900 to-purple-900 text-white px-6 py-4 flex items-center justify-between border-b border-indigo-800">
@@ -106,7 +109,7 @@ export const AiSpecAnalyzerModal = () => {
</div> </div>
<div> <div>
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<h2 className="font-extrabold text-base">Teknik Şartname & Teklif Analiz Asistanı</h2> <h2 id={dialogTitleId} className="font-extrabold text-base">Teknik Şartname & Teklif Analiz Asistanı</h2>
<span className="bg-amber-400 text-slate-900 text-[10px] font-black px-2 py-0.5 rounded-full uppercase tracking-wider"> <span className="bg-amber-400 text-slate-900 text-[10px] font-black px-2 py-0.5 rounded-full uppercase tracking-wider">
{modelEtiketi} {modelEtiketi}
</span> </span>
+3 -25
View File
@@ -1,5 +1,6 @@
import { TenderSourceHistory } from './TenderSourceHistory'; 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 { useApp } from '../context/AppContext';
import { BidPackageBuilder } from './BidPackageBuilder'; import { BidPackageBuilder } from './BidPackageBuilder';
import { TenderDocuments } from './TenderDocuments'; import { TenderDocuments } from './TenderDocuments';
@@ -15,32 +16,9 @@ import { createTenderICS } from '../services/tenderCalendar';
export const TenderDetailModal = () => { export const TenderDetailModal = () => {
const { activeTenderDetail, setActiveTenderDetail, toggleTrackTender, userProfile, setIsAiSpecAnalyzerOpen, gorevAc, gorevKontrol, aiAnalysisResults, tenders, mutateTender, savingCount, saveError, authUser, detailLoad, retryTenderDetail } = useApp(); 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 dialogTitleId = useId();
const isOpen = Boolean(activeTenderDetail); const isOpen = Boolean(activeTenderDetail);
useEffect(() => { const {ref:dialogRef,onKeyDown:handleDialogKey}=useModalFocus(isOpen,()=>setActiveTenderDetail(null));
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 [activeTab, setActiveTab] = useState('details'); // 'details', 'pdf_stamp', 'ai_assistant', 'checklist', 'kurum_tenders' const [activeTab, setActiveTab] = useState('details'); // 'details', 'pdf_stamp', 'ai_assistant', 'checklist', 'kurum_tenders'
useEffect(()=>{setActiveTab(activeTenderDetail?.preparationTarget ? 'pdf_stamp' : 'details');},[activeTenderDetail?.id,activeTenderDetail?.preparationTarget]); useEffect(()=>{setActiveTab(activeTenderDetail?.preparationTarget ? 'pdf_stamp' : 'details');},[activeTenderDetail?.id,activeTenderDetail?.preparationTarget]);
const noteOwner = authUser?.email || ''; const noteOwner = authUser?.email || '';
+35
View File
@@ -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};
}