Extract independent MIT Mortar kit and contribution workflow
Mortar contribution checks / Mortar baseline and browser checks (push) Successful in 3m15s

This commit is contained in:
tony committed 2026-10-13 22:16:21 +01:00
commit 43fe76158c
108 files changed
+10081

No files matched your search

+418
View File
@@ -0,0 +1,418 @@
import { useId, useRef, useEffect, useState, type ReactNode } from "react";
import { Dialog, DialogPanel, DialogTitle } from "@headlessui/react";
import { Button, EmptyState, Notice, type Tone } from "./primitives.js";
export interface NavigationItem {
label: string;
href: string;
current?: boolean;
count?: string;
}
export interface AppShellProps {
brand: string;
workspace?: string;
user?: ReactNode;
navigation: NavigationItem[];
secondaryNavigation?: { label: string; items: NavigationItem[] }[];
header?: ReactNode;
actions?: ReactNode;
children: ReactNode;
}
export function AppShell({
brand,
workspace,
user,
navigation,
secondaryNavigation = [],
header,
actions,
children,
}: AppShellProps) {
const [open, setOpen] = useState(false);
const links = (items: NavigationItem[]) =>
items.map((item) => (
<a
key={item.href}
href={item.href}
aria-current={item.current ? "page" : undefined}
onClick={() => setOpen(false)}
>
<span className="m-nav-marker" aria-hidden="true" />
{item.label}
{item.count && <span className="m-nav-count">{item.count}</span>}
</a>
));
const content = (
<>
<a className="m-workspace" href={navigation[0]?.href || "#"}>
<span className="m-mark" aria-hidden="true" />
<span>
<strong>{brand}</strong>
{workspace && <small>{workspace}</small>}
</span>
</a>
<nav aria-label="Primary">
{links(navigation)}
{secondaryNavigation.map((group) => (
<section key={group.label}>
<h2>{group.label}</h2>
{links(group.items)}
</section>
))}
</nav>
{user && <div className="m-user">{user}</div>}
</>
);
return (
<div className="m-app-shell">
<a className="m-skip" href="#m-content">
Skip to content
</a>
<aside className="m-sidebar">{content}</aside>
<div className="m-app-main">
<header className="m-app-header">
<Button
variant="ghost"
className="m-menu-trigger"
onClick={() => setOpen(true)}
aria-label="Open navigation"
>
Menu
</Button>
<div className="m-header-context">{header || workspace || brand}</div>
<div className="m-actions">{actions}</div>
</header>
<main id="m-content" tabIndex={-1}>
{children}
</main>
</div>
<Dialog open={open} onClose={() => setOpen(false)} className="m-dialog">
<div className="m-backdrop" aria-hidden="true" />
<DialogPanel className="m-mobile-sidebar">
<div className="m-dialog-heading">
<DialogTitle>{brand}</DialogTitle>
<Button variant="ghost" onClick={() => setOpen(false)}>
Close navigation
</Button>
</div>
{content}
</DialogPanel>
</Dialog>
</div>
);
}
export function PageHeading({
title,
description,
eyebrow,
actions,
breadcrumbs,
headingLevel = 1,
}: {
title: string;
description?: string;
eyebrow?: string;
actions?: ReactNode;
breadcrumbs?: { label: string; href?: string }[];
headingLevel?: 1 | 2;
}) {
const Heading = headingLevel === 1 ? "h1" : "h2";
return (
<header className="m-page-heading">
{breadcrumbs && (
<nav aria-label="Breadcrumb">
<ol>
{breadcrumbs.map((b, i) => (
<li key={i}>
{b.href ? (
<a href={b.href}>{b.label}</a>
) : (
<span aria-current="page">{b.label}</span>
)}
</li>
))}
</ol>
</nav>
)}
<div className="m-heading-row">
<div>
{eyebrow && <p className="m-eyebrow">{eyebrow}</p>}
<Heading>{title}</Heading>
{description && <p>{description}</p>}
</div>
{actions && <div className="m-actions">{actions}</div>}
</div>
</header>
);
}
export function MetricGroup({
label,
items,
}: {
label: string;
items: { label: string; value: string; change?: string; tone?: Tone }[];
}) {
return (
<dl className="m-metrics" aria-label={label}>
{items.map((item) => (
<div key={item.label}>
<dt>{item.label}</dt>
<dd>
<strong>{item.value}</strong>
{item.change && (
<span className={`m-tone-${item.tone || "neutral"}`}>
{item.change}
</span>
)}
</dd>
</div>
))}
</dl>
);
}
export interface DataTableProps<T extends { id: string }> {
caption: string;
columns: {
key: string;
label: string;
render: (row: T) => ReactNode;
align?: "left" | "right";
}[];
rows: T[];
selectedIds?: string[];
onSelectionChange?: (ids: string[]) => void;
rowLabel: (row: T) => string;
toolbar?: ReactNode;
bulkActions?: ReactNode;
emptyTitle?: string;
emptyDescription?: string;
loading?: boolean;
error?: string;
}
function SelectAll({
checked,
partial,
onChange,
}: {
checked: boolean;
partial: boolean;
onChange: () => void;
}) {
const ref = useRef<HTMLInputElement>(null);
useEffect(() => {
if (ref.current) ref.current.indeterminate = partial;
}, [partial]);
return (
<input
ref={ref}
type="checkbox"
aria-label="Select all rows"
checked={checked}
onChange={onChange}
/>
);
}
export function DataTable<T extends { id: string }>({
caption,
columns,
rows,
selectedIds = [],
onSelectionChange,
rowLabel,
toolbar,
bulkActions,
emptyTitle = "No records",
emptyDescription = "Records will appear here when they are available.",
loading,
error,
}: DataTableProps<T>) {
const selected = rows.filter((row) => selectedIds.includes(row.id));
return (
<section className="m-data-table" aria-label={caption}>
{(toolbar || selected.length > 0) && (
<div className="m-table-toolbar">
{selected.length > 0 ? (
<>
<strong>{selected.length} selected</strong>
<div className="m-actions">
{bulkActions}
<Button variant="ghost" onClick={() => onSelectionChange?.([])}>
Clear selection
</Button>
</div>
</>
) : (
toolbar
)}
</div>
)}
{loading ? (
<p className="m-table-state" role="status">
Loading records…
</p>
) : error ? (
<Notice title="Unable to load records" tone="danger" announce>
{error}
</Notice>
) : !rows.length ? (
<EmptyState title={emptyTitle} description={emptyDescription} />
) : (
<div
className="m-table-scroll"
role="region"
aria-label={`${caption} scroll area`}
tabIndex={0}
>
<table>
<caption className="m-sr-only">{caption}</caption>
<thead>
<tr>
{onSelectionChange && (
<th scope="col">
<SelectAll
checked={selected.length === rows.length}
partial={
selected.length > 0 && selected.length < rows.length
}
onChange={() =>
onSelectionChange(
selected.length === rows.length
? []
: rows.map((r) => r.id),
)
}
/>
</th>
)}
{columns.map((col) => (
<th key={col.key} scope="col" data-align={col.align}>
{col.label}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr
key={row.id}
data-selected={selectedIds.includes(row.id) || undefined}
>
{onSelectionChange && (
<td>
<input
type="checkbox"
aria-label={`Select ${rowLabel(row)}`}
checked={selectedIds.includes(row.id)}
onChange={() =>
onSelectionChange(
selectedIds.includes(row.id)
? selectedIds.filter((id) => id !== row.id)
: [...selectedIds, row.id],
)
}
/>
</td>
)}
{columns.map((col, index) =>
index === 0 ? (
<th scope="row" key={col.key} data-align={col.align}>
{col.render(row)}
</th>
) : (
<td key={col.key} data-align={col.align}>
{col.render(row)}
</td>
),
)}
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
);
}
export function Pagination({
page,
pageCount,
onChange,
}: {
page: number;
pageCount: number;
onChange: (page: number) => void;
}) {
return (
<nav className="m-pagination" aria-label="Pagination">
<span aria-live="polite">
Page {page} of {Math.max(1, pageCount)}
</span>
<div className="m-actions">
<Button
variant="secondary"
disabled={page <= 1}
onClick={() => onChange(page - 1)}
>
Previous
</Button>
<Button
variant="secondary"
disabled={page >= pageCount}
onClick={() => onChange(page + 1)}
>
Next
</Button>
</div>
</nav>
);
}
export function SettingsSection({
title,
description,
children,
}: {
title: string;
description?: string;
children: ReactNode;
}) {
const id = useId();
return (
<section className="m-settings-section" aria-labelledby={id}>
<div>
<h2 id={id}>{title}</h2>
{description && <p>{description}</p>}
</div>
<div className="m-stack">{children}</div>
</section>
);
}
export function ActivityFeed({
label,
items,
}: {
label: string;
items: {
id: string;
title: string;
description?: string;
time: string;
tone?: Tone;
}[];
}) {
return (
<ol className="m-activity" aria-label={label}>
{items.map((item) => (
<li key={item.id}>
<span
aria-hidden="true"
className={`m-activity-dot m-tone-${item.tone || "neutral"}`}
/>
<div>
<strong>{item.title}</strong>
{item.description && <p>{item.description}</p>}
</div>
<span>{item.time}</span>
</li>
))}
</ol>
);
}
+1289
View File
File diff suppressed because it is too large. Load diff
+99
View File
@@ -0,0 +1,99 @@
import {
createContext,
useContext,
useEffect,
useState,
type ReactNode,
} from "react";
export type Appearance = "light" | "dark" | "system";
export type Brand = "neutral" | "green";
export type Density = "compact" | "comfortable" | "spacious";
type Theme = {
appearance: Appearance;
setAppearance: (value: Appearance) => void;
brand: Brand;
density: Density;
};
const ThemeContext = createContext<Theme | null>(null);
export interface ThemeProviderProps {
children: ReactNode;
initialAppearance?: Appearance;
brand?: Brand;
density?: Density;
}
/** One provider per application. Selection never requires a hosted account. */
export function ThemeProvider({
children,
initialAppearance = "system",
brand = "neutral",
density = "comfortable",
}: ThemeProviderProps) {
const [appearance, setAppearance] = useState<Appearance>(initialAppearance);
useEffect(() => {
const element = document.documentElement;
const attributes = {
"data-mortar": "",
"data-mortar-brand": brand,
"data-mortar-density": density,
};
const previous = Object.keys(attributes).map(
(key) => [key, element.getAttribute(key)] as const,
);
for (const [key, value] of Object.entries(attributes))
element.setAttribute(key, value);
return () => {
for (const [key, value] of previous)
value === null
? element.removeAttribute(key)
: element.setAttribute(key, value);
};
}, [brand, density]);
useEffect(() => {
const element = document.documentElement;
const previous = element.getAttribute("data-mortar-appearance");
const media = window.matchMedia("(prefers-color-scheme: dark)");
const update = () =>
element.setAttribute(
"data-mortar-appearance",
appearance === "system"
? media.matches
? "dark"
: "light"
: appearance,
);
update();
media.addEventListener("change", update);
return () => {
media.removeEventListener("change", update);
previous === null
? element.removeAttribute("data-mortar-appearance")
: element.setAttribute("data-mortar-appearance", previous);
};
}, [appearance]);
return (
<ThemeContext.Provider
value={{ appearance, setAppearance, brand, density }}
>
{children}
</ThemeContext.Provider>
);
}
export function AppearanceControl() {
const context = useContext(ThemeContext);
if (!context) throw new Error("AppearanceControl requires ThemeProvider");
return (
<div className="m-segmented" role="group" aria-label="Appearance">
{(["light", "dark", "system"] as const).map((value) => (
<button
key={value}
type="button"
aria-pressed={context.appearance === value}
onClick={() => context.setAppearance(value)}
>
{value[0].toUpperCase() + value.slice(1)}
</button>
))}
</div>
);
}
+57
View File
@@ -0,0 +1,57 @@
export { ThemeProvider, AppearanceControl } from "./foundation.js";
export type {
ThemeProviderProps,
Appearance,
Brand,
Density,
} from "./foundation.js";
export {
Button,
ActionLink,
Input,
Select,
TextArea,
Checkbox,
RadioGroup,
Switch,
Badge,
Tabs,
Modal,
Dropdown,
Notice,
EmptyState,
} from "./primitives.js";
export type {
ButtonProps,
InputProps,
LinkAction,
Tone,
} from "./primitives.js";
export {
AppShell,
PageHeading,
MetricGroup,
DataTable,
Pagination,
SettingsSection,
ActivityFeed,
} from "./application.js";
export type {
AppShellProps,
NavigationItem,
DataTableProps,
} from "./application.js";
export {
MarketingHeader,
Hero,
FeatureGrid,
FeatureSection,
Testimonial,
LogoCloud,
Pricing,
FAQ,
CallToAction,
Footer,
} from "./marketing.js";
export type { PricingPlan } from "./marketing.js";
export { SegmentedControl, CodeBlock, PreviewFrame } from "./inspection.js";
+59
View File
@@ -0,0 +1,59 @@
import { useId } from "react";
export function SegmentedControl({
label,
value,
options,
onChange,
}: {
label: string;
value: string;
options: { value: string; label: string }[];
onChange: (value: string) => void;
}) {
return (
<div className="m-segmented" role="group" aria-label={label}>
{options.map((option) => (
<button
key={option.value}
type="button"
aria-pressed={value === option.value}
onClick={() => onChange(option.value)}
>
{option.label}
</button>
))}
</div>
);
}
export function CodeBlock({
code,
label = "Code",
}: {
code: string;
label?: string;
}) {
const id = useId();
return (
<figure className="m-code-block">
<figcaption id={id}>{label}</figcaption>
<pre tabIndex={0} aria-labelledby={id}>
<code>{code}</code>
</pre>
</figure>
);
}
export function PreviewFrame({ title, src }: { title: string; src: string }) {
return (
<section className="m-preview-frame" aria-label={title}>
<a href={src} target="_blank" rel="noopener noreferrer">
Open full preview
</a>
<iframe
title={title}
src={src}
sandbox="allow-scripts"
referrerPolicy="no-referrer"
/>
</section>
);
}
+342
View File
@@ -0,0 +1,342 @@
import { useState, type ReactNode } from "react";
import { ActionLink, Button, type LinkAction } from "./primitives.js";
export function MarketingHeader({
brand,
homeHref = "/",
links,
action,
controls,
}: {
brand: string;
homeHref?: string;
links: LinkAction[];
action?: LinkAction;
controls?: ReactNode;
}) {
const [open, setOpen] = useState(false);
return (
<header className="m-marketing-header">
<a className="m-brand" href={homeHref}>
<span className="m-mark" aria-hidden="true" />
{brand}
</a>
<Button
variant="ghost"
className="m-menu-trigger"
aria-expanded={open}
aria-controls="m-marketing-nav"
onClick={() => setOpen(!open)}
>
Menu
</Button>
<nav id="m-marketing-nav" aria-label="Primary" data-open={open}>
{links.map((link) => (
<a key={link.href} href={link.href} onClick={() => setOpen(false)}>
{link.label}
</a>
))}
</nav>
<div className="m-actions">
{controls}
{action && <ActionLink {...action} />}
</div>
</header>
);
}
export function Hero({
eyebrow,
title,
description,
actions = [],
children,
align = "center",
headingLevel = 1,
}: {
eyebrow?: string;
title: string;
description: string;
actions?: LinkAction[];
children?: ReactNode;
align?: "center" | "split";
headingLevel?: 1 | 2;
}) {
const Heading = headingLevel === 1 ? "h1" : "h2";
return (
<section className={`m-hero m-hero-${align}`}>
<div>
{eyebrow && <p className="m-eyebrow">{eyebrow}</p>}
<Heading>{title}</Heading>
<p className="m-hero-description">{description}</p>
<div className="m-actions">
{actions.map((action, i) => (
<ActionLink
key={action.href}
{...action}
variant={i === 0 ? "primary" : "secondary"}
/>
))}
</div>
</div>
{children && <div className="m-hero-preview">{children}</div>}
</section>
);
}
export function FeatureGrid({
eyebrow,
title,
description,
items,
}: {
eyebrow?: string;
title: string;
description?: string;
items: { title: string; description: string; icon?: ReactNode }[];
}) {
return (
<section className="m-marketing-section">
{eyebrow && <p className="m-eyebrow">{eyebrow}</p>}
<h2>{title}</h2>
{description && <p className="m-section-description">{description}</p>}
<div className="m-feature-grid">
{items.map((item, i) => (
<article key={item.title}>
<span className="m-feature-icon" aria-hidden="true">
{item.icon || String(i + 1).padStart(2, "0")}
</span>
<h3>{item.title}</h3>
<p>{item.description}</p>
</article>
))}
</div>
</section>
);
}
export function FeatureSection({
title,
description,
children,
reverse = false,
}: {
title: string;
description: string;
children: ReactNode;
reverse?: boolean;
}) {
return (
<section className="m-feature-section" data-reverse={reverse}>
<div>
<h2>{title}</h2>
<p>{description}</p>
</div>
<div className="m-feature-preview">{children}</div>
</section>
);
}
export function Testimonial({
quote,
name,
role,
}: {
quote: string;
name: string;
role?: string;
}) {
return (
<figure className="m-testimonial">
<blockquote>{quote}</blockquote>
<figcaption>
<strong>{name}</strong>
{role && <span>{role}</span>}
</figcaption>
</figure>
);
}
export function LogoCloud({
label,
items,
}: {
label: string;
items: { name: string; image?: string }[];
}) {
return (
<section className="m-logo-cloud" aria-label={label}>
<p>{label}</p>
<ul>
{items.map((item) => (
<li key={item.name}>
{item.image ? (
<img src={item.image} alt={item.name} />
) : (
<span>{item.name}</span>
)}
</li>
))}
</ul>
</section>
);
}
export interface PricingPlan {
id: string;
name: string;
description: string;
monthly: string;
annual?: string;
unit?: string;
features: string[];
action: LinkAction;
featured?: boolean;
}
export function Pricing({
title,
description,
plans,
}: {
title: string;
description?: string;
plans: PricingPlan[];
}) {
const [annual, setAnnual] = useState(true),
toggle = plans.some((p) => p.annual);
return (
<section className="m-marketing-section m-pricing">
<h2>{title}</h2>
{description && <p className="m-section-description">{description}</p>}
{toggle && (
<div className="m-segmented" role="group" aria-label="Billing interval">
<button
type="button"
aria-pressed={annual}
onClick={() => setAnnual(true)}
>
Annual
</button>
<button
type="button"
aria-pressed={!annual}
onClick={() => setAnnual(false)}
>
Monthly
</button>
</div>
)}
<div className="m-pricing-grid">
{plans.map((plan) => (
<article key={plan.id} data-featured={plan.featured}>
<h3>{plan.name}</h3>
<p>{plan.description}</p>
<div className="m-price">
<strong>
{annual && plan.annual ? plan.annual : plan.monthly}
</strong>
{plan.unit && <span>{plan.unit}</span>}
</div>
<ul>
{plan.features.map((feature) => (
<li key={feature}>{feature}</li>
))}
</ul>
<ActionLink {...plan.action} variant="secondary" />
</article>
))}
</div>
</section>
);
}
export function FAQ({
title,
description,
items,
}: {
title: string;
description?: ReactNode;
items: { question: string; answer: ReactNode }[];
}) {
return (
<section className="m-faq">
<div>
<h2>{title}</h2>
{description && <div>{description}</div>}
</div>
<div>
{items.map((item) => (
<details key={item.question}>
<summary>
{item.question}
<span aria-hidden="true">+</span>
</summary>
<div>{item.answer}</div>
</details>
))}
</div>
</section>
);
}
export function CallToAction({
title,
description,
actions,
}: {
title: string;
description?: string;
actions: LinkAction[];
}) {
return (
<section className="m-cta">
<h2>{title}</h2>
{description && <p>{description}</p>}
<div className="m-actions">
{actions.map((action, i) => (
<ActionLink
key={action.href}
{...action}
variant={i === 0 ? "primary" : "secondary"}
/>
))}
</div>
</section>
);
}
export function Footer({
brand,
description,
columns,
copyright,
legal = [],
}: {
brand: string;
description: string;
columns: { title: string; links: LinkAction[] }[];
copyright: string;
legal?: LinkAction[];
}) {
return (
<footer className="m-footer">
<div className="m-footer-grid">
<div>
<strong>{brand}</strong>
<p>{description}</p>
</div>
{columns.map((column) => (
<nav key={column.title} aria-label={column.title}>
<h2>{column.title}</h2>
{column.links.map((link) => (
<a href={link.href} key={link.href}>
{link.label}
</a>
))}
</nav>
))}
</div>
<div className="m-footer-bottom">
<span>{copyright}</span>
<div>
{legal.map((link) => (
<a key={link.href} href={link.href}>
{link.label}
</a>
))}
</div>
</div>
</footer>
);
}
+429
View File
@@ -0,0 +1,429 @@
import {
forwardRef,
useId,
type ButtonHTMLAttributes,
type InputHTMLAttributes,
type SelectHTMLAttributes,
type TextareaHTMLAttributes,
type ReactNode,
} from "react";
import {
Dialog as HeadlessDialog,
DialogPanel,
DialogTitle,
Menu,
MenuButton,
MenuItems,
MenuItem,
} from "@headlessui/react";
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "primary" | "secondary" | "ghost" | "danger";
loading?: boolean;
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
function Button(
{
variant = "primary",
loading = false,
disabled,
children,
className = "",
type = "button",
...props
},
ref,
) {
return (
<button
{...props}
ref={ref}
type={type}
disabled={disabled || loading}
aria-busy={loading || undefined}
className={`m-button m-button-${variant} ${className}`}
>
{loading && <span className="m-spinner" aria-hidden="true" />}
{children}
</button>
);
},
);
export interface LinkAction {
label: string;
href: string;
}
export function ActionLink({
label,
href,
variant = "primary",
}: LinkAction & { variant?: ButtonProps["variant"] }) {
return (
<a className={`m-button m-button-${variant}`} href={href}>
{label}
</a>
);
}
export type Tone = "neutral" | "success" | "warning" | "danger";
export function Badge({
children,
tone = "neutral",
}: {
children: ReactNode;
tone?: Tone;
}) {
return <span className={`m-badge m-tone-${tone}`}>{children}</span>;
}
interface FieldDescription {
label: string;
hint?: string;
error?: string;
}
export interface InputProps
extends InputHTMLAttributes<HTMLInputElement>, FieldDescription {
prefix?: string;
suffix?: string;
}
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
{ label, hint, error, id, prefix, suffix, className = "", ...props },
ref,
) {
const generated = useId(),
fieldId = id || generated,
messageId = fieldId + "-message";
return (
<div className="m-field">
<label htmlFor={fieldId}>{label}</label>
<div className="m-input-group" data-invalid={!!error || undefined}>
{prefix && <span>{prefix}</span>}
<input
{...props}
ref={ref}
id={fieldId}
className={className}
aria-invalid={!!error || props["aria-invalid"]}
aria-describedby={
[props["aria-describedby"], (error || hint) && messageId]
.filter(Boolean)
.join(" ") || undefined
}
/>
{suffix && <span>{suffix}</span>}
</div>
{(error || hint) && (
<p id={messageId} className={error ? "m-error" : ""}>
{error || hint}
</p>
)}
</div>
);
});
export function Select({
label,
options,
id,
hint,
error,
...props
}: SelectHTMLAttributes<HTMLSelectElement> &
FieldDescription & { options: { value: string; label: string }[] }) {
const generated = useId(),
fieldId = id || generated,
listbox = !!props.multiple || (props.size || 0) > 1;
return (
<div className="m-field">
<label htmlFor={fieldId}>{label}</label>
<div className="m-select-control" data-listbox={listbox || undefined}>
<select
{...props}
id={fieldId}
aria-invalid={!!error || props["aria-invalid"]}
aria-describedby={
[props["aria-describedby"], (hint || error) && fieldId + "-message"]
.filter(Boolean)
.join(" ") || undefined
}
>
{options.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
{!listbox && (
<svg
viewBox="0 0 20 20"
fill="none"
aria-hidden="true"
focusable="false"
>
<path
d="m6 8 4 4 4-4"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)}
</div>
{(hint || error) && (
<p id={fieldId + "-message"} className={error ? "m-error" : ""}>
{error || hint}
</p>
)}
</div>
);
}
export function TextArea({
label,
id,
hint,
error,
...props
}: TextareaHTMLAttributes<HTMLTextAreaElement> & FieldDescription) {
const generated = useId(),
fieldId = id || generated;
return (
<div className="m-field">
<label htmlFor={fieldId}>{label}</label>
<textarea
{...props}
id={fieldId}
aria-invalid={!!error || props["aria-invalid"]}
aria-describedby={
[props["aria-describedby"], (hint || error) && fieldId + "-message"]
.filter(Boolean)
.join(" ") || undefined
}
/>
{(hint || error) && (
<p id={fieldId + "-message"} className={error ? "m-error" : ""}>
{error || hint}
</p>
)}
</div>
);
}
export function Checkbox({
label,
...props
}: Omit<InputHTMLAttributes<HTMLInputElement>, "type"> & { label: string }) {
return (
<label className="m-choice">
<input {...props} type="checkbox" />
{label}
</label>
);
}
export function Switch({
label,
...props
}: Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "role"> & {
label: string;
}) {
return (
<label className="m-choice">
<input {...props} type="checkbox" role="switch" className="m-switch" />
{label}
</label>
);
}
export function RadioGroup({
label,
name,
value,
onChange,
options,
}: {
label: string;
name: string;
value: string;
onChange: (value: string) => void;
options: { value: string; label: string; description?: string }[];
}) {
return (
<fieldset className="m-radio-group">
<legend>{label}</legend>
{options.map((o) => (
<label className="m-radio-card" key={o.value}>
<input
type="radio"
name={name}
value={o.value}
checked={value === o.value}
onChange={() => onChange(o.value)}
/>
<span>
<strong>{o.label}</strong>
{o.description && <span>{o.description}</span>}
</span>
</label>
))}
</fieldset>
);
}
export function Tabs({
label,
value,
onChange,
items,
}: {
label: string;
value: string;
onChange: (value: string) => void;
items: { id: string; label: string; content: ReactNode }[];
}) {
const base = useId();
return (
<div>
<div
className="m-tabs"
role="tablist"
aria-label={label}
onKeyDown={(e) => {
const i = items.findIndex((item) => item.id === value);
let next = i;
if (e.key === "ArrowRight") next = (i + 1) % items.length;
else if (e.key === "ArrowLeft")
next = (i - 1 + items.length) % items.length;
else if (e.key === "Home") next = 0;
else if (e.key === "End") next = items.length - 1;
else return;
e.preventDefault();
onChange(items[next].id);
document.getElementById(`${base}-${items[next].id}-tab`)?.focus();
}}
>
{items.map((item) => (
<button
key={item.id}
id={`${base}-${item.id}-tab`}
type="button"
role="tab"
aria-selected={item.id === value}
aria-controls={`${base}-${item.id}-panel`}
tabIndex={item.id === value ? 0 : -1}
onClick={() => onChange(item.id)}
>
{item.label}
</button>
))}
</div>
{items.map((item) => (
<div
key={item.id}
hidden={item.id !== value}
role="tabpanel"
id={`${base}-${item.id}-panel`}
aria-labelledby={`${base}-${item.id}-tab`}
tabIndex={0}
>
{item.content}
</div>
))}
</div>
);
}
export function Modal({
open,
onClose,
title,
children,
actions,
}: {
open: boolean;
onClose: () => void;
title: string;
children: ReactNode;
actions?: ReactNode;
}) {
return (
<HeadlessDialog open={open} onClose={onClose} className="m-dialog">
<div className="m-backdrop" aria-hidden="true" />
<div className="m-dialog-position">
<DialogPanel className="m-dialog-panel">
<div className="m-dialog-heading">
<DialogTitle>{title}</DialogTitle>
<Button variant="ghost" onClick={onClose} aria-label="Close dialog">
Close
</Button>
</div>
{children}
{actions && <div className="m-actions">{actions}</div>}
</DialogPanel>
</div>
</HeadlessDialog>
);
}
export function Dropdown({
label,
items,
}: {
label: string;
items: {
label: string;
onSelect: () => void;
danger?: boolean;
disabled?: boolean;
}[];
}) {
return (
<Menu>
<MenuButton className="m-button m-button-secondary">
{label}
<span aria-hidden="true">⌄</span>
</MenuButton>
<MenuItems anchor="bottom end" className="m-menu">
{items.map((item) => (
<MenuItem key={item.label} disabled={item.disabled}>
<button
type="button"
className={item.danger ? "m-error" : ""}
onClick={item.onSelect}
>
{item.label}
</button>
</MenuItem>
))}
</MenuItems>
</Menu>
);
}
export function Notice({
title,
children,
tone = "neutral",
announce = false,
}: {
title: string;
children?: ReactNode;
tone?: Tone;
announce?: boolean;
}) {
return (
<div
className={`m-notice m-tone-${tone}`}
role={announce ? (tone === "danger" ? "alert" : "status") : undefined}
>
<strong>{title}</strong>
{children && <div>{children}</div>}
</div>
);
}
export function EmptyState({
title,
description,
action,
}: {
title: string;
description: string;
action?: ReactNode;
}) {
return (
<div className="m-empty">
<h3>{title}</h3>
<p>{description}</p>
{action}
</div>
);
}
+90
View File
@@ -0,0 +1,90 @@
/* Project-agnostic semantic tokens derived from the supplied Mortar v2 designs.
Status colours deliberately differ from brand colours. No hosted service dependency. */
:root {
--m-font:
-apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue",
sans-serif;
--m-mono: "SF Mono", ui-monospace, Menlo, monospace;
--m-paper: #ffffff;
--m-surface: #ffffff;
--m-sunken: #f5f5f7;
--m-line: #e5e5ea;
--m-line-strong: #8e8e93;
--m-ink: #000000;
--m-secondary: #3a3a3c;
--m-muted: #6e6e73;
--m-primary: #000000;
--m-primary-hover: #29292c;
--m-primary-active: #3a3a3c;
--m-on-primary: #ffffff;
--m-focus: #000000;
--m-link: #000000;
--m-selected: #f2f2f4;
--m-success: #1f7a36;
--m-warning: #945700;
--m-danger: #c70014;
--m-control: 36px;
--m-pad: 14px;
--m-font-size: 14px;
--m-row-pad: 14px;
--m-radius-sm: 4px;
--m-radius: 8px;
--m-radius-lg: 12px;
--m-float: 0 12px 32px -12px rgb(0 0 0 / 0.14);
}
:root[data-mortar] {
color-scheme: light;
}
:root[data-mortar-appearance="dark"] {
color-scheme: dark;
--m-paper: #000000;
--m-surface: #0e0e10;
--m-sunken: #1c1c1e;
--m-line: #29292c;
--m-line-strong: #727278;
--m-ink: #f5f5f7;
--m-secondary: #c7c7cc;
--m-muted: #98989d;
--m-primary: #ffffff;
--m-primary-hover: #e5e5ea;
--m-primary-active: #c7c7cc;
--m-on-primary: #000000;
--m-focus: #ffffff;
--m-link: #f5f5f7;
--m-selected: #1c1c1e;
--m-success: #30d158;
--m-warning: #ffd60a;
--m-danger: #ff6961;
--m-float: none;
}
:root[data-mortar-brand="green"] {
--m-primary: #047857;
--m-primary-hover: #065f46;
--m-primary-active: #064e3b;
--m-on-primary: #ffffff;
--m-focus: #047857;
--m-link: #047857;
--m-selected: #e6f5ed;
}
:root[data-mortar-brand="green"][data-mortar-appearance="dark"] {
--m-focus: #6ee7b7;
--m-link: #6ee7b7;
--m-selected: #062d23;
}
:root[data-mortar-density="compact"] {
--m-control: 30px;
--m-pad: 10px;
--m-font-size: 13px;
--m-row-pad: 10px;
}
:root[data-mortar-density="spacious"] {
--m-control: 44px;
--m-pad: 18px;
--m-font-size: 15px;
--m-row-pad: 18px;
}
@media (pointer: coarse) {
:root[data-mortar] {
--m-control: 44px;
}
}