fix: manage guide and profile modal keyboard focus

This commit is contained in:
2026-09-06 15:22:36 +03:00
parent 1d00f4fa91
commit abbf24078d
3 changed files with 23 additions and 17 deletions
+8
View File
@@ -1208,3 +1208,11 @@ Taslak türü seçicisine erişilebilir ad eklendi. Üretim derlemesi geçti. Ö
- 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. - 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ı. - 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. - 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.
### 2026-09-06 — Kılavuz ve firma profilinin klavye kabulü
- Kullanım kılavuzu açıldığında odak arka plandaki yardım düğmesinde kalıyor ve panel dialog olarak tanımlanmıyordu. Tarayıcıda başlangıç odağının panel dışında ve dialog sayısının sıfır olması doğrulandı. Kılavuza başlıklı modal rolü ve ortak useModalFocus eklendi; kapatma düğmesi adı kılavuzu açıkça belirtiyor.
- Global Escape dinleyicisi kaldırıldı; kapanışı odaktaki pencere yönetiyor. Soru işareti kısayolu başka işlemin engellediği olayları, komut kombinasyonlarını ve düzenlenebilir metin alanlarını açma/kapatma komutu olarak kullanmıyor. Firma profili mevcut ayrı odak döngüsü yerine aynı ortak kancaya geçti; istek iptali ve bileşen yaşam döngüsü korunuyor.
- On gerçek tarayıcı kontrolü geçti: kılavuz ilk odağı, ileri/geri Tab ve açan düğmeye dönüş; profilde ilk odak/ileri/geri döngü; profilin üstünde ? ile kılavuz açma; ilk Escape ile yalnız kılavuzu kapatıp profile odak dönüşü; ikinci Escape ile profili kapatıp başlıktaki açan düğmeye dönüş. Kanıt `rapor/qa-guide-profile-focus.json`.
- Build başarılı. Önceki 275 birim/entegrasyon testi sonucu geçerli; JSX odak bağlamaları için tarayıcı kabulü yapıldı, tüm paket tekrarlanmadı. Bu kabul ekran okuyucunun sanal imleci/arka planın inert olması veya bütün mobil kontrolleri kapsamaz. Genel plan açık.
+6 -12
View File
@@ -1,5 +1,6 @@
import {HealthPortfolioEditor} from './HealthPortfolioEditor'; import {HealthPortfolioEditor} from './HealthPortfolioEditor';
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 {getToken} from '../services/auth'; import {getToken} from '../services/auth';
import {X,Loader2} from 'lucide-react'; import {X,Loader2} from 'lucide-react';
@@ -13,10 +14,11 @@ function ProfileEditor(){
const [url,setUrl]=useState(userProfile.website || ''),[caps,setCaps]=useState(userProfile.scrapedCapabilities || {}); const [url,setUrl]=useState(userProfile.website || ''),[caps,setCaps]=useState(userProfile.scrapedCapabilities || {});
const [threshold,setThreshold]=useState(uyumEsigi),[busy,setBusy]=useState(''),[error,setError]=useState(null),[message,setMessage]=useState(''); const [threshold,setThreshold]=useState(uyumEsigi),[busy,setBusy]=useState(''),[error,setError]=useState(null),[message,setMessage]=useState('');
const [entries,setEntries]=useState({}); const [entries,setEntries]=useState({});
const alive=useRef(true),controller=useRef(null),locked=useRef(false),root=useRef(null),titleId=useId(); const alive=useRef(true),controller=useRef(null),locked=useRef(false),titleId=useId();
const modalFocus=useModalFocus(true,()=>setIsCompanyScannerOpen(false));
useEffect(()=>{ useEffect(()=>{
alive.current=true;const opener=document.activeElement;root.current?.querySelector('button')?.focus(); alive.current=true;
return()=>{alive.current=false;controller.current?.abort();if(opener?.isConnected)opener.focus();}; return()=>{alive.current=false;controller.current?.abort();};
},[]); },[]);
const run=async(kind,operation)=>{ const run=async(kind,operation)=>{
if(locked.current)return;locked.current=true;setBusy(kind);setError(null);setMessage(''); if(locked.current)return;locked.current=true;setBusy(kind);setError(null);setMessage('');
@@ -32,16 +34,8 @@ function ProfileEditor(){
if(alive.current)setMessage('Firma profili sunucuya kaydedildi. Eşleşmeler kayıtlı profilinizle güncellenir.'); if(alive.current)setMessage('Firma profili sunucuya kaydedildi. Eşleşmeler kayıtlı profilinizle güncellenir.');
}); });
const edit=(key,values)=>{setCaps(previous=>({...previous,[key]:values}));setMessage('Kaydedilmemiş değişiklikler var.');}; const edit=(key,values)=>{setCaps(previous=>({...previous,[key]:values}));setMessage('Kaydedilmemiş değişiklikler var.');};
const keyDown=e=>{
if(e.key==='Escape'){e.preventDefault();e.stopPropagation();setIsCompanyScannerOpen(false);}
if(e.key!=='Tab')return;
const controls=[...root.current.querySelectorAll('button,input,select,textarea,a[href]')].filter(el=>!el.disabled&&el.getClientRects().length);
const first=controls[0],last=controls.at(-1);
if(e.shiftKey&&document.activeElement===first){e.preventDefault();last?.focus();}
else if(!e.shiftKey&&document.activeElement===last){e.preventDefault();first?.focus();}
};
return <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/70"> return <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/70">
<section ref={root} role="dialog" aria-modal="true" aria-labelledby={titleId} onKeyDown={keyDown} className="bg-white rounded-2xl w-full max-w-3xl max-h-[92vh] overflow-y-auto p-5 space-y-4"> <section {...modalFocus} role="dialog" aria-modal="true" aria-labelledby={titleId} tabIndex={-1} className="bg-white rounded-2xl w-full max-w-3xl max-h-[92vh] overflow-y-auto p-5 space-y-4">
<div className="flex justify-between gap-3"><h2 id={titleId} className="font-bold text-lg">Firma ürün ve faaliyet profili</h2><button aria-label="Firma profilini kapat" onClick={()=>setIsCompanyScannerOpen(false)}><X/></button></div> <div className="flex justify-between gap-3"><h2 id={titleId} className="font-bold text-lg">Firma ürün ve faaliyet profili</h2><button aria-label="Firma profilini kapat" onClick={()=>setIsCompanyScannerOpen(false)}><X/></button></div>
<p className="text-sm text-slate-600">Ürünlerinizi elle ekleyebilir veya web sitenizden öneri alabilirsiniz. Tarama, ürün uygunluğunu ya da ÜTS/sertifika geçerliliğini doğrulamaz. Kaydetmeden kapatırsanız bu ekrandaki değişiklikler uygulanmaz.</p> <p className="text-sm text-slate-600">Ürünlerinizi elle ekleyebilir veya web sitenizden öneri alabilirsiniz. Tarama, ürün uygunluğunu ya da ÜTS/sertifika geçerliliğini doğrulamaz. Kaydetmeden kapatırsanız bu ekrandaki değişiklikler uygulanmaz.</p>
<form onSubmit={scan} className="flex gap-2 items-end flex-wrap"> <form onSubmit={scan} className="flex gap-2 items-end flex-wrap">
+9 -5
View File
@@ -1,5 +1,6 @@
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState, useId } from 'react';
import {useModalFocus} from '../hooks/useModalFocus';
import { useApp } from '../context/AppContext'; import { useApp } from '../context/AppContext';
import { HelpCircle, X, BookOpen, Lightbulb, ListChecks } from 'lucide-react'; import { HelpCircle, X, BookOpen, Lightbulb, ListChecks } from 'lucide-react';
@@ -79,10 +80,12 @@ const GENEL = {
export const KullanimKilavuzu = () => { export const KullanimKilavuzu = () => {
const { activeTab } = useApp(); const { activeTab } = useApp();
const [acik, setAcik] = useState(false); const [acik, setAcik] = useState(false);
const titleId=useId();
const modalFocus=useModalFocus(acik,()=>setAcik(false));
useEffect(() => { useEffect(() => {
const h = (e) => { const h = (e) => {
if (e.key === '?' && !/input|textarea|select/i.test(e.target?.tagName || '')) setAcik((o) => !o); if(e.defaultPrevented || e.ctrlKey || e.altKey || e.metaKey || e.target?.isContentEditable)return;
if (e.key === 'Escape') setAcik(false); if (e.key === '?' && !/input|textarea|select/i.test(e.target?.tagName || '')) {e.preventDefault();setAcik((o) => !o);}
}; };
window.addEventListener('keydown', h); window.addEventListener('keydown', h);
return () => window.removeEventListener('keydown', h); return () => window.removeEventListener('keydown', h);
@@ -113,6 +116,7 @@ export const KullanimKilavuzu = () => {
<div className="fixed inset-0 z-50 flex justify-end" onClick={() => setAcik(false)}> <div className="fixed inset-0 z-50 flex justify-end" onClick={() => setAcik(false)}>
<div className="absolute inset-0 bg-slate-900/40" /> <div className="absolute inset-0 bg-slate-900/40" />
<aside <aside
{...modalFocus} role="dialog" aria-modal="true" aria-labelledby={titleId} tabIndex={-1}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
className="relative h-full w-full max-w-md bg-white shadow-2xl flex flex-col animate-fade-in" className="relative h-full w-full max-w-md bg-white shadow-2xl flex flex-col animate-fade-in"
> >
@@ -120,11 +124,11 @@ export const KullanimKilavuzu = () => {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<BookOpen className="w-5 h-5 text-amber-400" /> <BookOpen className="w-5 h-5 text-amber-400" />
<div> <div>
<p className="text-[10px] uppercase tracking-wider text-slate-400 font-bold">Kullanım kılavuzu</p> <p id={titleId} className="text-[10px] uppercase tracking-wider text-slate-400 font-bold">Kullanım kılavuzu</p>
<h3 className="font-extrabold text-sm">{k.baslik}</h3> <h3 className="font-extrabold text-sm">{k.baslik}</h3>
</div> </div>
</div> </div>
<button onClick={() => setAcik(false)} className="text-slate-400 hover:text-white p-1" aria-label="Kapat"><X className="w-5 h-5" /></button> <button onClick={() => setAcik(false)} className="text-slate-400 hover:text-white p-1" aria-label="Kullanım kılavuzunu kapat"><X className="w-5 h-5" /></button>
</div> </div>
<div className="flex-1 overflow-y-auto p-5 space-y-5"> <div className="flex-1 overflow-y-auto p-5 space-y-5">
<p className="text-xs text-slate-600">Üst menüde Keşfet ilanları ve takvimi, Hazırlan dosyaları ve hesap araçlarını, Pazar sonuçları ve analizleri, Yönetim hesap ve bağlantı ayarlarını açar. AI model ayarları yalnız yöneticilere görünür.</p> <p className="text-xs text-slate-600">Üst menüde Keşfet ilanları ve takvimi, Hazırlan dosyaları ve hesap araçlarını, Pazar sonuçları ve analizleri, Yönetim hesap ve bağlantı ayarlarını açar. AI model ayarları yalnız yöneticilere görünür.</p>