Files
linuxcncWebWASM/web/react/src/DraggableDialog.tsx

56 lines
3.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>;
}