Watch
0
0
Fork
You've already forked hyperhive
0

swarm-ui: shared Hint, ActionRow, Toolbar, EmptyState, ExternalLink and ExpandableCard replace per-page copies

Hint, ActionRow, Toolbar, EmptyState and ExternalLink replace the muted
text, button row, refresh bar, empty line and _blank anchor that each page
restyled under its own class. ExpandableCard (with a loading twin) holds the
builds and recent-runs cards, which had identical head and body CSS. The
create form's raw checkbox becomes CheckboxField. The components page shows
each new piece.
This commit is contained in:
iris 2026-11-06 17:54:24 +01:00 • committed by mara
commit 8a2e5203ad
13 changed files with 689 additions and 427 deletions

View file

@ -0,0 +1,9 @@
.ui-action-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75em;
}
.ui-action-row-end {
justify-content: flex-end;
}

View file

@ -0,0 +1,24 @@
// <ActionRow> — the row of buttons at the end of a form, a dialog or a
// section: aligned on the centre line, wrapping when it is narrow, with one
// gap. `end` pushes the buttons to the right edge.
import type { ComponentChildren } from "preact";
import "./ActionRow.css";
export function ActionRow({
align = "start",
class: extraClass,
children,
}: {
align?: "start" | "end";
class?: string;
children: ComponentChildren;
}) {
const classes = [
"ui-action-row",
align === "end" && "ui-action-row-end",
extraClass,
]
.filter(Boolean)
.join(" ");
return <div class={classes}>{children}</div>;
}

View file

