feat: complete catalog fleet management workflow
This commit is contained in:
@@ -0,0 +1,239 @@
|
||||
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";
|
||||
|
||||
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;
|
||||
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";
|
||||
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,
|
||||
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(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 = (
|
||||
<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 panelHost ? createPortal(panel, panelHost) : panel;
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user