feat: establish shared Ekklesia design system
This commit is contained in:
@@ -50,6 +50,34 @@ export interface ToastProps {
|
||||
readonly children?: ReactNode;
|
||||
}
|
||||
|
||||
export interface InlineNoticeProps {
|
||||
readonly tone?: EkklesiaStatusTone;
|
||||
readonly title?: ReactNode;
|
||||
readonly className?: string;
|
||||
readonly children?: ReactNode;
|
||||
}
|
||||
|
||||
/** A calm in-flow status or explanation that should not interrupt the current task. */
|
||||
export function InlineNotice(props: InlineNoticeProps): ReactElement {
|
||||
const tone = props.tone ?? "info";
|
||||
return (
|
||||
<div
|
||||
className={classNames("ek-notice", `ek-notice--${tone}`, props.className)}
|
||||
role={tone === "danger" ? "alert" : "status"}
|
||||
>
|
||||
<span aria-hidden="true" className="ek-notice__symbol">
|
||||
{statusSymbols[tone]}
|
||||
</span>
|
||||
<div className="ek-notice__body">
|
||||
{props.title === undefined ? undefined : (
|
||||
<strong className="ek-notice__title">{props.title}</strong>
|
||||
)}
|
||||
{props.children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One notification's presentation — and only its presentation.
|
||||
*
|
||||
|
||||
@@ -52,6 +52,8 @@ export {
|
||||
type EmptyStateProps,
|
||||
ErrorState,
|
||||
type ErrorStateProps,
|
||||
InlineNotice,
|
||||
type InlineNoticeProps,
|
||||
LoadingState,
|
||||
type LoadingStateProps,
|
||||
Toast,
|
||||
@@ -66,10 +68,14 @@ export {
|
||||
installEkklesiaUiStyles,
|
||||
} from "./styles.js";
|
||||
export {
|
||||
ActionBar,
|
||||
type ActionBarProps,
|
||||
Dialog,
|
||||
type DialogProps,
|
||||
Panel,
|
||||
type PanelProps,
|
||||
PageHeader,
|
||||
type PageHeaderProps,
|
||||
type TabDefinition,
|
||||
Tabs,
|
||||
type TabsProps,
|
||||
|
||||
+111
-7
@@ -44,6 +44,7 @@ export const EKKLESIA_UI_STYLESHEET = `
|
||||
align-items: center;
|
||||
border: ${designToken("border.width")} solid transparent;
|
||||
border-radius: ${designToken("radius.md")};
|
||||
box-sizing: border-box;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
font-family: inherit;
|
||||
@@ -72,11 +73,11 @@ export const EKKLESIA_UI_STYLESHEET = `
|
||||
}
|
||||
|
||||
.ek-button--primary:hover:not(:disabled) {
|
||||
background: ${designToken("accent.strong")};
|
||||
background: ${designToken("accent.hover")};
|
||||
}
|
||||
|
||||
.ek-button--primary:active:not(:disabled) {
|
||||
background: ${designToken("accent.strong")};
|
||||
background: ${designToken("accent.active")};
|
||||
}
|
||||
|
||||
.ek-button--secondary {
|
||||
@@ -160,9 +161,10 @@ export const EKKLESIA_UI_STYLESHEET = `
|
||||
}
|
||||
|
||||
.ek-control {
|
||||
background: ${designToken("background.inset")};
|
||||
background: ${designToken("background.surface")};
|
||||
border: ${designToken("border.width")} solid ${designToken("border.base")};
|
||||
border-radius: ${designToken("radius.md")};
|
||||
box-sizing: border-box;
|
||||
color: ${designToken("text.primary")};
|
||||
font-family: inherit;
|
||||
font-size: ${designToken("typography.size-sm")};
|
||||
@@ -212,9 +214,10 @@ export const EKKLESIA_UI_STYLESHEET = `
|
||||
border: ${designToken("border.width")} solid ${designToken("border.base")};
|
||||
border-radius: ${designToken("radius.lg")};
|
||||
box-shadow: ${designToken("shadow.sm")};
|
||||
box-sizing: border-box;
|
||||
display: grid;
|
||||
gap: ${designToken("spacing.md")};
|
||||
padding: ${designToken("spacing.lg")};
|
||||
gap: ${designToken("spacing.lg")};
|
||||
padding: ${designToken("spacing.xl")};
|
||||
}
|
||||
|
||||
.ek-panel--raised {
|
||||
@@ -223,12 +226,17 @@ export const EKKLESIA_UI_STYLESHEET = `
|
||||
}
|
||||
|
||||
.ek-panel__header {
|
||||
align-items: baseline;
|
||||
align-items: flex-start;
|
||||
display: flex;
|
||||
gap: ${designToken("spacing.md")};
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.ek-panel__heading {
|
||||
display: grid;
|
||||
gap: ${designToken("spacing.xs")};
|
||||
}
|
||||
|
||||
.ek-panel__title {
|
||||
font-size: ${designToken("typography.size-heading-3")};
|
||||
font-weight: ${designToken("typography.weight-bold")};
|
||||
@@ -281,7 +289,7 @@ export const EKKLESIA_UI_STYLESHEET = `
|
||||
.ek-tabs__list {
|
||||
border-bottom: ${designToken("border.width")} solid ${designToken("border.divider")};
|
||||
display: flex;
|
||||
gap: ${designToken("spacing.xs")};
|
||||
gap: ${designToken("spacing.sm")};
|
||||
}
|
||||
|
||||
.ek-tab {
|
||||
@@ -293,6 +301,7 @@ export const EKKLESIA_UI_STYLESHEET = `
|
||||
font-family: inherit;
|
||||
font-size: ${designToken("typography.size-sm")};
|
||||
font-weight: ${designToken("typography.weight-medium")};
|
||||
border-radius: ${designToken("radius.md")} ${designToken("radius.md")} 0 0;
|
||||
padding: ${designToken("spacing.sm")} ${designToken("spacing.md")};
|
||||
}
|
||||
|
||||
@@ -302,6 +311,7 @@ export const EKKLESIA_UI_STYLESHEET = `
|
||||
}
|
||||
|
||||
.ek-tab[aria-selected="true"] {
|
||||
background: ${designToken("accent.subtle")};
|
||||
border-bottom-color: ${designToken("accent.base")};
|
||||
color: ${designToken("state.selected-foreground")};
|
||||
}
|
||||
@@ -440,6 +450,7 @@ export const EKKLESIA_UI_STYLESHEET = `
|
||||
}
|
||||
|
||||
.ek-message {
|
||||
background: ${designToken("background.canvas")};
|
||||
align-items: center;
|
||||
border: ${designToken("border.width")} dashed ${designToken("border.base")};
|
||||
border-radius: ${designToken("radius.lg")};
|
||||
@@ -450,6 +461,99 @@ export const EKKLESIA_UI_STYLESHEET = `
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ek-page-header {
|
||||
align-items: flex-end;
|
||||
display: flex;
|
||||
gap: ${designToken("spacing.xl")};
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.ek-page-header__copy {
|
||||
display: grid;
|
||||
gap: ${designToken("spacing.xs")};
|
||||
}
|
||||
|
||||
.ek-page-header__eyebrow {
|
||||
color: ${designToken("accent.strong")};
|
||||
font-size: ${designToken("typography.size-xs")};
|
||||
font-weight: ${designToken("typography.weight-bold")};
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.ek-page-header__title {
|
||||
font-size: ${designToken("typography.size-heading-1")};
|
||||
font-weight: ${designToken("typography.weight-bold")};
|
||||
line-height: ${designToken("typography.line-height-tight")};
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ek-page-header__description {
|
||||
color: ${designToken("text.secondary")};
|
||||
margin: 0;
|
||||
max-width: 64ch;
|
||||
}
|
||||
|
||||
.ek-page-header__actions,
|
||||
.ek-action-bar {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: ${designToken("spacing.sm")};
|
||||
}
|
||||
|
||||
.ek-notice {
|
||||
align-items: flex-start;
|
||||
background: ${designToken("background.inset")};
|
||||
border: ${designToken("border.width")} solid ${designToken("border.base")};
|
||||
border-radius: ${designToken("radius.md")};
|
||||
color: ${designToken("text.secondary")};
|
||||
display: flex;
|
||||
font-size: ${designToken("typography.size-sm")};
|
||||
gap: ${designToken("spacing.sm")};
|
||||
padding: ${designToken("spacing.md")};
|
||||
}
|
||||
|
||||
.ek-notice--info {
|
||||
background: ${designToken("status.info-background")};
|
||||
border-color: ${designToken("status.info-border")};
|
||||
}
|
||||
|
||||
.ek-notice--success {
|
||||
background: ${designToken("status.success-background")};
|
||||
border-color: ${designToken("status.success-border")};
|
||||
}
|
||||
|
||||
.ek-notice--warning {
|
||||
background: ${designToken("status.warning-background")};
|
||||
border-color: ${designToken("status.warning-border")};
|
||||
}
|
||||
|
||||
.ek-notice--danger {
|
||||
background: ${designToken("status.danger-background")};
|
||||
border-color: ${designToken("status.danger-border")};
|
||||
color: ${designToken("text.danger")};
|
||||
}
|
||||
|
||||
.ek-notice__symbol,
|
||||
.ek-notice__title {
|
||||
color: ${designToken("text.primary")};
|
||||
font-weight: ${designToken("typography.weight-bold")};
|
||||
}
|
||||
|
||||
.ek-notice__body {
|
||||
display: grid;
|
||||
gap: ${designToken("spacing.2xs")};
|
||||
}
|
||||
|
||||
@media (max-width: 48rem) {
|
||||
.ek-page-header,
|
||||
.ek-panel__header {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.ek-message__title {
|
||||
font-size: ${designToken("typography.size-lg")};
|
||||
font-weight: ${designToken("typography.weight-bold")};
|
||||
|
||||
@@ -1,9 +1,63 @@
|
||||
import { type ReactElement, type ReactNode, useEffect, useId, useRef } from "react";
|
||||
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;
|
||||
@@ -26,7 +80,7 @@ export function Panel(props: PanelProps): ReactElement {
|
||||
>
|
||||
{hasHeader ? (
|
||||
<header className="ek-panel__header">
|
||||
<div>
|
||||
<div className="ek-panel__heading">
|
||||
{props.title === undefined ? undefined : (
|
||||
<h2 className="ek-panel__title">{props.title}</h2>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user