89 lines
2.5 KiB
TypeScript
89 lines
2.5 KiB
TypeScript
"use client";
|
||
|
||
// Dialog-Basis-Komponente — SHL-01. WCAG 2.1 AA: Fokus-Falle, ESC schließt, Tastaturbedienung vollständig.
|
||
|
||
import { useEffect, useRef } from "react";
|
||
import type { ReactNode } from "react";
|
||
import { useI18n } from "../i18n/i18n";
|
||
|
||
const FOCUSABLE_SELECTOR =
|
||
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
||
|
||
export interface DialogProps {
|
||
open: boolean;
|
||
onClose: () => void;
|
||
titleId: string;
|
||
title: string;
|
||
children: ReactNode;
|
||
}
|
||
|
||
export function Dialog({ open, onClose, titleId, title, children }: DialogProps) {
|
||
const { t } = useI18n();
|
||
const dialogRef = useRef<HTMLDivElement>(null);
|
||
const previouslyFocused = useRef<HTMLElement | null>(null);
|
||
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
previouslyFocused.current = document.activeElement as HTMLElement | null;
|
||
|
||
const node = dialogRef.current;
|
||
const focusables = node?.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR);
|
||
focusables?.[0]?.focus();
|
||
|
||
function handleKeyDown(event: KeyboardEvent) {
|
||
if (event.key === "Escape") {
|
||
onClose();
|
||
return;
|
||
}
|
||
if (event.key !== "Tab" || !node) return;
|
||
|
||
const items = Array.from(node.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR));
|
||
if (items.length === 0) return;
|
||
const first = items[0];
|
||
const last = items[items.length - 1];
|
||
|
||
if (event.shiftKey && document.activeElement === first) {
|
||
event.preventDefault();
|
||
last.focus();
|
||
} else if (!event.shiftKey && document.activeElement === last) {
|
||
event.preventDefault();
|
||
first.focus();
|
||
}
|
||
}
|
||
|
||
document.addEventListener("keydown", handleKeyDown);
|
||
return () => {
|
||
document.removeEventListener("keydown", handleKeyDown);
|
||
previouslyFocused.current?.focus();
|
||
};
|
||
}, [open, onClose]);
|
||
|
||
if (!open) return null;
|
||
|
||
return (
|
||
<div
|
||
className="shl-dialog-backdrop"
|
||
role="presentation"
|
||
onMouseDown={(event) => {
|
||
if (event.target === event.currentTarget) onClose();
|
||
}}
|
||
>
|
||
<div
|
||
ref={dialogRef}
|
||
className="shl-dialog"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby={titleId}
|
||
>
|
||
<div className="shl-dialog-header">
|
||
<h2 id={titleId}>{title}</h2>
|
||
<button type="button" onClick={onClose} aria-label={t("shl.dialog.close")}>
|
||
×
|
||
</button>
|
||
</div>
|
||
<div className="shl-dialog-body">{children}</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|