Extract independent MIT Mortar kit and contribution workflow
Mortar contribution checks / Mortar baseline and browser checks (push) Successful in 3m15s
Mortar contribution checks / Mortar baseline and browser checks (push) Successful in 3m15s
This commit is contained in:
commit
43fe76158c
108 files changed
+10081
No files matched your search
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user