feat: give every Ekklesia screen one set of design tokens and elements
Modules have owned their own UI since ADR 0021, but the platform has never said what that UI should look like. Every screen here — the Dashboard shell, the display host, the module template — invented its own hex colours, font stack, spacing, and radii, and they already disagreed. A module author had no vocabulary for "the ordinary panel background", so the only available answer was to invent one, and two modules would invent two. Add `@ekklesia/design-tokens`: one validated, enumerable registry of semantic tokens. A token declares a name, label, description, and default value; its category and `--ekklesia-*` identifier are derived from the name, so it can never claim one category while being named for another. Construction rejects duplicates, malformed names, unknown categories, empty or overlong text, and any value that is not a bounded literal — no declaration terminator, rule block, comment, at-rule, `url(`, `!important`, escape, control character, or markup. A registry that constructs can therefore be emitted as CSS without escaping, because nothing needing escaping can be in it. That validation is also what makes a future theme editor implementable rather than aspirational. Add `@ekklesia/ui`: button, icon button, text input, select, checkbox, field wrapper, panel, dialog, tabs, table and list primitives, badge, toast, and empty/error/loading states — every value a token reference, with real `:focus-visible` treatment and status that never depends on colour alone. Deliberately small: an element earns its place when two consumers need it. The stylesheet is a string rather than a `.css` file so the identical bytes install into a document or an isolated artifact root, and so hover, disabled, and focus-visible are real CSS rather than JavaScript approximations of them. `pnpm boundaries` opens exactly two shared roles to modules, by name — every other role stays rejected — and pins both design packages to the bottom of the presentation stack. Both are browser-safe by construction, proven by a real compile of module-shaped source in which `process.env` must fail to typecheck. The shell installs the defaults at its document root for the Dashboard and the display; custom properties inherit into module artifacts, so a module reads `var(--ekklesia-…)` with nothing to import at run time and nothing for a host to forget to pass. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
b14613decd
commit
e03d38fcab
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "@ekklesia/ui",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc --project tsconfig.build.json",
|
||||
"lint": "biome lint .",
|
||||
"prepack": "pnpm --workspace-root exec turbo run build --filter=@ekklesia/ui",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit --project tsconfig.json && tsc --noEmit --project tsconfig.test.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ekklesia/design-tokens": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "19.2.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.5.3",
|
||||
"@ekklesia/typescript-config": "workspace:*",
|
||||
"@types/node": "24.13.3",
|
||||
"@types/react": "19.2.18",
|
||||
"@types/react-dom": "19.2.4",
|
||||
"happy-dom": "20.0.11",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8",
|
||||
"typescript": "7.0.2",
|
||||
"vitest": "4.1.10"
|
||||
},
|
||||
"ekklesia": {
|
||||
"layer": "ui"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { ReactElement } from "react";
|
||||
import { EKKLESIA_UI_STYLE_ATTRIBUTE, EKKLESIA_UI_STYLESHEET } from "./styles.js";
|
||||
|
||||
/**
|
||||
* Renders the element stylesheet into whichever root this React tree happens to live in.
|
||||
*
|
||||
* That is the whole point: a Dashboard renders it into the document, and a module UI artifact
|
||||
* renders it inside its own isolated root, with no caller having to know which one it is. React puts
|
||||
* a `<style>` where the tree is, so the same component gives a document and a shadow root the
|
||||
* identical rules without either reaching into the other.
|
||||
*
|
||||
* Render it once per root, above the elements that use it. A second copy in one root is harmless
|
||||
* (the rules are identical) but pointless. There is deliberately no global side effect, no
|
||||
* `document.head` hoisting, and no `precedence` attribute: hoisting a shadow root's styles into the
|
||||
* host document is exactly the leak this package exists to prevent.
|
||||
*/
|
||||
export function EkklesiaUiStyles(): ReactElement {
|
||||
const marker = { [EKKLESIA_UI_STYLE_ATTRIBUTE]: "" };
|
||||
return <style {...marker}>{EKKLESIA_UI_STYLESHEET}</style>;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Joins the class names an element resolved to, dropping the ones that are not there.
|
||||
*
|
||||
* Deliberately not a dependency: this package has exactly one runtime dependency (the design tokens)
|
||||
* and adding a second for six lines would be a poor trade for every browser program that bundles it.
|
||||
*/
|
||||
export function classNames(...values: readonly (string | undefined | false)[]): string {
|
||||
return values
|
||||
.filter((value): value is string => typeof value === "string" && value !== "")
|
||||
.join(" ");
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import type {
|
||||
ButtonHTMLAttributes,
|
||||
InputHTMLAttributes,
|
||||
ReactElement,
|
||||
ReactNode,
|
||||
Ref,
|
||||
SelectHTMLAttributes,
|
||||
} from "react";
|
||||
import { useId } from "react";
|
||||
import { classNames } from "./class-names.js";
|
||||
|
||||
/**
|
||||
* The foundational interactive elements.
|
||||
*
|
||||
* Each one is a thin, accessible shell over the real HTML element rather than a re-implementation of
|
||||
* it: a `Button` is a `<button>`, a `TextInput` is an `<input>`, and every native attribute a caller
|
||||
* needs — `type`, `name`, `autoComplete`, `aria-*`, `onChange` — passes straight through. That is
|
||||
* deliberate. A component library that reinvents the control is a component library that has to
|
||||
* reinvent focus, keyboard behaviour, form association, and assistive-technology semantics too, and
|
||||
* it always gets some of them wrong.
|
||||
*/
|
||||
|
||||
export type EkklesiaButtonVariant = "primary" | "secondary" | "quiet" | "danger";
|
||||
export type EkklesiaControlSize = "sm" | "md" | "lg";
|
||||
|
||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
readonly variant?: EkklesiaButtonVariant;
|
||||
readonly size?: EkklesiaControlSize;
|
||||
readonly ref?: Ref<HTMLButtonElement>;
|
||||
}
|
||||
|
||||
/**
|
||||
* `type` defaults to `"button"`, not the HTML default of `"submit"`.
|
||||
*
|
||||
* A button inside a form that submits it by accident is one of the most common and most confusing
|
||||
* UI defects there is. A caller that genuinely wants a submit button says so.
|
||||
*/
|
||||
export function Button(props: ButtonProps): ReactElement {
|
||||
const { className, size = "md", variant = "secondary", type = "button", ...rest } = props;
|
||||
return (
|
||||
<button
|
||||
{...rest}
|
||||
className={classNames(
|
||||
"ek-button",
|
||||
"ek-focusable",
|
||||
`ek-button--${variant}`,
|
||||
size === "md" ? undefined : `ek-button--${size}`,
|
||||
className,
|
||||
)}
|
||||
type={type}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export interface IconButtonProps extends ButtonProps {
|
||||
/** Required: an icon-only control with no accessible name is unusable with a screen reader. */
|
||||
readonly label: string;
|
||||
}
|
||||
|
||||
export function IconButton(props: IconButtonProps): ReactElement {
|
||||
const { className, label, ...rest } = props;
|
||||
return (
|
||||
<Button {...rest} aria-label={label} className={classNames("ek-icon-button", className)} />
|
||||
);
|
||||
}
|
||||
|
||||
export interface FieldProps {
|
||||
readonly label: ReactNode;
|
||||
readonly hint?: ReactNode;
|
||||
/** When present the field is invalid: the message is rendered with the danger status symbol and
|
||||
* announced politely, and the control is wired to it through `aria-describedby`. */
|
||||
readonly error?: ReactNode;
|
||||
readonly className?: string;
|
||||
/**
|
||||
* The control. Receives the generated `id`, `aria-describedby`, and `aria-invalid` so a caller
|
||||
* never has to invent identifiers or remember to associate the label — the two things hand-rolled
|
||||
* form markup gets wrong most often.
|
||||
*/
|
||||
readonly children: (control: {
|
||||
readonly id: string;
|
||||
readonly "aria-describedby": string | undefined;
|
||||
readonly "aria-invalid": boolean | undefined;
|
||||
}) => ReactNode;
|
||||
}
|
||||
|
||||
export function Field(props: FieldProps): ReactElement {
|
||||
const generatedId = useId();
|
||||
const hintId = `${generatedId}-hint`;
|
||||
const errorId = `${generatedId}-error`;
|
||||
const describedBy =
|
||||
[props.error === undefined ? undefined : errorId, props.hint === undefined ? undefined : hintId]
|
||||
.filter((value): value is string => value !== undefined)
|
||||
.join(" ") || undefined;
|
||||
|
||||
return (
|
||||
<div className={classNames("ek-field", props.className)}>
|
||||
<label className="ek-field__label" htmlFor={generatedId}>
|
||||
{props.label}
|
||||
</label>
|
||||
{props.children({
|
||||
"aria-describedby": describedBy,
|
||||
"aria-invalid": props.error === undefined ? undefined : true,
|
||||
id: generatedId,
|
||||
})}
|
||||
{props.hint === undefined ? undefined : (
|
||||
<span className="ek-field__hint" id={hintId}>
|
||||
{props.hint}
|
||||
</span>
|
||||
)}
|
||||
{props.error === undefined ? undefined : (
|
||||
<span className="ek-field__error" id={errorId} role="alert">
|
||||
{props.error}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface TextInputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
readonly ref?: Ref<HTMLInputElement>;
|
||||
}
|
||||
|
||||
export function TextInput(props: TextInputProps): ReactElement {
|
||||
const { className, type = "text", ...rest } = props;
|
||||
return (
|
||||
<input {...rest} className={classNames("ek-control", "ek-focusable", className)} type={type} />
|
||||
);
|
||||
}
|
||||
|
||||
export interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
|
||||
readonly ref?: Ref<HTMLSelectElement>;
|
||||
}
|
||||
|
||||
export function Select(props: SelectProps): ReactElement {
|
||||
const { className, ...rest } = props;
|
||||
return <select {...rest} className={classNames("ek-control", "ek-focusable", className)} />;
|
||||
}
|
||||
|
||||
export interface CheckboxProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type"> {
|
||||
readonly label: ReactNode;
|
||||
readonly ref?: Ref<HTMLInputElement>;
|
||||
}
|
||||
|
||||
export function Checkbox(props: CheckboxProps): ReactElement {
|
||||
const { className, label, ...rest } = props;
|
||||
return (
|
||||
<label className={classNames("ek-checkbox", className)}>
|
||||
<input {...rest} className="ek-checkbox__input ek-focusable" type="checkbox" />
|
||||
<span>{label}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import { classNames } from "./class-names.js";
|
||||
|
||||
/**
|
||||
* Table and list primitives.
|
||||
*
|
||||
* These are primitives rather than a data grid on purpose: sorting, filtering, pagination, selection
|
||||
* models, and virtualisation are product decisions a module owns, and a "smart" table forces every
|
||||
* consumer into one answer for all of them. What the platform owns is that two modules' tables look
|
||||
* and behave like one product.
|
||||
*/
|
||||
|
||||
export interface TableProps {
|
||||
/** Names the table for assistive technology when no visible caption is rendered. */
|
||||
readonly label: string;
|
||||
readonly className?: string;
|
||||
readonly children?: ReactNode;
|
||||
}
|
||||
|
||||
export function Table(props: TableProps): ReactElement {
|
||||
return (
|
||||
<table aria-label={props.label} className={classNames("ek-table", props.className)}>
|
||||
{props.children}
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
export interface TableSectionProps {
|
||||
readonly children?: ReactNode;
|
||||
}
|
||||
|
||||
export function TableHead(props: TableSectionProps): ReactElement {
|
||||
return <thead>{props.children}</thead>;
|
||||
}
|
||||
|
||||
export function TableBody(props: TableSectionProps): ReactElement {
|
||||
return <tbody>{props.children}</tbody>;
|
||||
}
|
||||
|
||||
export interface TableRowProps {
|
||||
readonly selected?: boolean;
|
||||
readonly children?: ReactNode;
|
||||
}
|
||||
|
||||
export function TableRow(props: TableRowProps): ReactElement {
|
||||
return (
|
||||
<tr {...(props.selected === undefined ? {} : { "aria-selected": props.selected })}>
|
||||
{props.children}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
export interface TableCellProps {
|
||||
readonly header?: boolean;
|
||||
/** `col` for a column header, `row` for a row header. Ignored unless `header` is set. */
|
||||
readonly scope?: "col" | "row";
|
||||
readonly children?: ReactNode;
|
||||
}
|
||||
|
||||
export function TableCell(props: TableCellProps): ReactElement {
|
||||
if (props.header === true) {
|
||||
return <th scope={props.scope ?? "col"}>{props.children}</th>;
|
||||
}
|
||||
return <td>{props.children}</td>;
|
||||
}
|
||||
|
||||
export interface ListProps {
|
||||
readonly label?: string;
|
||||
readonly className?: string;
|
||||
readonly children?: ReactNode;
|
||||
}
|
||||
|
||||
export function List(props: ListProps): ReactElement {
|
||||
return (
|
||||
<ul
|
||||
{...(props.label === undefined ? {} : { "aria-label": props.label })}
|
||||
className={classNames("ek-list", props.className)}
|
||||
>
|
||||
{props.children}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
export interface ListItemProps {
|
||||
readonly className?: string;
|
||||
readonly children?: ReactNode;
|
||||
}
|
||||
|
||||
export function ListItem(props: ListItemProps): ReactElement {
|
||||
return <li className={classNames("ek-list__item", props.className)}>{props.children}</li>;
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import { classNames } from "./class-names.js";
|
||||
|
||||
/**
|
||||
* How the platform says something is fine, something needs attention, something broke, something is
|
||||
* loading, and there is nothing here yet.
|
||||
*
|
||||
* ## Status never depends on colour alone
|
||||
*
|
||||
* Every status tone renders a symbol beside its label — `✓`, `!`, `✕`, `i` — sourced from the
|
||||
* design-token registry so a theme can change the glyph without a component changing. A screen in a
|
||||
* bright lobby, a projector with a washed-out gamut, and a person who cannot distinguish red from
|
||||
* green all get the same information (WCAG 2.2 §1.4.1). This is a rule of the shared elements, not
|
||||
* an option a caller can turn off.
|
||||
*/
|
||||
|
||||
export type EkklesiaStatusTone = "neutral" | "info" | "success" | "warning" | "danger";
|
||||
|
||||
const statusSymbols: Readonly<Record<EkklesiaStatusTone, string>> = Object.freeze({
|
||||
danger: "✕",
|
||||
info: "i",
|
||||
neutral: "•",
|
||||
success: "✓",
|
||||
warning: "!",
|
||||
});
|
||||
|
||||
export interface BadgeProps {
|
||||
readonly tone?: EkklesiaStatusTone;
|
||||
readonly className?: string;
|
||||
readonly children?: ReactNode;
|
||||
}
|
||||
|
||||
/** A compact status indicator: a tone, its symbol, and a label. */
|
||||
export function Badge(props: BadgeProps): ReactElement {
|
||||
const tone = props.tone ?? "neutral";
|
||||
return (
|
||||
<span className={classNames("ek-badge", `ek-badge--${tone}`, props.className)}>
|
||||
<span aria-hidden="true" className="ek-badge__symbol">
|
||||
{statusSymbols[tone]}
|
||||
</span>
|
||||
{props.children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export interface ToastProps {
|
||||
readonly tone?: EkklesiaStatusTone;
|
||||
readonly title: ReactNode;
|
||||
readonly className?: string;
|
||||
readonly children?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* One notification's presentation — and only its presentation.
|
||||
*
|
||||
* There is deliberately no toast *manager*: no queue, no timeout, no global store, no imperative
|
||||
* `toast()` function. Queuing policy belongs to whichever application is rendering, and a
|
||||
* package-level singleton would be shared mutable state reaching across every isolated artifact on
|
||||
* a page. A caller renders these where it wants them, for as long as it wants them.
|
||||
*/
|
||||
export function Toast(props: ToastProps): ReactElement {
|
||||
const tone = props.tone ?? "info";
|
||||
return (
|
||||
<div
|
||||
aria-live={tone === "danger" ? "assertive" : "polite"}
|
||||
className={classNames("ek-toast", `ek-toast--${tone}`, props.className)}
|
||||
role="status"
|
||||
>
|
||||
<div className="ek-toast__title">
|
||||
<span aria-hidden="true">{statusSymbols[tone]} </span>
|
||||
{props.title}
|
||||
</div>
|
||||
{props.children === undefined ? undefined : (
|
||||
<div className="ek-toast__body">{props.children}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface ToastStackProps {
|
||||
readonly label?: string;
|
||||
readonly className?: string;
|
||||
readonly children?: ReactNode;
|
||||
}
|
||||
|
||||
export function ToastStack(props: ToastStackProps): ReactElement {
|
||||
return (
|
||||
<section
|
||||
aria-label={props.label ?? "Notifications"}
|
||||
className={classNames("ek-toast__stack", props.className)}
|
||||
>
|
||||
{props.children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export interface EmptyStateProps {
|
||||
readonly title: ReactNode;
|
||||
readonly description?: ReactNode;
|
||||
/** The one action that would resolve the emptiness, if there is one. */
|
||||
readonly action?: ReactNode;
|
||||
readonly className?: string;
|
||||
}
|
||||
|
||||
export function EmptyState(props: EmptyStateProps): ReactElement {
|
||||
return (
|
||||
<div className={classNames("ek-message", props.className)}>
|
||||
<p className="ek-message__title">{props.title}</p>
|
||||
{props.description === undefined ? undefined : (
|
||||
<p className="ek-message__body">{props.description}</p>
|
||||
)}
|
||||
{props.action}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface ErrorStateProps {
|
||||
readonly title: ReactNode;
|
||||
readonly description?: ReactNode;
|
||||
/** A retry control, or whatever else the caller can honestly offer. */
|
||||
readonly action?: ReactNode;
|
||||
readonly className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The failure a person sees.
|
||||
*
|
||||
* Callers pass a fixed, human message. **Never** pass a caught exception, a stack, a URL, a
|
||||
* filesystem path, or a raw server response: every Ekklesia error boundary is sanitized, and this
|
||||
* component is where that discipline becomes visible to a user.
|
||||
*/
|
||||
export function ErrorState(props: ErrorStateProps): ReactElement {
|
||||
return (
|
||||
<div className={classNames("ek-message", "ek-message--error", props.className)} role="alert">
|
||||
<p className="ek-message__title">{props.title}</p>
|
||||
{props.description === undefined ? undefined : (
|
||||
<p className="ek-message__body">{props.description}</p>
|
||||
)}
|
||||
{props.action}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface LoadingStateProps {
|
||||
readonly label?: string;
|
||||
readonly className?: string;
|
||||
}
|
||||
|
||||
export function LoadingState(props: LoadingStateProps): ReactElement {
|
||||
const label = props.label ?? "Loading…";
|
||||
return (
|
||||
<div
|
||||
aria-live="polite"
|
||||
className={classNames("ek-message", "ek-message--loading", props.className)}
|
||||
role="status"
|
||||
>
|
||||
<div aria-hidden="true" className="ek-spinner" />
|
||||
<p className="ek-message__body">{label}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* `@ekklesia/ui` — the foundational Ekklesia elements every Ekklesia screen is built from, whether
|
||||
* that screen belongs to the Dashboard shell, to a module's own management UI, or to a renderer one
|
||||
* module contributes to another.
|
||||
*
|
||||
* Every element draws its colour, type, spacing, radius, shadow, and interaction state from
|
||||
* `@ekklesia/design-tokens`. There is no hardcoded brand value anywhere in the package, which is
|
||||
* what makes a future theme editor possible rather than aspirational.
|
||||
*
|
||||
* This is deliberately **not** a large component catalogue, a design-system site, a brand builder,
|
||||
* or a theme editor. It is the set a real module dashboard cannot be built without, and nothing
|
||||
* more; a component earns its place here when two independent consumers need the same one.
|
||||
*/
|
||||
export { classNames } from "./class-names.js";
|
||||
export {
|
||||
Button,
|
||||
type ButtonProps,
|
||||
Checkbox,
|
||||
type CheckboxProps,
|
||||
type EkklesiaButtonVariant,
|
||||
type EkklesiaControlSize,
|
||||
Field,
|
||||
type FieldProps,
|
||||
IconButton,
|
||||
type IconButtonProps,
|
||||
Select,
|
||||
type SelectProps,
|
||||
TextInput,
|
||||
type TextInputProps,
|
||||
} from "./controls.js";
|
||||
export {
|
||||
List,
|
||||
type ListItemProps,
|
||||
ListItem,
|
||||
type ListProps,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
type TableCellProps,
|
||||
TableHead,
|
||||
type TableProps,
|
||||
TableRow,
|
||||
type TableRowProps,
|
||||
type TableSectionProps,
|
||||
} from "./data.js";
|
||||
export { EkklesiaUiStyles } from "./EkklesiaUiStyles.js";
|
||||
export {
|
||||
Badge,
|
||||
type BadgeProps,
|
||||
type EkklesiaStatusTone,
|
||||
EmptyState,
|
||||
type EmptyStateProps,
|
||||
ErrorState,
|
||||
type ErrorStateProps,
|
||||
LoadingState,
|
||||
type LoadingStateProps,
|
||||
Toast,
|
||||
type ToastProps,
|
||||
ToastStack,
|
||||
type ToastStackProps,
|
||||
} from "./feedback.js";
|
||||
export {
|
||||
EKKLESIA_UI_STYLE_ATTRIBUTE,
|
||||
EKKLESIA_UI_STYLESHEET,
|
||||
type EkklesiaUiStyleRoot,
|
||||
installEkklesiaUiStyles,
|
||||
} from "./styles.js";
|
||||
export {
|
||||
Dialog,
|
||||
type DialogProps,
|
||||
Panel,
|
||||
type PanelProps,
|
||||
type TabDefinition,
|
||||
Tabs,
|
||||
type TabsProps,
|
||||
} from "./surfaces.js";
|
||||
@@ -0,0 +1,556 @@
|
||||
import { designToken } from "@ekklesia/design-tokens";
|
||||
|
||||
/**
|
||||
* Every Ekklesia element's presentation, expressed once as a stylesheet built entirely from the
|
||||
* global semantic tokens.
|
||||
*
|
||||
* ## Why a stylesheet string rather than a `.css` file
|
||||
*
|
||||
* These elements are mounted in two very different places: a document (the Dashboard shell, a
|
||||
* standalone host page) and an isolated shadow root (a module's own UI artifact, a contributed
|
||||
* renderer). A `.css` file would need a bundler on both sides and a served asset on one of them; a
|
||||
* string is installed into whichever root the caller actually has, with the identical bytes. It is
|
||||
* also what lets `:focus-visible`, `:hover`, and `:disabled` be real CSS rather than JavaScript
|
||||
* approximations of them — which is the difference between a focus treatment that works for a
|
||||
* keyboard user and one that only looks like it does.
|
||||
*
|
||||
* ## Every value here is a token reference
|
||||
*
|
||||
* There is no literal colour, font stack, radius, or shadow below: each is `var(--ekklesia-…)`
|
||||
* resolved from the registry a host installs. That is enforced by a test, not by review, so a
|
||||
* hardcoded brand colour cannot quietly enter the shared elements and make a future theme editor a
|
||||
* lie. Structural values that carry no theme meaning — a `0`, a `100%`, a `flex` — stay literal on
|
||||
* purpose: they are layout, not theme.
|
||||
*/
|
||||
export const EKKLESIA_UI_STYLESHEET = `
|
||||
.ek-root {
|
||||
color: ${designToken("text.primary")};
|
||||
font-family: ${designToken("typography.family-sans")};
|
||||
font-size: ${designToken("typography.size-md")};
|
||||
line-height: ${designToken("typography.line-height-normal")};
|
||||
}
|
||||
|
||||
.ek-stack {
|
||||
display: grid;
|
||||
gap: ${designToken("spacing.md")};
|
||||
}
|
||||
|
||||
.ek-focusable:focus-visible {
|
||||
outline: ${designToken("state.focus-visible-width")} solid ${designToken("accent.focus")};
|
||||
outline-offset: ${designToken("state.focus-visible-offset")};
|
||||
}
|
||||
|
||||
.ek-button {
|
||||
align-items: center;
|
||||
border: ${designToken("border.width")} solid transparent;
|
||||
border-radius: ${designToken("radius.md")};
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
font-family: inherit;
|
||||
font-size: ${designToken("typography.size-sm")};
|
||||
font-weight: ${designToken("typography.weight-medium")};
|
||||
gap: ${designToken("spacing.sm")};
|
||||
height: ${designToken("control.height-md")};
|
||||
justify-content: center;
|
||||
line-height: ${designToken("typography.line-height-tight")};
|
||||
padding-inline: ${designToken("control.padding-inline")};
|
||||
}
|
||||
|
||||
.ek-button--sm {
|
||||
font-size: ${designToken("typography.size-xs")};
|
||||
height: ${designToken("control.height-sm")};
|
||||
}
|
||||
|
||||
.ek-button--lg {
|
||||
font-size: ${designToken("typography.size-md")};
|
||||
height: ${designToken("control.height-lg")};
|
||||
}
|
||||
|
||||
.ek-button--primary {
|
||||
background: ${designToken("accent.base")};
|
||||
color: ${designToken("accent.foreground")};
|
||||
}
|
||||
|
||||
.ek-button--primary:hover:not(:disabled) {
|
||||
background: ${designToken("accent.strong")};
|
||||
}
|
||||
|
||||
.ek-button--primary:active:not(:disabled) {
|
||||
background: ${designToken("accent.strong")};
|
||||
}
|
||||
|
||||
.ek-button--secondary {
|
||||
background: ${designToken("background.surface")};
|
||||
border-color: ${designToken("border.base")};
|
||||
color: ${designToken("text.primary")};
|
||||
}
|
||||
|
||||
.ek-button--secondary:hover:not(:disabled) {
|
||||
background: ${designToken("state.hover-background")};
|
||||
}
|
||||
|
||||
.ek-button--secondary:active:not(:disabled) {
|
||||
background: ${designToken("state.pressed-background")};
|
||||
}
|
||||
|
||||
.ek-button--quiet {
|
||||
background: transparent;
|
||||
color: ${designToken("text.secondary")};
|
||||
}
|
||||
|
||||
.ek-button--quiet:hover:not(:disabled) {
|
||||
background: ${designToken("state.hover-background")};
|
||||
color: ${designToken("text.primary")};
|
||||
}
|
||||
|
||||
.ek-button--quiet:active:not(:disabled) {
|
||||
background: ${designToken("state.pressed-background")};
|
||||
}
|
||||
|
||||
.ek-button--danger {
|
||||
background: ${designToken("status.danger-background")};
|
||||
border-color: ${designToken("status.danger-border")};
|
||||
color: ${designToken("text.danger")};
|
||||
}
|
||||
|
||||
.ek-button--danger:hover:not(:disabled) {
|
||||
border-color: ${designToken("text.danger")};
|
||||
}
|
||||
|
||||
.ek-button[aria-pressed="true"] {
|
||||
background: ${designToken("state.selected-background")};
|
||||
color: ${designToken("state.selected-foreground")};
|
||||
}
|
||||
|
||||
.ek-button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: ${designToken("state.disabled-opacity")};
|
||||
}
|
||||
|
||||
.ek-icon-button {
|
||||
aspect-ratio: 1;
|
||||
padding-inline: 0;
|
||||
width: ${designToken("control.height-md")};
|
||||
}
|
||||
|
||||
.ek-field {
|
||||
display: grid;
|
||||
gap: ${designToken("spacing.xs")};
|
||||
}
|
||||
|
||||
.ek-field__label {
|
||||
color: ${designToken("text.primary")};
|
||||
font-size: ${designToken("typography.size-sm")};
|
||||
font-weight: ${designToken("typography.weight-medium")};
|
||||
}
|
||||
|
||||
.ek-field__hint {
|
||||
color: ${designToken("text.muted")};
|
||||
font-size: ${designToken("typography.size-xs")};
|
||||
}
|
||||
|
||||
.ek-field__error {
|
||||
color: ${designToken("text.danger")};
|
||||
font-size: ${designToken("typography.size-xs")};
|
||||
}
|
||||
|
||||
.ek-field__error::before {
|
||||
content: ${designToken("status.danger-symbol")};
|
||||
margin-inline-end: ${designToken("spacing.xs")};
|
||||
}
|
||||
|
||||
.ek-control {
|
||||
background: ${designToken("background.inset")};
|
||||
border: ${designToken("border.width")} solid ${designToken("border.base")};
|
||||
border-radius: ${designToken("radius.md")};
|
||||
color: ${designToken("text.primary")};
|
||||
font-family: inherit;
|
||||
font-size: ${designToken("typography.size-sm")};
|
||||
height: ${designToken("control.height-md")};
|
||||
padding-inline: ${designToken("control.padding-inline")};
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ek-control:hover:not(:disabled) {
|
||||
border-color: ${designToken("border.strong")};
|
||||
}
|
||||
|
||||
.ek-control::placeholder {
|
||||
color: ${designToken("text.muted")};
|
||||
}
|
||||
|
||||
.ek-control:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: ${designToken("state.disabled-opacity")};
|
||||
}
|
||||
|
||||
.ek-control[aria-invalid="true"] {
|
||||
border-color: ${designToken("text.danger")};
|
||||
}
|
||||
|
||||
.ek-checkbox {
|
||||
align-items: center;
|
||||
color: ${designToken("text.primary")};
|
||||
display: inline-flex;
|
||||
font-size: ${designToken("typography.size-sm")};
|
||||
gap: ${designToken("spacing.sm")};
|
||||
}
|
||||
|
||||
.ek-checkbox__input {
|
||||
accent-color: ${designToken("accent.base")};
|
||||
height: ${designToken("spacing.lg")};
|
||||
width: ${designToken("spacing.lg")};
|
||||
}
|
||||
|
||||
.ek-checkbox__input:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: ${designToken("state.disabled-opacity")};
|
||||
}
|
||||
|
||||
.ek-panel {
|
||||
background: ${designToken("background.surface")};
|
||||
border: ${designToken("border.width")} solid ${designToken("border.base")};
|
||||
border-radius: ${designToken("radius.lg")};
|
||||
box-shadow: ${designToken("shadow.sm")};
|
||||
display: grid;
|
||||
gap: ${designToken("spacing.md")};
|
||||
padding: ${designToken("spacing.lg")};
|
||||
}
|
||||
|
||||
.ek-panel--raised {
|
||||
background: ${designToken("background.raised")};
|
||||
box-shadow: ${designToken("shadow.md")};
|
||||
}
|
||||
|
||||
.ek-panel__header {
|
||||
align-items: baseline;
|
||||
display: flex;
|
||||
gap: ${designToken("spacing.md")};
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.ek-panel__title {
|
||||
font-size: ${designToken("typography.size-heading-3")};
|
||||
font-weight: ${designToken("typography.weight-bold")};
|
||||
line-height: ${designToken("typography.line-height-tight")};
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ek-panel__description {
|
||||
color: ${designToken("text.secondary")};
|
||||
font-size: ${designToken("typography.size-sm")};
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ek-dialog {
|
||||
background: ${designToken("background.raised")};
|
||||
border: ${designToken("border.width")} solid ${designToken("border.base")};
|
||||
border-radius: ${designToken("radius.lg")};
|
||||
box-shadow: ${designToken("shadow.lg")};
|
||||
color: ${designToken("text.primary")};
|
||||
display: grid;
|
||||
gap: ${designToken("spacing.lg")};
|
||||
max-width: 32rem;
|
||||
padding: ${designToken("spacing.xl")};
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ek-dialog__scrim {
|
||||
align-items: center;
|
||||
background: ${designToken("background.overlay")};
|
||||
display: flex;
|
||||
inset: 0;
|
||||
justify-content: center;
|
||||
padding: ${designToken("spacing.lg")};
|
||||
position: fixed;
|
||||
}
|
||||
|
||||
.ek-dialog__title {
|
||||
font-size: ${designToken("typography.size-heading-2")};
|
||||
font-weight: ${designToken("typography.weight-bold")};
|
||||
line-height: ${designToken("typography.line-height-tight")};
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ek-dialog__actions {
|
||||
display: flex;
|
||||
gap: ${designToken("spacing.sm")};
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.ek-tabs__list {
|
||||
border-bottom: ${designToken("border.width")} solid ${designToken("border.divider")};
|
||||
display: flex;
|
||||
gap: ${designToken("spacing.xs")};
|
||||
}
|
||||
|
||||
.ek-tab {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-bottom: ${designToken("state.focus-visible-width")} solid transparent;
|
||||
color: ${designToken("text.secondary")};
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: ${designToken("typography.size-sm")};
|
||||
font-weight: ${designToken("typography.weight-medium")};
|
||||
padding: ${designToken("spacing.sm")} ${designToken("spacing.md")};
|
||||
}
|
||||
|
||||
.ek-tab:hover {
|
||||
background: ${designToken("state.hover-background")};
|
||||
color: ${designToken("text.primary")};
|
||||
}
|
||||
|
||||
.ek-tab[aria-selected="true"] {
|
||||
border-bottom-color: ${designToken("accent.base")};
|
||||
color: ${designToken("state.selected-foreground")};
|
||||
}
|
||||
|
||||
.ek-tabs__panel {
|
||||
padding-block-start: ${designToken("spacing.lg")};
|
||||
}
|
||||
|
||||
.ek-table {
|
||||
border-collapse: collapse;
|
||||
font-size: ${designToken("typography.size-sm")};
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ek-table th,
|
||||
.ek-table td {
|
||||
border-bottom: ${designToken("border.width")} solid ${designToken("border.divider")};
|
||||
padding: ${designToken("spacing.sm")} ${designToken("spacing.md")};
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.ek-table th {
|
||||
color: ${designToken("text.secondary")};
|
||||
font-weight: ${designToken("typography.weight-medium")};
|
||||
}
|
||||
|
||||
.ek-table tbody tr:hover {
|
||||
background: ${designToken("state.hover-background")};
|
||||
}
|
||||
|
||||
.ek-table tbody tr[aria-selected="true"] {
|
||||
background: ${designToken("state.selected-background")};
|
||||
color: ${designToken("state.selected-foreground")};
|
||||
}
|
||||
|
||||
.ek-list {
|
||||
display: grid;
|
||||
gap: ${designToken("spacing.xs")};
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.ek-list__item {
|
||||
background: ${designToken("background.surface")};
|
||||
border: ${designToken("border.width")} solid ${designToken("border.divider")};
|
||||
border-radius: ${designToken("radius.md")};
|
||||
padding: ${designToken("spacing.sm")} ${designToken("spacing.md")};
|
||||
}
|
||||
|
||||
.ek-badge {
|
||||
align-items: center;
|
||||
border: ${designToken("border.width")} solid transparent;
|
||||
border-radius: ${designToken("radius.pill")};
|
||||
display: inline-flex;
|
||||
font-size: ${designToken("typography.size-xs")};
|
||||
font-weight: ${designToken("typography.weight-medium")};
|
||||
gap: ${designToken("spacing.xs")};
|
||||
padding: ${designToken("spacing.2xs")} ${designToken("spacing.sm")};
|
||||
}
|
||||
|
||||
.ek-badge--neutral {
|
||||
background: ${designToken("background.inset")};
|
||||
border-color: ${designToken("border.base")};
|
||||
color: ${designToken("text.secondary")};
|
||||
}
|
||||
|
||||
.ek-badge--info {
|
||||
background: ${designToken("status.info-background")};
|
||||
border-color: ${designToken("status.info-border")};
|
||||
color: ${designToken("accent.strong")};
|
||||
}
|
||||
|
||||
.ek-badge--success {
|
||||
background: ${designToken("status.success-background")};
|
||||
border-color: ${designToken("status.success-border")};
|
||||
color: ${designToken("text.success")};
|
||||
}
|
||||
|
||||
.ek-badge--warning {
|
||||
background: ${designToken("status.warning-background")};
|
||||
border-color: ${designToken("status.warning-border")};
|
||||
color: ${designToken("text.warning")};
|
||||
}
|
||||
|
||||
.ek-badge--danger {
|
||||
background: ${designToken("status.danger-background")};
|
||||
border-color: ${designToken("status.danger-border")};
|
||||
color: ${designToken("text.danger")};
|
||||
}
|
||||
|
||||
.ek-badge__symbol {
|
||||
font-weight: ${designToken("typography.weight-bold")};
|
||||
}
|
||||
|
||||
.ek-toast {
|
||||
background: ${designToken("background.raised")};
|
||||
border: ${designToken("border.width")} solid ${designToken("border.base")};
|
||||
border-inline-start-width: ${designToken("spacing.xs")};
|
||||
border-radius: ${designToken("radius.md")};
|
||||
box-shadow: ${designToken("shadow.md")};
|
||||
display: grid;
|
||||
gap: ${designToken("spacing.2xs")};
|
||||
padding: ${designToken("spacing.md")};
|
||||
}
|
||||
|
||||
.ek-toast--success {
|
||||
border-inline-start-color: ${designToken("status.success-border")};
|
||||
}
|
||||
|
||||
.ek-toast--warning {
|
||||
border-inline-start-color: ${designToken("status.warning-border")};
|
||||
}
|
||||
|
||||
.ek-toast--danger {
|
||||
border-inline-start-color: ${designToken("status.danger-border")};
|
||||
}
|
||||
|
||||
.ek-toast--info {
|
||||
border-inline-start-color: ${designToken("status.info-border")};
|
||||
}
|
||||
|
||||
.ek-toast__title {
|
||||
font-size: ${designToken("typography.size-sm")};
|
||||
font-weight: ${designToken("typography.weight-bold")};
|
||||
}
|
||||
|
||||
.ek-toast__body {
|
||||
color: ${designToken("text.secondary")};
|
||||
font-size: ${designToken("typography.size-sm")};
|
||||
}
|
||||
|
||||
.ek-toast__stack {
|
||||
display: grid;
|
||||
gap: ${designToken("spacing.sm")};
|
||||
}
|
||||
|
||||
.ek-message {
|
||||
align-items: center;
|
||||
border: ${designToken("border.width")} dashed ${designToken("border.base")};
|
||||
border-radius: ${designToken("radius.lg")};
|
||||
display: grid;
|
||||
gap: ${designToken("spacing.sm")};
|
||||
justify-items: center;
|
||||
padding: ${designToken("spacing.xl")};
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ek-message__title {
|
||||
font-size: ${designToken("typography.size-lg")};
|
||||
font-weight: ${designToken("typography.weight-bold")};
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ek-message__body {
|
||||
color: ${designToken("text.secondary")};
|
||||
font-size: ${designToken("typography.size-sm")};
|
||||
margin: 0;
|
||||
max-width: 40ch;
|
||||
}
|
||||
|
||||
.ek-message--error {
|
||||
border-color: ${designToken("status.danger-border")};
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
.ek-message--error .ek-message__title {
|
||||
color: ${designToken("text.danger")};
|
||||
}
|
||||
|
||||
.ek-message--error .ek-message__title::before {
|
||||
content: ${designToken("status.danger-symbol")};
|
||||
margin-inline-end: ${designToken("spacing.xs")};
|
||||
}
|
||||
|
||||
.ek-message--loading {
|
||||
border-style: solid;
|
||||
border-color: ${designToken("border.divider")};
|
||||
}
|
||||
|
||||
.ek-spinner {
|
||||
animation: ek-spin 1s linear infinite;
|
||||
border: ${designToken("state.focus-visible-width")} solid ${designToken("border.base")};
|
||||
border-block-start-color: ${designToken("accent.base")};
|
||||
border-radius: ${designToken("radius.pill")};
|
||||
height: ${designToken("spacing.xl")};
|
||||
width: ${designToken("spacing.xl")};
|
||||
}
|
||||
|
||||
@keyframes ek-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ek-spinner {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/** Marks a stylesheet this package installed, so installation is idempotent per root. */
|
||||
export const EKKLESIA_UI_STYLE_ATTRIBUTE = "data-ekklesia-ui";
|
||||
|
||||
/** A document or a shadow root — the two places these elements are ever mounted. Structural rather
|
||||
* than nominal for the same reason the token installer's root type is. */
|
||||
export interface EkklesiaUiStyleRoot {
|
||||
querySelector(selectors: string): Element | null;
|
||||
appendChild<Node_ extends Node>(node: Node_): Node_;
|
||||
readonly ownerDocument?: Document | null;
|
||||
}
|
||||
|
||||
function documentFor(root: EkklesiaUiStyleRoot): Document | undefined {
|
||||
if (typeof (root as Partial<Document>).createElement === "function") {
|
||||
return root as Document;
|
||||
}
|
||||
return root.ownerDocument ?? undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Installs the element stylesheet into one root, once, and returns a remover for exactly what it
|
||||
* inserted.
|
||||
*
|
||||
* The React equivalent is `<EkklesiaUiStyles />`, which is what an artifact renders so its styles
|
||||
* land inside its own isolated root rather than in the host's document. This imperative form exists
|
||||
* for a host that owns a document directly.
|
||||
*/
|
||||
export function installEkklesiaUiStyles(root: EkklesiaUiStyleRoot): () => void {
|
||||
if (root.querySelector(`style[${EKKLESIA_UI_STYLE_ATTRIBUTE}]`)) {
|
||||
return () => undefined;
|
||||
}
|
||||
const ownerDocument = documentFor(root);
|
||||
if (!ownerDocument) {
|
||||
return () => undefined;
|
||||
}
|
||||
const style = ownerDocument.createElement("style");
|
||||
style.setAttribute(EKKLESIA_UI_STYLE_ATTRIBUTE, "");
|
||||
style.textContent = EKKLESIA_UI_STYLESHEET;
|
||||
const target =
|
||||
(ownerDocument === (root as unknown as Document) ? ownerDocument.head : undefined) ?? root;
|
||||
target.appendChild(style);
|
||||
|
||||
let removed = false;
|
||||
return () => {
|
||||
if (removed) {
|
||||
return;
|
||||
}
|
||||
removed = true;
|
||||
style.remove();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import { 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 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>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const run = promisify(execFile);
|
||||
const packageDirectory = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
||||
const fixtureDirectory = path.join(packageDirectory, "tests", "browser-boundary");
|
||||
const typescript = path.join(packageDirectory, "node_modules", ".bin", "tsc");
|
||||
|
||||
async function typecheck(project: string): Promise<{ code: number; output: string }> {
|
||||
try {
|
||||
await run(typescript, ["--noEmit", "--project", path.join(fixtureDirectory, project)]);
|
||||
return { code: 0, output: "" };
|
||||
} catch (error) {
|
||||
const failure = error as { code?: number; stdout?: string; stderr?: string };
|
||||
return { code: failure.code ?? 1, output: `${failure.stdout ?? ""}${failure.stderr ?? ""}` };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The design packages are consumed by module UI, so they inherit ADR 0024's browser boundary
|
||||
* exactly: importing them must not make Node's global declarations visible to browser-only code.
|
||||
*
|
||||
* Tested the way a module author experiences it — a real compile under the exact project a module
|
||||
* uses for UI source (`browser-react.json`, `"types": []`) — rather than by inspecting the
|
||||
* dependency graph, because the leak ADR 0024 found was a *type graph* leak that no dependency list
|
||||
* would have shown.
|
||||
*/
|
||||
describe("design package browser boundary", () => {
|
||||
it("compiles supported module UI source that imports both design packages", async () => {
|
||||
const result = await typecheck("tsconfig.supported.json");
|
||||
|
||||
expect(result.output).toBe("");
|
||||
expect(result.code).toBe(0);
|
||||
}, 60_000);
|
||||
|
||||
it("refuses Node globals in UI source that imports the design packages", async () => {
|
||||
const result = await typecheck("tsconfig.node-global.json");
|
||||
|
||||
expect(result.code).not.toBe(0);
|
||||
expect(result.output).toContain("Cannot find name 'process'");
|
||||
expect(result.output).toContain("node-global.tsx");
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
// The negative case. Importing the approved design packages must NOT make Node globals visible. If
|
||||
// this file ever typechecks, the browser boundary has regressed and `browser-boundary.test.ts`
|
||||
// fails.
|
||||
import { designToken } from "@ekklesia/design-tokens";
|
||||
import { Button } from "../../src/index.js";
|
||||
|
||||
export const element = Button;
|
||||
export const color = designToken("text.primary");
|
||||
export const leaked: string | undefined = process.env.NODE_ENV;
|
||||
@@ -0,0 +1,21 @@
|
||||
// A module author's supported UI source: it imports both approved design packages and must
|
||||
// typecheck cleanly under the exact browser project a module uses for its own UI
|
||||
// (`browser-react.json`, `"types": []`).
|
||||
import { designToken, ekklesiaDesignTokens } from "@ekklesia/design-tokens";
|
||||
import type { ReactElement } from "react";
|
||||
import { Badge, Button, EkklesiaUiStyles, Panel } from "../../src/index.js";
|
||||
|
||||
export function ModuleScreen(): ReactElement {
|
||||
return (
|
||||
<>
|
||||
<EkklesiaUiStyles />
|
||||
<Panel title="Slides">
|
||||
<p style={{ color: designToken("text.secondary") }}>
|
||||
{ekklesiaDesignTokens.list().length} tokens available.
|
||||
</p>
|
||||
<Badge tone="success">Ready</Badge>
|
||||
<Button variant="primary">Add</Button>
|
||||
</Panel>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "../../../typescript-config/browser-react.json",
|
||||
"compilerOptions": { "noEmit": true, "types": [] },
|
||||
"include": ["node-global.tsx"]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "../../../typescript-config/browser-react.json",
|
||||
"compilerOptions": { "noEmit": true, "types": [] },
|
||||
"include": ["supported.tsx"]
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Checkbox,
|
||||
Dialog,
|
||||
EkklesiaUiStyles,
|
||||
EmptyState,
|
||||
ErrorState,
|
||||
Field,
|
||||
IconButton,
|
||||
List,
|
||||
ListItem,
|
||||
LoadingState,
|
||||
Panel,
|
||||
Select,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Tabs,
|
||||
TextInput,
|
||||
Toast,
|
||||
ToastStack,
|
||||
} from "../src/index.js";
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var IS_REACT_ACT_ENVIRONMENT: boolean | undefined;
|
||||
}
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
function render(node: React.ReactNode): void {
|
||||
act(() => root.render(node));
|
||||
}
|
||||
|
||||
describe("Ekklesia elements", () => {
|
||||
it("renders a button that does not submit a form by accident", () => {
|
||||
render(<Button variant="primary">Save</Button>);
|
||||
const button = container.querySelector("button");
|
||||
|
||||
expect(button?.getAttribute("type")).toBe("button");
|
||||
expect(button?.className).toContain("ek-button--primary");
|
||||
expect(button?.className).toContain("ek-focusable");
|
||||
});
|
||||
|
||||
it("requires an accessible name on an icon-only button", () => {
|
||||
render(<IconButton label="Remove slide">×</IconButton>);
|
||||
|
||||
expect(container.querySelector("button")?.getAttribute("aria-label")).toBe("Remove slide");
|
||||
});
|
||||
|
||||
it("associates a field's label, hint, and error with its control", () => {
|
||||
render(
|
||||
<Field error="Enter a name." hint="Shown on the display." label="Name">
|
||||
{(control) => <TextInput {...control} />}
|
||||
</Field>,
|
||||
);
|
||||
|
||||
const label = container.querySelector("label");
|
||||
const input = container.querySelector("input");
|
||||
const error = container.querySelector(".ek-field__error");
|
||||
|
||||
expect(input?.id).toBeTruthy();
|
||||
expect(label?.getAttribute("for")).toBe(input?.id);
|
||||
expect(input?.getAttribute("aria-invalid")).toBe("true");
|
||||
expect(input?.getAttribute("aria-describedby")?.split(" ")).toContain(error?.id);
|
||||
expect(error?.getAttribute("role")).toBe("alert");
|
||||
});
|
||||
|
||||
it("renders a select and a labelled checkbox as real form controls", () => {
|
||||
render(
|
||||
<>
|
||||
<Select aria-label="Mode">
|
||||
<option value="a">A</option>
|
||||
</Select>
|
||||
<Checkbox label="Enabled" />
|
||||
</>,
|
||||
);
|
||||
|
||||
expect(container.querySelector("select")?.className).toContain("ek-control");
|
||||
expect(container.querySelector('input[type="checkbox"]')).not.toBeNull();
|
||||
expect(container.querySelector(".ek-checkbox")?.textContent).toContain("Enabled");
|
||||
});
|
||||
|
||||
it("renders a panel with an accessible heading and its actions", () => {
|
||||
render(
|
||||
<Panel actions={<Button>New</Button>} description="What this panel is." title="Slides">
|
||||
<p>Body</p>
|
||||
</Panel>,
|
||||
);
|
||||
|
||||
expect(container.querySelector("h2")?.textContent).toBe("Slides");
|
||||
expect(container.querySelector(".ek-panel__description")?.textContent).toBe(
|
||||
"What this panel is.",
|
||||
);
|
||||
expect(container.querySelector(".ek-panel__header button")?.textContent).toBe("New");
|
||||
});
|
||||
|
||||
it("renders a dialog in place, labels it, and dismisses it on Escape", () => {
|
||||
const onDismiss = vi.fn();
|
||||
render(
|
||||
<Dialog onDismiss={onDismiss} open title="Delete slide">
|
||||
Are you sure?
|
||||
</Dialog>,
|
||||
);
|
||||
|
||||
const dialog = container.querySelector('[role="dialog"]');
|
||||
expect(dialog?.getAttribute("aria-modal")).toBe("true");
|
||||
expect(
|
||||
container.querySelector(`#${dialog?.getAttribute("aria-labelledby")}`)?.textContent,
|
||||
).toBe("Delete slide");
|
||||
// Rendered inside the caller's own subtree, never portalled to document.body: an artifact's
|
||||
// dialog must not escape its isolated root.
|
||||
expect(document.body.querySelector(":scope > [role='dialog']")).toBeNull();
|
||||
|
||||
act(() => {
|
||||
document.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Escape" }));
|
||||
});
|
||||
expect(onDismiss).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders nothing for a closed dialog", () => {
|
||||
render(
|
||||
<Dialog onDismiss={() => undefined} open={false} title="Hidden">
|
||||
Body
|
||||
</Dialog>,
|
||||
);
|
||||
|
||||
expect(container.querySelector('[role="dialog"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("exposes tabs with ARIA roles and arrow-key movement", () => {
|
||||
const onSelect = vi.fn();
|
||||
const tabs = [
|
||||
{ content: <p>One</p>, id: "one", label: "One" },
|
||||
{ content: <p>Two</p>, id: "two", label: "Two" },
|
||||
];
|
||||
render(<Tabs label="Sections" onSelect={onSelect} selectedId="one" tabs={tabs} />);
|
||||
|
||||
const tabButtons = [...container.querySelectorAll('[role="tab"]')];
|
||||
expect(tabButtons.map((tab) => tab.getAttribute("aria-selected"))).toEqual(["true", "false"]);
|
||||
expect(tabButtons.map((tab) => tab.getAttribute("tabindex"))).toEqual(["0", "-1"]);
|
||||
expect(container.querySelector('[role="tabpanel"]')?.textContent).toBe("One");
|
||||
|
||||
act(() => {
|
||||
tabButtons[0]?.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { bubbles: true, key: "ArrowRight" }),
|
||||
);
|
||||
});
|
||||
expect(onSelect).toHaveBeenCalledWith("two");
|
||||
});
|
||||
|
||||
it("renders table and list primitives with header semantics", () => {
|
||||
render(
|
||||
<>
|
||||
<Table label="Slides">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell header>Name</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
<TableRow selected>
|
||||
<TableCell>Welcome</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
<List label="Recent">
|
||||
<ListItem>First</ListItem>
|
||||
</List>
|
||||
</>,
|
||||
);
|
||||
|
||||
expect(container.querySelector("table")?.getAttribute("aria-label")).toBe("Slides");
|
||||
expect(container.querySelector("th")?.getAttribute("scope")).toBe("col");
|
||||
expect(container.querySelector("tbody tr")?.getAttribute("aria-selected")).toBe("true");
|
||||
expect(container.querySelector(".ek-list__item")?.textContent).toBe("First");
|
||||
});
|
||||
|
||||
it("never signals status with colour alone", () => {
|
||||
render(
|
||||
<>
|
||||
<Badge tone="success">Running</Badge>
|
||||
<ToastStack>
|
||||
<Toast title="Saved" tone="success">
|
||||
Everything went through.
|
||||
</Toast>
|
||||
<Toast title="Failed" tone="danger" />
|
||||
</ToastStack>
|
||||
</>,
|
||||
);
|
||||
|
||||
expect(container.querySelector(".ek-badge__symbol")?.textContent).toBe("✓");
|
||||
expect(container.querySelector(".ek-badge__symbol")?.getAttribute("aria-hidden")).toBe("true");
|
||||
const toasts = [...container.querySelectorAll(".ek-toast")];
|
||||
expect(toasts[0]?.textContent).toContain("✓");
|
||||
expect(toasts[0]?.getAttribute("aria-live")).toBe("polite");
|
||||
expect(toasts[1]?.textContent).toContain("✕");
|
||||
expect(toasts[1]?.getAttribute("aria-live")).toBe("assertive");
|
||||
});
|
||||
|
||||
it("renders empty, error, and loading states with the right announcements", () => {
|
||||
render(
|
||||
<>
|
||||
<EmptyState description="Add one to begin." title="No slides yet" />
|
||||
<ErrorState description="Try again." title="This could not load" />
|
||||
<LoadingState label="Loading slides…" />
|
||||
</>,
|
||||
);
|
||||
|
||||
expect(container.querySelector(".ek-message")?.textContent).toContain("No slides yet");
|
||||
expect(container.querySelector('[role="alert"]')?.textContent).toContain("This could not load");
|
||||
const loading = container.querySelector('[role="status"]');
|
||||
expect(loading?.getAttribute("aria-live")).toBe("polite");
|
||||
expect(loading?.textContent).toContain("Loading slides…");
|
||||
expect(container.querySelector(".ek-spinner")?.getAttribute("aria-hidden")).toBe("true");
|
||||
});
|
||||
|
||||
it("puts its stylesheet in whichever root it is rendered into", () => {
|
||||
const host = document.createElement("div");
|
||||
container.appendChild(host);
|
||||
const shadow = host.attachShadow({ mode: "open" });
|
||||
const shadowRoot = createRoot(shadow);
|
||||
|
||||
act(() =>
|
||||
shadowRoot.render(
|
||||
<>
|
||||
<EkklesiaUiStyles />
|
||||
<Button>Inside</Button>
|
||||
</>,
|
||||
),
|
||||
);
|
||||
|
||||
expect(shadow.querySelector("style[data-ekklesia-ui]")?.textContent).toContain(".ek-button");
|
||||
// The host document never receives the artifact's copy.
|
||||
expect(document.head.querySelector("style[data-ekklesia-ui]")).toBeNull();
|
||||
|
||||
act(() => shadowRoot.unmount());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { ekklesiaDesignTokens } from "@ekklesia/design-tokens";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
EKKLESIA_UI_STYLE_ATTRIBUTE,
|
||||
EKKLESIA_UI_STYLESHEET,
|
||||
installEkklesiaUiStyles,
|
||||
} from "../src/index.js";
|
||||
|
||||
const packageDirectory = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
||||
|
||||
async function sourceFiles(): Promise<readonly string[]> {
|
||||
const directory = path.join(packageDirectory, "src");
|
||||
return (await readdir(directory))
|
||||
.filter((entry) => entry.endsWith(".ts") || entry.endsWith(".tsx"))
|
||||
.map((entry) => path.join(directory, entry));
|
||||
}
|
||||
|
||||
/** Any literal that would be a theme decision rather than a layout one. */
|
||||
const THEME_LITERAL_PATTERN =
|
||||
/#[0-9a-f]{3,8}\b|\brgba?\s*\(|\bhsla?\s*\(|\boklch\s*\(|\bsystem-ui\b|\bsans-serif\b/giu;
|
||||
|
||||
describe("the shared elements consume design tokens, never hardcoded theme values", () => {
|
||||
it("expresses every theme value in the stylesheet as a token reference", () => {
|
||||
expect(EKKLESIA_UI_STYLESHEET.match(THEME_LITERAL_PATTERN)).toBeNull();
|
||||
expect(EKKLESIA_UI_STYLESHEET).toContain("var(--ekklesia-");
|
||||
});
|
||||
|
||||
it("references only registered tokens", () => {
|
||||
const referenced = [...EKKLESIA_UI_STYLESHEET.matchAll(/var\((--ekklesia-[a-z0-9-]+)\)/gu)].map(
|
||||
([, variable]) => String(variable),
|
||||
);
|
||||
const registered = new Set(ekklesiaDesignTokens.list().map((token) => token.cssVariable));
|
||||
|
||||
expect(referenced.length).toBeGreaterThan(0);
|
||||
expect([...new Set(referenced)].filter((variable) => !registered.has(variable))).toEqual([]);
|
||||
});
|
||||
|
||||
it("never defines a global Ekklesia theme variable of its own", () => {
|
||||
// Referencing `--ekklesia-*` is the contract; *declaring* one would invent a global theme
|
||||
// variable outside the registry, which is precisely what the design-token contract forbids.
|
||||
expect(EKKLESIA_UI_STYLESHEET).not.toMatch(/--ekklesia-[a-z0-9-]+\s*:/u);
|
||||
});
|
||||
|
||||
it("hardcodes no theme value in component source either", async () => {
|
||||
for (const file of await sourceFiles()) {
|
||||
if (path.basename(file) === "styles.ts") {
|
||||
continue;
|
||||
}
|
||||
expect(await readFile(file, "utf8")).not.toMatch(THEME_LITERAL_PATTERN);
|
||||
}
|
||||
});
|
||||
|
||||
it("never names a Node built-in or reads the environment", async () => {
|
||||
for (const file of await sourceFiles()) {
|
||||
const source = await readFile(file, "utf8");
|
||||
|
||||
expect(source).not.toContain('"node:');
|
||||
expect(source).not.toContain("process.env");
|
||||
}
|
||||
});
|
||||
|
||||
it("depends only on the design tokens and React", async () => {
|
||||
const manifest = JSON.parse(
|
||||
await readFile(path.join(packageDirectory, "package.json"), "utf8"),
|
||||
) as { dependencies?: Record<string, string>; peerDependencies?: Record<string, string> };
|
||||
|
||||
expect(Object.keys(manifest.dependencies ?? {})).toEqual(["@ekklesia/design-tokens"]);
|
||||
expect(Object.keys(manifest.peerDependencies ?? {})).toEqual(["react"]);
|
||||
});
|
||||
|
||||
it("installs its stylesheet into a document root exactly once", () => {
|
||||
const remove = installEkklesiaUiStyles(document);
|
||||
installEkklesiaUiStyles(document);
|
||||
|
||||
expect(document.querySelectorAll(`style[${EKKLESIA_UI_STYLE_ATTRIBUTE}]`).length).toBe(1);
|
||||
|
||||
remove();
|
||||
remove();
|
||||
expect(document.querySelectorAll(`style[${EKKLESIA_UI_STYLE_ATTRIBUTE}]`).length).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"noEmit": false,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||
"exclude": ["tests"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@ekklesia/typescript-config/browser-react.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"types": []
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "@ekklesia/typescript-config/browser-react.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"types": ["node", "vitest/globals"]
|
||||
},
|
||||
"include": ["tests/**/*.ts", "tests/**/*.tsx"],
|
||||
"exclude": ["tests/browser-boundary"]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "happy-dom",
|
||||
include: ["tests/**/*.test.ts", "tests/**/*.test.tsx"],
|
||||
passWithNoTests: false,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user