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, "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(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(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(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) => { if (allowBackdropClose && event.target === event.currentTarget) onClose(); }; return createPortal(
{ 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(); }} >

{title}

{subtitle ?

{subtitle}

: null}
{children}
{footer ?
{footer}
: null}
, document.body, ); } export function WindowFooterActions({ children, className, ...props }: HTMLAttributes) { return
{children}
; }