56 lines
3.8 KiB
TypeScript
56 lines
3.8 KiB
TypeScript
import { useEffect, useId, useLayoutEffect, useRef, type PointerEvent, type ReactNode } from "react";
|
||
|
||
type Props = { open: boolean; title: string; className?: string; onClose: () => void; cancelGuard?: () => boolean; children: ReactNode };
|
||
|
||
// Native dialog provides focus containment, inert background, Escape and focus return.
|
||
// Keeping children mounted preserves drafts and pending import results when closed.
|
||
export function DraggableDialog({ open, title, className = "", onClose, cancelGuard, children }: Props) {
|
||
const ref = useRef<HTMLDialogElement>(null);
|
||
const titleId = useId();
|
||
const drag = useRef<{ id: number; x: number; y: number; left: number; top: number } | null>(null);
|
||
const position = (left: number, top: number) => {
|
||
const dialog = ref.current;
|
||
if (!dialog) return;
|
||
const bounds = dialog.getBoundingClientRect();
|
||
const width = document.documentElement.clientWidth, height = window.innerHeight;
|
||
dialog.style.left = `${Math.max(8, Math.min(left, width - bounds.width - 8))}px`;
|
||
dialog.style.top = `${Math.max(8, Math.min(top, height - bounds.height - 8))}px`;
|
||
};
|
||
useLayoutEffect(() => {
|
||
const dialog = ref.current!;
|
||
if (!open) { if (dialog.open) dialog.close(); return; }
|
||
if (!dialog.open) dialog.showModal();
|
||
const bounds = dialog.getBoundingClientRect();
|
||
position((document.documentElement.clientWidth - bounds.width) / 2, (window.innerHeight - bounds.height) / 2);
|
||
const keepVisible = () => { const rect = dialog.getBoundingClientRect(); position(rect.left, rect.top); };
|
||
const observer = new ResizeObserver(keepVisible);
|
||
observer.observe(dialog); window.addEventListener("resize", keepVisible);
|
||
return () => { observer.disconnect(); window.removeEventListener("resize", keepVisible); drag.current = null; };
|
||
}, [open]);
|
||
useEffect(() => () => { if (ref.current?.open) ref.current.close(); }, []);
|
||
const start = (event: PointerEvent<HTMLElement>) => {
|
||
if (event.button !== 0 || (event.target as HTMLElement).closest("button")) return;
|
||
const bounds = ref.current!.getBoundingClientRect();
|
||
drag.current = { id: event.pointerId, x: event.clientX, y: event.clientY, left: bounds.left, top: bounds.top };
|
||
event.currentTarget.setPointerCapture(event.pointerId);
|
||
};
|
||
const move = (event: PointerEvent<HTMLElement>) => {
|
||
const from = drag.current;
|
||
if (from?.id !== event.pointerId) return;
|
||
position(from.left + event.clientX - from.x, from.top + event.clientY - from.y);
|
||
};
|
||
const stop = () => { drag.current = null; };
|
||
return <dialog ref={ref} className={`machine-dialog ${className}`} aria-modal="true" aria-labelledby={titleId} onCancel={(event) => { event.preventDefault(); if (!cancelGuard?.()) onClose(); }} onKeyDown={(event) => {
|
||
if (event.key !== "Tab") return;
|
||
const elements = [...event.currentTarget.querySelectorAll<HTMLElement>('button:not(:disabled), a[href], input:not(:disabled):not([type="hidden"]), select:not(:disabled), textarea:not(:disabled), summary, [tabindex="0"]')].filter((element) => element.getClientRects().length > 0);
|
||
const first = elements[0], last = elements.at(-1);
|
||
if (event.shiftKey && (document.activeElement === first || !elements.includes(document.activeElement as HTMLElement))) { event.preventDefault(); last?.focus(); }
|
||
else if (!event.shiftKey && (document.activeElement === last || !elements.includes(document.activeElement as HTMLElement))) { event.preventDefault(); first?.focus(); }
|
||
}}>
|
||
<header className="machine-dialog-titlebar" onPointerDown={start} onPointerMove={move} onPointerUp={stop} onPointerCancel={stop} onLostPointerCapture={stop}>
|
||
<strong id={titleId}>{title}</strong><button type="button" autoFocus aria-label={`关闭${title}`} onClick={onClose}>×</button>
|
||
</header>
|
||
<div className="machine-dialog-body">{children}</div>
|
||
</dialog>;
|
||
}
|