Files
Ekklesia/packages/ui/src/surfaces.tsx
T
ekklesia 3a26ef49ad
CI / verify (pull_request) Successful in 48s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 1m47s
feat: establish shared Ekklesia design system
2026-09-30 18:21:09 -05:00

266 lines
8.6 KiB
TypeScript

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 (
<header className={classNames("ek-page-header", props.className)}>
<div className="ek-page-header__copy">
{props.eyebrow === undefined ? undefined : (
<span className="ek-page-header__eyebrow">{props.eyebrow}</span>
)}
<h1 className="ek-page-header__title">{props.title}</h1>
{props.description === undefined ? undefined : (
<p className="ek-page-header__description">{props.description}</p>
)}
</div>
{props.actions === undefined ? undefined : (
<div className="ek-page-header__actions">{props.actions}</div>
)}
</header>
);
}
export interface ActionBarProps extends HTMLAttributes<HTMLDivElement> {
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 (
<div
{...rest}
{...(label === undefined ? {} : { "aria-label": label })}
className={classNames("ek-action-bar", className)}
>
{children}
</div>
);
}
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 (
<section
className={classNames(
"ek-panel",
props.raised === true && "ek-panel--raised",
props.className,
)}
>
{hasHeader ? (
<header className="ek-panel__header">
<div className="ek-panel__heading">
{props.title === undefined ? undefined : (
<h2 className="ek-panel__title">{props.title}</h2>
)}
{props.description === undefined ? undefined : (
<p className="ek-panel__description">{props.description}</p>
)}
</div>
{props.actions}
</header>
) : undefined}
{props.children}
</section>
);
}
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<HTMLDivElement | null>(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 (
<div className="ek-dialog__scrim">
<div
aria-labelledby={titleId}
aria-modal="true"
className={classNames("ek-dialog", "ek-focusable", props.className)}
ref={dialogRef}
role="dialog"
tabIndex={-1}
>
<h2 className="ek-dialog__title" id={titleId}>
{props.title}
</h2>
{props.children}
{props.actions === undefined ? undefined : (
<div className="ek-dialog__actions">{props.actions}</div>
)}
</div>
</div>
);
}
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 (
<div className={classNames(props.className)}>
<div aria-label={props.label} className="ek-tabs__list" role="tablist">
{props.tabs.map((tab) => {
const isSelected = tab.id === selected?.id;
return (
<button
aria-controls={`${baseId}-${tab.id}-panel`}
aria-selected={isSelected}
className="ek-tab ek-focusable"
id={`${baseId}-${tab.id}-tab`}
key={tab.id}
onClick={() => props.onSelect(tab.id)}
onKeyDown={(event) => {
if (event.key === "ArrowRight") {
event.preventDefault();
move(1);
} else if (event.key === "ArrowLeft") {
event.preventDefault();
move(-1);
} else if (event.key === "Home") {
event.preventDefault();
const first = props.tabs[0];
if (first) {
props.onSelect(first.id);
}
} else if (event.key === "End") {
event.preventDefault();
const last = props.tabs[props.tabs.length - 1];
if (last) {
props.onSelect(last.id);
}
}
}}
role="tab"
tabIndex={isSelected ? 0 : -1}
type="button"
>
{tab.label}
</button>
);
})}
</div>
{selected === undefined ? undefined : (
<div
aria-labelledby={`${baseId}-${selected.id}-tab`}
className="ek-tabs__panel"
id={`${baseId}-${selected.id}-panel`}
role="tabpanel"
// biome-ignore lint/a11y/noNoninteractiveTabindex: a tab panel is required to be focusable (see above).
tabIndex={0}
>
{selected.content}
</div>
)}
</div>
);
}