T23: Add modal keyboard accessibility (Escape, focus trap, ARIA) and dropdown outside-click/Escape close
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user