196 lines
7.1 KiB
TypeScript
196 lines
7.1 KiB
TypeScript
import {
|
|
useEffect,
|
|
useId,
|
|
useRef,
|
|
useState,
|
|
type HTMLAttributes,
|
|
type PointerEvent,
|
|
type ReactNode,
|
|
} from "react";
|
|
import { createPortal } from "react-dom";
|
|
import { cn } from "./cn.js";
|
|
import { Icon } from "./Icon.js";
|
|
|
|
const focusableSelector = [
|
|
"a[href]",
|
|
"button:not([disabled])",
|
|
"input:not([disabled])",
|
|
"select:not([disabled])",
|
|
"textarea:not([disabled])",
|
|
"[tabindex]:not([tabindex='-1'])",
|
|
].join(",");
|
|
|
|
export type WindowSize = "sm" | "md" | "lg";
|
|
export type WindowPlacement = "center" | "end";
|
|
|
|
export interface WindowProps extends Omit<HTMLAttributes<HTMLDivElement>, "title"> {
|
|
open: boolean;
|
|
title: ReactNode;
|
|
subtitle?: ReactNode;
|
|
children: ReactNode;
|
|
footer?: ReactNode;
|
|
size?: WindowSize;
|
|
placement?: WindowPlacement;
|
|
closeLabel?: string;
|
|
closeOnBackdrop?: boolean;
|
|
closeOnEscape?: boolean;
|
|
lockBodyScroll?: boolean;
|
|
trapFocus?: boolean;
|
|
draggable?: boolean;
|
|
onClose: () => void;
|
|
}
|
|
|
|
export function Window({
|
|
open,
|
|
title,
|
|
subtitle,
|
|
children,
|
|
footer,
|
|
size = "md",
|
|
placement = "center",
|
|
closeLabel = "Закрыть",
|
|
closeOnBackdrop,
|
|
closeOnEscape = true,
|
|
lockBodyScroll,
|
|
trapFocus,
|
|
draggable = false,
|
|
onClose,
|
|
className,
|
|
style,
|
|
...props
|
|
}: WindowProps) {
|
|
const titleId = useId();
|
|
const descriptionId = useId();
|
|
const dialogRef = useRef<HTMLDivElement>(null);
|
|
const dragRef = useRef<{ pointerId: number; offsetX: number; offsetY: number } | null>(null);
|
|
const [dragPosition, setDragPosition] = useState<{ left: number; top: number } | null>(null);
|
|
const shouldLockBodyScroll = lockBodyScroll ?? placement === "center";
|
|
const shouldTrapFocus = trapFocus ?? placement === "center";
|
|
|
|
useEffect(() => {
|
|
if (!open || typeof document === "undefined") return;
|
|
const previousActiveElement = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
|
const previousOverflow = document.body.style.overflow;
|
|
if (shouldLockBodyScroll) document.body.style.overflow = "hidden";
|
|
|
|
const frame = window.requestAnimationFrame(() => {
|
|
const firstFocusable = dialogRef.current?.querySelector<HTMLElement>(focusableSelector);
|
|
(firstFocusable ?? dialogRef.current)?.focus();
|
|
});
|
|
|
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
if (event.key === "Escape" && closeOnEscape) {
|
|
event.preventDefault();
|
|
onClose();
|
|
return;
|
|
}
|
|
if (event.key !== "Tab" || !shouldTrapFocus || !dialogRef.current) return;
|
|
const focusable = Array.from(dialogRef.current.querySelectorAll<HTMLElement>(focusableSelector));
|
|
if (focusable.length === 0) {
|
|
event.preventDefault();
|
|
dialogRef.current.focus();
|
|
return;
|
|
}
|
|
const first = focusable[0];
|
|
const last = focusable[focusable.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 () => {
|
|
window.cancelAnimationFrame(frame);
|
|
document.removeEventListener("keydown", handleKeyDown);
|
|
if (shouldLockBodyScroll) document.body.style.overflow = previousOverflow;
|
|
previousActiveElement?.focus();
|
|
};
|
|
}, [closeOnEscape, onClose, open, shouldLockBodyScroll, shouldTrapFocus]);
|
|
|
|
useEffect(() => {
|
|
if (!open) setDragPosition(null);
|
|
}, [open]);
|
|
|
|
useEffect(() => {
|
|
if (!draggable || !open) return;
|
|
const handlePointerMove = (event: globalThis.PointerEvent) => {
|
|
const drag = dragRef.current;
|
|
const dialog = dialogRef.current;
|
|
if (!drag || !dialog || event.pointerId !== drag.pointerId) return;
|
|
const bounds = dialog.getBoundingClientRect();
|
|
const left = Math.max(8, Math.min(window.innerWidth - bounds.width - 8, event.clientX - drag.offsetX));
|
|
const top = Math.max(76, Math.min(window.innerHeight - bounds.height - 8, event.clientY - drag.offsetY));
|
|
setDragPosition({ left, top });
|
|
};
|
|
const handlePointerUp = (event: globalThis.PointerEvent) => {
|
|
if (dragRef.current?.pointerId === event.pointerId) dragRef.current = null;
|
|
};
|
|
window.addEventListener("pointermove", handlePointerMove);
|
|
window.addEventListener("pointerup", handlePointerUp);
|
|
window.addEventListener("pointercancel", handlePointerUp);
|
|
return () => {
|
|
window.removeEventListener("pointermove", handlePointerMove);
|
|
window.removeEventListener("pointerup", handlePointerUp);
|
|
window.removeEventListener("pointercancel", handlePointerUp);
|
|
};
|
|
}, [draggable, open]);
|
|
|
|
if (!open || typeof document === "undefined") return null;
|
|
|
|
const allowBackdropClose = closeOnBackdrop ?? placement === "center";
|
|
|
|
const handleBackdropPointerDown = (event: PointerEvent<HTMLDivElement>) => {
|
|
if (allowBackdropClose && event.target === event.currentTarget) onClose();
|
|
};
|
|
|
|
return createPortal(
|
|
<div className="nodedc-overlay nodedc-ui-root" data-placement={placement} onPointerDown={handleBackdropPointerDown}>
|
|
<div
|
|
ref={dialogRef}
|
|
className={cn("nodedc-window nodedc-glass-material nodedc-material-rim", className)}
|
|
data-material="glass-v4"
|
|
data-size={size === "md" ? undefined : size}
|
|
data-placement={placement}
|
|
data-draggable={draggable ? "true" : undefined}
|
|
role="dialog"
|
|
aria-modal={placement === "center" ? "true" : undefined}
|
|
aria-labelledby={titleId}
|
|
aria-describedby={subtitle ? descriptionId : undefined}
|
|
tabIndex={-1}
|
|
style={dragPosition ? { ...style, position: "fixed", left: dragPosition.left, top: dragPosition.top, right: "auto", bottom: "auto" } : style}
|
|
{...props}
|
|
>
|
|
<header
|
|
className="nodedc-window__head"
|
|
onPointerDown={(event) => {
|
|
if (!draggable || event.button !== 0 || (event.target as HTMLElement).closest("button, input, select, textarea, a")) return;
|
|
const bounds = dialogRef.current?.getBoundingClientRect();
|
|
if (!bounds) return;
|
|
dragRef.current = { pointerId: event.pointerId, offsetX: event.clientX - bounds.left, offsetY: event.clientY - bounds.top };
|
|
event.preventDefault();
|
|
}}
|
|
>
|
|
<div className="nodedc-window__titles">
|
|
<h2 id={titleId} className="nodedc-window__title">{title}</h2>
|
|
{subtitle ? <p id={descriptionId} className="nodedc-window__subtitle">{subtitle}</p> : null}
|
|
</div>
|
|
<button type="button" className="nodedc-window__close" aria-label={closeLabel} onClick={onClose}>
|
|
<Icon name="close" size={16} strokeWidth={1.6} />
|
|
</button>
|
|
</header>
|
|
<div className="nodedc-window__body">{children}</div>
|
|
{footer ? <footer className="nodedc-window__footer">{footer}</footer> : null}
|
|
</div>
|
|
</div>,
|
|
document.body,
|
|
);
|
|
}
|
|
|
|
export function WindowFooterActions({ children, className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
|
return <div className={cn("nodedc-window__footer-actions", className)} {...props}>{children}</div>;
|
|
}
|