Files
ThothII/frontend/src/shell/WorkAreaPanel.tsx
T
Codex d8a29bfbdd Add full shell, replaceable Omics adapter and bilingual interaction
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.
2026-09-13 14:26:39 +02:00

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;
},
);