mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 11:23:10 +02:00
Portal: full mock-driven surfaces, demonolithed components, backend-ready mocks (#6686)
This commit is contained in:
@@ -1,10 +1,8 @@
|
||||
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/Drawer.css";
|
||||
|
||||
const FOCUSABLE_SELECTOR =
|
||||
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
||||
|
||||
export type DrawerSide = "right" | "left";
|
||||
export type DrawerWidth = "sm" | "md" | "lg";
|
||||
|
||||
@@ -26,9 +24,10 @@ export interface DrawerProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Side drawer — sibling to {@link Modal}. Slides in from `side`, locks body
|
||||
* scroll, closes on backdrop click or Escape. PipelineDetailDrawer, doc
|
||||
* detail drawers, etc. should all sit on top of this primitive.
|
||||
* Side drawer — sibling to {@link Modal}, with our own brand shell. Tab focus
|
||||
* trapping, initial focus, and focus restoration on close are delegated to
|
||||
* Mantine's <FocusTrap>; we keep the slide-in chrome, scroll lock, and ESC /
|
||||
* backdrop dismissal.
|
||||
*/
|
||||
export function Drawer({
|
||||
open,
|
||||
@@ -44,7 +43,6 @@ export function Drawer({
|
||||
ariaLabel,
|
||||
children,
|
||||
}: DrawerProps) {
|
||||
const dialogRef = useRef<HTMLElement | null>(null);
|
||||
const titleId = useId();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -65,40 +63,6 @@ export function Drawer({
|
||||
};
|
||||
}, [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() {
|
||||
@@ -114,57 +78,59 @@ export function Drawer({
|
||||
onClick={onBackdropClick}
|
||||
role="presentation"
|
||||
/>
|
||||
<aside
|
||||
ref={dialogRef}
|
||||
className={[
|
||||
"sui-drawer",
|
||||
`sui-drawer--${side}`,
|
||||
`sui-drawer--${width}`,
|
||||
className ?? "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={hasTitle ? titleId : undefined}
|
||||
aria-label={!hasTitle ? ariaLabel : undefined}
|
||||
>
|
||||
{(title || subtitle) && (
|
||||
<header className="sui-drawer__header">
|
||||
<div className="sui-drawer__header-text">
|
||||
{title && (
|
||||
<div id={titleId} className="sui-drawer__title">
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
{subtitle && <div className="sui-drawer__sub">{subtitle}</div>}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="sui-drawer__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>
|
||||
<aside
|
||||
className={[
|
||||
"sui-drawer",
|
||||
`sui-drawer--${side}`,
|
||||
`sui-drawer--${width}`,
|
||||
className ?? "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={hasTitle ? titleId : undefined}
|
||||
aria-label={!hasTitle ? ariaLabel : undefined}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{(title || subtitle) && (
|
||||
<header className="sui-drawer__header">
|
||||
<div className="sui-drawer__header-text">
|
||||
{title && (
|
||||
<div id={titleId} className="sui-drawer__title">
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
{subtitle && <div className="sui-drawer__sub">{subtitle}</div>}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="sui-drawer__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-drawer__body">{children}</div>
|
||||
{footer && <footer className="sui-drawer__footer">{footer}</footer>}
|
||||
</aside>
|
||||
<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-drawer__body">{children}</div>
|
||||
{footer && <footer className="sui-drawer__footer">{footer}</footer>}
|
||||
</aside>
|
||||
</FocusTrap>
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user