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:
parent
b74b5e77e4
commit
8a2e5203ad
13 changed files with 689 additions and 427 deletions
|
|
@ -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;
|
||||
}
|
||||
24
frontend/packages/swarm-ui/src/ui/action-row/ActionRow.tsx
Normal file
24
frontend/packages/swarm-ui/src/ui/action-row/ActionRow.tsx
Normal 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>;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
.ui-empty-state {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
.ui-empty-state-centered {
|
||||
padding: 1.25em 0.75em;
|
||||
text-align: center;
|
||||
}
|
||||
19
frontend/packages/swarm-ui/src/ui/empty-state/EmptyState.tsx
Normal file
19
frontend/packages/swarm-ui/src/ui/empty-state/EmptyState.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
17
frontend/packages/swarm-ui/src/ui/hint/Hint.css
Normal file
17
frontend/packages/swarm-ui/src/ui/hint/Hint.css
Normal 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);
|
||||
}
|
||||
39
frontend/packages/swarm-ui/src/ui/hint/Hint.tsx
Normal file
39
frontend/packages/swarm-ui/src/ui/hint/Hint.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
11
frontend/packages/swarm-ui/src/ui/toolbar/Toolbar.css
Normal file
11
frontend/packages/swarm-ui/src/ui/toolbar/Toolbar.css
Normal 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;
|
||||
}
|
||||
24
frontend/packages/swarm-ui/src/ui/toolbar/Toolbar.tsx
Normal file
24
frontend/packages/swarm-ui/src/ui/toolbar/Toolbar.tsx
Normal 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>;
|
||||
}
|
||||
Loading…
Reference in a new issue