267 lines
7.9 KiB
TypeScript
267 lines
7.9 KiB
TypeScript
import {
|
|
createContext,
|
|
forwardRef,
|
|
useCallback,
|
|
useContext,
|
|
useId,
|
|
useLayoutEffect,
|
|
useRef,
|
|
useState,
|
|
type HTMLAttributes,
|
|
type ReactNode,
|
|
} from "react";
|
|
import { createPortal } from "react-dom";
|
|
import { X } from "lucide-react";
|
|
|
|
import { Button } from "../components/ui/button";
|
|
|
|
import "./WorkAreaPanel.css";
|
|
import { AdministrationHeader } from "./administration/AdministrationPage";
|
|
|
|
function classes(base: string, className?: string) {
|
|
return className ? `${base} ${className}` : base;
|
|
}
|
|
|
|
const WorkAreaPanelHostContext = createContext<HTMLElement | null>(null);
|
|
|
|
interface WorkAreaPanelHostProviderProps {
|
|
host: HTMLElement | null;
|
|
children: ReactNode;
|
|
}
|
|
|
|
export function WorkAreaPanelHostProvider({
|
|
host,
|
|
children,
|
|
}: WorkAreaPanelHostProviderProps) {
|
|
return (
|
|
<WorkAreaPanelHostContext.Provider value={host}>
|
|
{children}
|
|
</WorkAreaPanelHostContext.Provider>
|
|
);
|
|
}
|
|
|
|
export interface WorkAreaPanelHostProps extends HTMLAttributes<HTMLDivElement> {}
|
|
|
|
/** Hosts modeless management panels inside the central application work area. */
|
|
export const WorkAreaPanelHost = forwardRef<HTMLDivElement, WorkAreaPanelHostProps>(
|
|
function WorkAreaPanelHost({ className, children, ...props }, forwardedRef) {
|
|
const [host, setHost] = useState<HTMLDivElement | null>(null);
|
|
const assignRef = useCallback((node: HTMLDivElement | null) => {
|
|
setHost(node);
|
|
if (typeof forwardedRef === "function") forwardedRef(node);
|
|
else if (forwardedRef) forwardedRef.current = node;
|
|
}, [forwardedRef]);
|
|
|
|
return (
|
|
<WorkAreaPanelHostContext.Provider value={host}>
|
|
<div
|
|
{...props}
|
|
ref={assignRef}
|
|
className={classes("thot-work-area-panel-host", className)}
|
|
>
|
|
{children}
|
|
</div>
|
|
</WorkAreaPanelHostContext.Provider>
|
|
);
|
|
},
|
|
);
|
|
|
|
/**
|
|
* Keeps a modeless work-area panel keyboard-reachable without trapping focus.
|
|
* Only a panel containing focus responds to Escape, so stacked panels close one
|
|
* at a time and focus returns to the control that opened the panel.
|
|
*/
|
|
export function useModelessPanel(open: boolean, onClose: () => void, busy = false) {
|
|
const panelRef = useRef<HTMLElement | null>(null);
|
|
const stateRef = useRef({ busy, onClose });
|
|
stateRef.current = { busy, onClose };
|
|
|
|
useLayoutEffect(() => {
|
|
if (!open) return;
|
|
const origin = document.activeElement instanceof HTMLElement
|
|
? document.activeElement
|
|
: null;
|
|
const focusTimer = window.setTimeout(() => {
|
|
const panel = panelRef.current;
|
|
const target = panel?.querySelector<HTMLElement>("[data-work-area-panel-close]") ?? panel;
|
|
target?.focus();
|
|
}, 0);
|
|
const onKeyDown = (event: KeyboardEvent) => {
|
|
const panel = panelRef.current;
|
|
if (
|
|
event.key !== "Escape"
|
|
|| event.defaultPrevented
|
|
|| stateRef.current.busy
|
|
|| !panel?.contains(document.activeElement)
|
|
) return;
|
|
event.preventDefault();
|
|
stateRef.current.onClose();
|
|
};
|
|
document.addEventListener("keydown", onKeyDown);
|
|
return () => {
|
|
const activeElement = document.activeElement;
|
|
const shouldRestoreFocus = activeElement === document.body
|
|
|| Boolean(activeElement && panelRef.current?.contains(activeElement));
|
|
window.clearTimeout(focusTimer);
|
|
document.removeEventListener("keydown", onKeyDown);
|
|
if (shouldRestoreFocus) {
|
|
window.setTimeout(() => {
|
|
if (origin?.isConnected) origin.focus();
|
|
}, 0);
|
|
}
|
|
};
|
|
}, [open]);
|
|
|
|
return panelRef;
|
|
}
|
|
|
|
export interface WorkAreaPanelProps {
|
|
open: boolean;
|
|
mode?: "panel" | "page";
|
|
ariaLabel?: string;
|
|
eyebrow?: ReactNode;
|
|
title: ReactNode;
|
|
description?: ReactNode;
|
|
onClose: () => void;
|
|
closeLabel?: string;
|
|
children: ReactNode;
|
|
bodyClassName?: string;
|
|
footer?: ReactNode;
|
|
footerClassName?: string;
|
|
busy?: boolean;
|
|
width?: "standard" | "wide" | "expanded";
|
|
className?: string;
|
|
catalogMarkers?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Shared presentational shell for management, history, review and edit panels
|
|
* that live inside the application's central work area.
|
|
*/
|
|
export const WorkAreaPanel = forwardRef<HTMLElement, WorkAreaPanelProps>(
|
|
function WorkAreaPanel(
|
|
{
|
|
open,
|
|
mode = "panel",
|
|
ariaLabel,
|
|
eyebrow,
|
|
title,
|
|
description,
|
|
onClose,
|
|
closeLabel = "Close panel",
|
|
children,
|
|
bodyClassName,
|
|
footer,
|
|
footerClassName,
|
|
busy = false,
|
|
width = "standard",
|
|
className,
|
|
catalogMarkers = false,
|
|
},
|
|
forwardedRef,
|
|
) {
|
|
const panelHost = useContext(WorkAreaPanelHostContext);
|
|
const titleId = useId();
|
|
const descriptionId = useId();
|
|
const panelRef = useModelessPanel(mode === "panel" && open, onClose, busy);
|
|
const assignRef = (node: HTMLElement | null) => {
|
|
panelRef.current = node;
|
|
if (typeof forwardedRef === "function") forwardedRef(node);
|
|
else if (forwardedRef) forwardedRef.current = node;
|
|
};
|
|
|
|
if (!open) return null;
|
|
|
|
const panel = mode === "page" ? (
|
|
<main
|
|
ref={assignRef}
|
|
className={classes("thot-work-area-page", className)}
|
|
data-work-area-page=""
|
|
aria-label={ariaLabel}
|
|
aria-busy={busy}
|
|
>
|
|
<AdministrationHeader title={title} description={description} />
|
|
<div
|
|
className={classes("thot-work-area-page__body", bodyClassName)}
|
|
data-work-area-panel-region="body"
|
|
>
|
|
{children}
|
|
</div>
|
|
{footer ? (
|
|
<footer
|
|
className={classes("thot-work-area-panel__footer", footerClassName)}
|
|
data-work-area-panel-region="footer"
|
|
>
|
|
{footer}
|
|
</footer>
|
|
) : null}
|
|
</main>
|
|
) : (
|
|
<aside
|
|
ref={assignRef}
|
|
role="dialog"
|
|
aria-modal="false"
|
|
className={classes("thot-work-area-panel", className)}
|
|
data-work-area-panel=""
|
|
data-catalog-panel={catalogMarkers ? "" : undefined}
|
|
data-width={width}
|
|
aria-label={ariaLabel}
|
|
aria-labelledby={ariaLabel ? undefined : titleId}
|
|
aria-describedby={description ? descriptionId : undefined}
|
|
aria-busy={busy}
|
|
tabIndex={-1}
|
|
>
|
|
<header
|
|
className="thot-work-area-panel__header"
|
|
data-work-area-panel-region="header"
|
|
data-catalog-panel-region={catalogMarkers ? "header" : undefined}
|
|
>
|
|
<div className="thot-work-area-panel__heading">
|
|
{eyebrow ? <p className="thot-work-area-panel__eyebrow">{eyebrow}</p> : null}
|
|
<h2 id={titleId} className="thot-work-area-panel__title">
|
|
{title}
|
|
</h2>
|
|
{description ? (
|
|
<p id={descriptionId} className="thot-work-area-panel__description">
|
|
{description}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon-lg"
|
|
className="thot-work-area-panel__close"
|
|
data-work-area-panel-close
|
|
data-drawer-close
|
|
onClick={onClose}
|
|
disabled={busy}
|
|
aria-label={closeLabel}
|
|
title={closeLabel}
|
|
>
|
|
<X aria-hidden="true" />
|
|
</Button>
|
|
</header>
|
|
<div
|
|
className={classes("thot-work-area-panel__body", bodyClassName)}
|
|
data-work-area-panel-region="body"
|
|
data-catalog-panel-region={catalogMarkers ? "body" : undefined}
|
|
>
|
|
{children}
|
|
</div>
|
|
{footer ? (
|
|
<footer
|
|
className={classes("thot-work-area-panel__footer", footerClassName)}
|
|
data-work-area-panel-region="footer"
|
|
data-catalog-panel-region={catalogMarkers ? "footer" : undefined}
|
|
>
|
|
{footer}
|
|
</footer>
|
|
) : null}
|
|
</aside>
|
|
);
|
|
|
|
return mode === "panel" && panelHost ? createPortal(panel, panelHost) : panel;
|
|
},
|
|
);
|