@ -20,6 +20,15 @@
color: var(--fg);
border: 1px solid var(--border);
}
/* `plain`: text only, no box, for a control that reads as a link in a row of
text (it keeps the touch-target height). */
.ui-button-plain {
background: none;
color: inherit;
border: 0;
padding: 0;
text-align: left;
}
.ui-button:disabled {
opacity: 0.6;
cursor: default;

View file

@ -7,7 +7,7 @@
import type { ComponentChildren } from "preact";
import "./Button.css";
export type ButtonVariant = "primary" | "default";
export type ButtonVariant = "primary" | "default" | "plain";
export function Button({
variant = "default",

View file

@ -0,0 +1,8 @@
.ui-empty-state {
margin: 0;
color: var(--muted);
}
.ui-empty-state-centered {
padding: 1.25em 0.75em;
text-align: center;
}

View file

@ -0,0 +1,19 @@
// <EmptyState> — what a list or a section says when it holds nothing: "No runs
// yet.", "none linked". Muted, with room around it. `Table`'s `emptyMessage`
// and `FilterableView` use the same look through this component.
import type { ComponentChildren } from "preact";
import "./EmptyState.css";
export function EmptyState({
centered = false,
children,
}: {
centered?: boolean;
children: ComponentChildren;
}) {
return (
<p class={"ui-empty-state" + (centered ? " ui-empty-state-centered" : "")}>
{children}
</p>
);
}

View file

@ -0,0 +1,43 @@
/* The head is the control: it reads as a row of text, not a boxed button. */
.ui-expandable-head {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5em 0.75em;
padding: 0;
border: 0;
background: none;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
}
.ui-expandable-head:focus-visible {
outline: 2px solid var(--purple);
outline-offset: 2px;
}
/* The placeholder head is not a control. */
.ui-expandable-head-static {
cursor: default;
}
.ui-expandable-title {
display: inline-flex;
align-items: baseline;
gap: 0.5em;
}
.ui-expandable-meta {
margin: 0;
color: var(--muted);
}
.ui-expandable-meta-row {
flex-basis: 100%;
}
.ui-expandable-body {
display: flex;
flex-direction: column;
gap: 0.5em;
margin-top: 0.6em;
}
.ui-expandable-body p {
margin: 0;
}

View file

@ -0,0 +1,112 @@
// <ExpandableCard> — a card in a list whose head is a button that opens its
// body in place: a build, a classifier run. The head holds a title, a badge
// and a line of meta (when it started, how long it took); the body is shown
// only while the card is open. It is controlled (`open`, `onToggle`), so a
// page can keep its open cards across a refresh and open one for the user.
//
// Not `ui/disclosure`: that is a `<details>` for one section of a page, and a
// list of these needs the page to own which are open. The head is a real
// `<button>` with `aria-expanded` and `aria-controls`.
//
// `ExpandableCardSkeleton` is its twin for a list that is loading: the same
// card and the same head layout with a placeholder in each slot.
import type { ComponentChildren } from "preact";
import { Skeleton } from "../skeleton/Skeleton.js";
import { Card } from "../card/Card.js";
import "./ExpandableCard.css";
function Head({
title,
badge,
meta,
metaOnOwnRow,
}: {
title: ComponentChildren;
badge: ComponentChildren;
meta: ComponentChildren;
metaOnOwnRow: boolean;
}) {
return (
<>
<span class="ui-expandable-title">{title}</span>
{badge}
<span
class={
"ui-expandable-meta" + (metaOnOwnRow ? " ui-expandable-meta-row" : "")
}
>
{meta}
</span>
</>
);
}
export function ExpandableCard({
id,
open,
onToggle,
title,
badge,
meta,
metaOnOwnRow = false,
class: extraClass,
children,
}: {
/** The id of the card's element, for a link that scrolls to it. */
id: string;
open: boolean;
onToggle: () => void;
title: ComponentChildren;
badge: ComponentChildren;
meta: ComponentChildren;
/** Put the meta line on a row of its own under the title and badge. */
metaOnOwnRow?: boolean;
class?: string;
children: ComponentChildren;
}) {
const bodyId = `${id}-body`;
return (
<Card class={extraClass}>
<div id={id}>
<button
type="button"
class="ui-expandable-head"
aria-expanded={open}
aria-controls={bodyId}
onClick={onToggle}
>
<Head
title={title}
badge={badge}
meta={meta}
metaOnOwnRow={metaOnOwnRow}
/>
</button>
{open ? (
<div class="ui-expandable-body" id={bodyId}>
{children}
</div>
) : null}
</div>
</Card>
);
}
export function ExpandableCardSkeleton({
metaOnOwnRow = false,
}: {
metaOnOwnRow?: boolean;
}) {
return (
<Card>
<div class="ui-expandable-head ui-expandable-head-static">
<Head
title={<Skeleton width="12em" />}
badge={<Skeleton width="5em" height="1.6em" />}
meta={<Skeleton width="14em" />}
metaOnOwnRow={metaOnOwnRow}
/>
</div>
</Card>
);
}

View file

@ -0,0 +1,28 @@
// <ExternalLink> — a link that opens another site in a new tab. It always
// sets `rel="noopener noreferrer"`, so the opened page cannot reach this one
// and is not told where the visitor came from.
import type { ComponentChildren } from "preact";
export function ExternalLink({
href,
title,
class: extraClass,
children,
}: {
href: string;
title?: string;
class?: string;
children: ComponentChildren;
}) {
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
title={title}
class={extraClass}
>
{children}
</a>
);
}

View file

@ -0,0 +1,17 @@
/* <Hint>: small supporting text. A `p` loses its default margin, so the page
that places it owns the spacing around it. */
.ui-hint {
margin: 0;
}
.ui-hint-muted {
color: var(--muted);
}
.ui-hint-warn {
color: var(--yellow);
}
.ui-hint-error {
color: var(--red);
}
.ui-hint-ok {
color: var(--green);
}

View file

@ -0,0 +1,39 @@
// <Hint> — a line of small supporting text: helper text under a field, a
// count, a status line, an inline problem or a success note. One component
// for what each form and card used to restyle under its own class name, so
// "muted", "warning", "error" and "ok" mean the same colour everywhere.
//
// `error` and `warn` are read out when they appear (`role="alert"` for an
// error); the others are plain text unless the caller passes a `role`, such as
// `status` for a line that changes after a save. `class` is for a page's
// layout around the hint (a margin, a width), not for its colour.
import type { ComponentChildren } from "preact";
import "./Hint.css";
export type HintTone = "muted" | "warn" | "error" | "ok";
export function Hint({
tone = "muted",
as: Tag = "p",
role,
class: extraClass,
children,
}: {
tone?: HintTone;
as?: "p" | "span" | "div";
role?: "status" | "alert";
class?: string;
children: ComponentChildren;
}) {
const classes = ["ui-hint", `ui-hint-${tone}`, extraClass]
.filter(Boolean)
.join(" ");
return (
<Tag
class={classes}
role={role ?? (tone === "error" ? "alert" : undefined)}
>
{children}
</Tag>
);
}

View file

@ -0,0 +1,11 @@
.ui-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 0.5em 0.75em;
margin-block-end: 0.5em;
}
.ui-toolbar-start {
justify-content: flex-start;
}

View file

@ -0,0 +1,24 @@
// <Toolbar> — the bar above a list or a card: filters, a count, the refresh
// picker. Items sit on one line and wrap when narrow, aligned to the end by
// default (where the refresh picker sits on every page that has one).
import type { ComponentChildren } from "preact";
import "./Toolbar.css";
export function Toolbar({
justify = "end",
class: extraClass,
children,
}: {
justify?: "start" | "end";
class?: string;
children: ComponentChildren;
}) {
const classes = [
"ui-toolbar",
justify === "start" && "ui-toolbar-start",
extraClass,
]
.filter(Boolean)
.join(" ");
return <div class={classes}>{children}</div>;
}