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.eyebrow === undefined ? undefined : ( {props.eyebrow} )}

{props.title}

{props.description === undefined ? undefined : (

{props.description}

)}
{props.actions === undefined ? undefined : (
{props.actions}
)}
); } export interface ActionBarProps extends HTMLAttributes { readonly children?: ReactNode; readonly label?: string; } /** A wrapping row of related controls with predictable spacing and alignment. */ export function ActionBar(props: ActionBarProps): ReactElement { const { children, className, label, ...rest } = props; return (
{children}
); } export interface PanelProps { readonly title?: ReactNode; readonly description?: ReactNode; /** Controls rendered opposite the title — a primary action, a filter, a status badge. */ readonly actions?: ReactNode; readonly raised?: boolean; readonly className?: string; readonly children?: ReactNode; } export function Panel(props: PanelProps): ReactElement { const hasHeader = props.title !== undefined || props.actions !== undefined; return (
{hasHeader ? (
{props.title === undefined ? undefined : (

{props.title}

)} {props.description === undefined ? undefined : (

{props.description}

)}
{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 (

{props.title}

{props.children} {props.actions === undefined ? undefined : (
{props.actions}
)}
); } 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 (
{props.tabs.map((tab) => { const isSelected = tab.id === selected?.id; return ( ); })}
{selected === undefined ? undefined : (
{selected.content}
)}
); }