266 lines
8.6 KiB
TypeScript
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>
|
|
);
|
|
}
|