mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 03:20:46 +02:00
Portal: full mock-driven surfaces, demonolithed components, backend-ready mocks (#6686)
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useId, useRef, type ReactNode } from "react";
|
||||
import { useEffect, useId, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { FocusTrap } from "@mantine/core";
|
||||
import "@shared/components/Modal.css";
|
||||
|
||||
export type ModalWidth = "sm" | "md" | "lg" | "xl";
|
||||
@@ -25,16 +26,12 @@ export interface ModalProps {
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
const FOCUSABLE_SELECTOR =
|
||||
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
||||
|
||||
/**
|
||||
* Portal-rendered modal. Backdrop fades in, dialog scales in. ESC and
|
||||
* click-on-backdrop close by default. Each instance is self-contained — the
|
||||
* caller owns the open state and the close handler.
|
||||
*
|
||||
* Focus is trapped inside the dialog while open and returned to the
|
||||
* previously-focused element on close.
|
||||
* Portal-rendered modal with our own brand shell (header / body / footer,
|
||||
* width presets, backdrop). The hard part — trapping Tab focus inside the
|
||||
* dialog, initial focus, and restoring focus to the opener on close — is
|
||||
* delegated to Mantine's <FocusTrap> rather than hand-rolled. ESC and
|
||||
* backdrop click close by default; the caller owns the open state.
|
||||
*/
|
||||
export function Modal({
|
||||
open,
|
||||
@@ -49,7 +46,6 @@ export function Modal({
|
||||
className,
|
||||
children,
|
||||
}: ModalProps) {
|
||||
const dialogRef = useRef<HTMLDivElement | null>(null);
|
||||
const titleId = useId();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -70,40 +66,6 @@ export function Modal({
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const previouslyFocused = document.activeElement as HTMLElement | null;
|
||||
const dialog = dialogRef.current;
|
||||
const first = dialog?.querySelector<HTMLElement>(FOCUSABLE_SELECTOR);
|
||||
first?.focus();
|
||||
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key !== "Tab" || !dialog) return;
|
||||
const focusables =
|
||||
dialog.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR);
|
||||
if (focusables.length === 0) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
const firstEl = focusables[0];
|
||||
const lastEl = focusables[focusables.length - 1];
|
||||
const active = document.activeElement;
|
||||
if (e.shiftKey && active === firstEl) {
|
||||
e.preventDefault();
|
||||
lastEl.focus();
|
||||
} else if (!e.shiftKey && active === lastEl) {
|
||||
e.preventDefault();
|
||||
firstEl.focus();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKey);
|
||||
previouslyFocused?.focus?.();
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
function onBackdropClick() {
|
||||
@@ -118,53 +80,55 @@ export function Modal({
|
||||
onClick={onBackdropClick}
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
ref={dialogRef}
|
||||
className={["sui-modal", `sui-modal--${width}`, className ?? ""]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={hasTitle ? titleId : undefined}
|
||||
aria-label={!hasTitle ? ariaLabel : undefined}
|
||||
>
|
||||
{(title || subtitle) && (
|
||||
<header className="sui-modal__header">
|
||||
<div className="sui-modal__header-text">
|
||||
{title && (
|
||||
<div id={titleId} className="sui-modal__title">
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
{subtitle && <div className="sui-modal__sub">{subtitle}</div>}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="sui-modal__close"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.75}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden
|
||||
<FocusTrap active>
|
||||
<div
|
||||
className={["sui-modal", `sui-modal--${width}`, className ?? ""]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={hasTitle ? titleId : undefined}
|
||||
aria-label={!hasTitle ? ariaLabel : undefined}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{(title || subtitle) && (
|
||||
<header className="sui-modal__header">
|
||||
<div className="sui-modal__header-text">
|
||||
{title && (
|
||||
<div id={titleId} className="sui-modal__title">
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
{subtitle && <div className="sui-modal__sub">{subtitle}</div>}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="sui-modal__close"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
)}
|
||||
<div className="sui-modal__body">{children}</div>
|
||||
{footer && <footer className="sui-modal__footer">{footer}</footer>}
|
||||
</div>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.75}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
)}
|
||||
<div className="sui-modal__body">{children}</div>
|
||||
{footer && <footer className="sui-modal__footer">{footer}</footer>}
|
||||
</div>
|
||||
</FocusTrap>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user