T23: Add modal keyboard accessibility (Escape, focus trap, ARIA) and dropdown outside-click/Escape close

This commit is contained in:
2026-06-22 23:44:31 +00:00
parent 90448c1214
commit 04c93adff3
@@ -1,4 +1,4 @@
import React, { useState, useMemo, useCallback } from 'react';
import React, { useState, useMemo, useCallback, useRef, useEffect } from 'react';
import type { YjsDocument } from '../../crdt/YjsDocument';
import {
preparePrint,
@@ -21,10 +21,16 @@ interface PrintPreviewProps {
const PAGE_SIZES: PageSize[] = ['a4', 'a3', 'a2', 'a1'];
const SCALE_PRESETS: ScalePreset[] = ['1:50', '1:100', '1:200', 'custom'];
const FOCUSABLE_SELECTOR =
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
const PrintPreview: React.FC<PrintPreviewProps> = ({ yjsDoc, onClose }) => {
const [settings, setSettings] = useState<PrintSettings>({ ...DEFAULT_PRINT_SETTINGS });
const [currentPage, setCurrentPage] = useState(0);
const modalRef = useRef<HTMLDivElement>(null);
const previouslyFocusedRef = useRef<HTMLElement | null>(null);
const printResult: PrintResult | null = useMemo(() => {
const doc = yjsDoc?.current;
if (!doc) return null;
@@ -49,12 +55,70 @@ const PrintPreview: React.FC<PrintPreviewProps> = ({ yjsDoc, onClose }) => {
}
}, [printResult]);
// Keyboard accessibility: Escape to close, focus trap, focus management
useEffect(() => {
// Store the element that had focus before the modal opened
previouslyFocusedRef.current = document.activeElement as HTMLElement;
// Focus the first focusable element in the modal
const modal = modalRef.current;
if (modal) {
const focusable = modal.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR);
if (focusable.length > 0) {
focusable[0].focus();
} else {
modal.focus();
}
}
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
onClose();
return;
}
if (e.key === 'Tab') {
const modalEl = modalRef.current;
if (!modalEl) return;
const focusableElements = modalEl.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR);
if (focusableElements.length === 0) return;
const first = focusableElements[0];
const last = focusableElements[focusableElements.length - 1];
if (e.shiftKey) {
if (document.activeElement === first || !modalEl.contains(document.activeElement)) {
e.preventDefault();
last.focus();
}
} else {
if (document.activeElement === last || !modalEl.contains(document.activeElement)) {
e.preventDefault();
first.focus();
}
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
// Restore focus to the trigger element
if (previouslyFocusedRef.current) {
previouslyFocusedRef.current.focus();
}
};
}, [onClose]);
if (!printResult) {
return (
<div className="print-preview-overlay" role="dialog" aria-modal="true" aria-label="Print Preview">
<div className="print-preview-overlay" ref={modalRef} role="dialog" aria-modal="true" aria-labelledby="print-preview-title" tabIndex={-1}>
<div className="print-preview-modal">
<div className="print-preview-header">
<h2>Druckvorschau</h2>
<h2 id="print-preview-title">Druckvorschau</h2>
<button className="print-preview-close" onClick={onClose} aria-label="Close"></button>
</div>
<div className="print-preview-empty">Kein Dokument verfügbar.</div>
@@ -68,11 +132,11 @@ const PrintPreview: React.FC<PrintPreviewProps> = ({ yjsDoc, onClose }) => {
const previewUrl = page ? svgToDataUrl(page.svg) : '';
return (
<div className="print-preview-overlay" role="dialog" aria-modal="true" aria-label="Print Preview">
<div className="print-preview-overlay" ref={modalRef} role="dialog" aria-modal="true" aria-labelledby="print-preview-title" tabIndex={-1}>
<div className="print-preview-modal">
{/* Header */}
<div className="print-preview-header">
<h2>Druckvorschau</h2>
<h2 id="print-preview-title">Druckvorschau</h2>
<button className="print-preview-close" onClick={onClose} aria-label="Close"></button>
</div>
@@ -265,7 +329,7 @@ const PrintPreview: React.FC<PrintPreviewProps> = ({ yjsDoc, onClose }) => {
key={i}
className={`print-preview-grid-cell ${i === currentPage ? 'active' : ''}`}
onClick={() => setCurrentPage(i)}
title={`Seite ${i + 1} (Spalte ${p.col + 1}, Zeile ${p.row + 1})}`}
title={`Seite ${i + 1} (Spalte ${p.col + 1}, Zeile ${p.row + 1})`}
>
{i + 1}
</button>