import {
type HTMLAttributes,
type ReactElement,
type ReactNode,
useEffect,
useId,
useRef,
} from "react";
import { classNames } from "./class-names.js";
/** Containers: the panel a screen is built from, the dialog that interrupts it, and the tab strip
* that divides it. */
export interface PageHeaderProps {
readonly title: ReactNode;
readonly description?: ReactNode;
readonly actions?: ReactNode;
readonly eyebrow?: ReactNode;
readonly className?: string;
}
/** The consistent top-level identity and primary-action region for an Ekklesia screen. */
export function PageHeader(props: PageHeaderProps): ReactElement {
return (
{props.actions}
) : undefined}
{props.children}
);
}
export interface DialogProps {
readonly open: boolean;
readonly title: ReactNode;
readonly onDismiss: () => void;
/** Buttons for the dialog's decisions, rendered in a trailing-aligned row. */
readonly actions?: ReactNode;
readonly className?: string;
readonly children?: ReactNode;
}
/**
* A modal dialog rendered in place rather than portalled to `document.body`.
*
* Portalling is what a document-scoped component library does. It is exactly wrong here: a module
* artifact's dialog portalled to the host document would escape the artifact's isolated root, lose
* its styles, and render over the Dashboard — which is the leak this package exists to prevent. The
* scrim is `position: fixed`, so it still covers the viewport from wherever it is rendered.
*
* Focus moves to the dialog when it opens and `Escape` dismisses it. A full focus trap is
* deliberately not implemented here rather than implemented badly; when the platform needs one it
* belongs in one place, tested, not sprinkled through call sites.
*/
export function Dialog(props: DialogProps): ReactElement | null {
const titleId = useId();
const dialogRef = useRef(null);
const { onDismiss, open } = props;
useEffect(() => {
if (!open) {
return;
}
dialogRef.current?.focus();
const onKeyDown = (event: KeyboardEvent): void => {
if (event.key === "Escape") {
event.stopPropagation();
onDismiss();
}
};
const owner = dialogRef.current?.ownerDocument;
owner?.addEventListener("keydown", onKeyDown);
return () => owner?.removeEventListener("keydown", onKeyDown);
}, [onDismiss, open]);
if (!open) {
return null;
}
return (
);
}
export interface TabDefinition {
readonly id: string;
readonly label: ReactNode;
readonly content: ReactNode;
}
export interface TabsProps {
readonly tabs: readonly TabDefinition[];
readonly selectedId: string;
readonly onSelect: (id: string) => void;
/** Names the tab strip for assistive technology. */
readonly label: string;
readonly className?: string;
}
/**
* A controlled tab strip with the WAI-ARIA keyboard behaviour: arrow keys move between tabs, `Home`
* and `End` jump to the ends, and only the selected tab is in the tab order.
*
* The panel itself carries `tabIndex={0}`, which the linter's generic "non-interactive elements do
* not take a tab stop" rule objects to. The WAI-ARIA Authoring Practices require it: without it a
* keyboard user cannot reach or scroll panel content that contains no focusable element of its own.
* Removing it would be an accessibility regression, not a fix, so the rule is suppressed at that one
* attribute rather than the behaviour being changed.
*/
export function Tabs(props: TabsProps): ReactElement {
const baseId = useId();
const selected = props.tabs.find((tab) => tab.id === props.selectedId) ?? props.tabs[0];
function move(offset: number): void {
const index = props.tabs.findIndex((tab) => tab.id === selected?.id);
if (index < 0 || props.tabs.length === 0) {
return;
}
const next = props.tabs[(index + offset + props.tabs.length) % props.tabs.length];
if (next) {
props.onSelect(next.id);
}
}
return (