Implement approved specification #32 and tickets #33-#37. Keep host authentication server-verified and pin session interaction language. Compile scoped base selectors for browser compatibility and retain full gutters during CSS pruning.
269 lines
8.0 KiB
TypeScript
269 lines
8.0 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 { useI18n } from "../i18n";
|
|
|
|
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,
|
|
children,
|
|
bodyClassName,
|
|
footer,
|
|
footerClassName,
|
|
busy = false,
|
|
width = "standard",
|
|
className,
|
|
catalogMarkers = false,
|
|
},
|
|
forwardedRef,
|
|
) {
|
|
const { t: translate } = useI18n();
|
|
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 ?? translate("Close panel")}
|
|
title={closeLabel ?? translate("Close panel")}
|
|
>
|
|
<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;
|
|
},
|
|
);
|