swarm-ui: the agents page shows placeholder cards in the shape of the real ones while the roster loads
A new Skeleton (a shimmering shape, still where motion is off) and a region that tells assistive tech once that something is loading. The agent card's header is split into AgentCardLayout, a layout with a slot for each part: the card fills the slots with the real icon, badges and controls, its new AgentCardSkeleton twin fills the same slots with placeholder shapes, so the card that replaces it lands in the same place. The agents page uses the twin in place of its "loading…" text.
This commit is contained in:
parent
fd51cbd19d
commit
27c4a593bb
8 changed files with 277 additions and 50 deletions
|
|
@ -66,6 +66,10 @@
|
|||
transform: rotate(-45deg);
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
/* The placeholder card keeps the chevron's box without its arrow. */
|
||||
.ui-agent-card-chevron-blank {
|
||||
border-color: transparent;
|
||||
}
|
||||
.ui-agent-card-row[aria-expanded="true"] .ui-agent-card-chevron {
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import { termPaths } from "../terminal/AgentTermPreview.js";
|
|||
import { FRESHNESS, showsLoginNeeded, type AgentRow } from "../AgentTypes.js";
|
||||
import type { AgentActions } from "./AgentActions.js";
|
||||
import { WantedMenu } from "./WantedMenu.js";
|
||||
import { AgentCardLayout } from "./AgentCardLayout.js";
|
||||
import "./AgentCard.css";
|
||||
|
||||
export function AgentCard({
|
||||
|
|
@ -44,24 +45,24 @@ export function AgentCard({
|
|||
const term = termPaths(row.name);
|
||||
return (
|
||||
<Card>
|
||||
<div
|
||||
class="ui-agent-card-row"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-expanded={open}
|
||||
onClick={toggle}
|
||||
onKeyDown={(e) => {
|
||||
if (e.target !== e.currentTarget) return;
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
toggle();
|
||||
}
|
||||
<AgentCardLayout
|
||||
rowProps={{
|
||||
role: "button",
|
||||
tabIndex: 0,
|
||||
"aria-expanded": open,
|
||||
onClick: toggle,
|
||||
onKeyDown: (e) => {
|
||||
if (e.target !== e.currentTarget) return;
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
toggle();
|
||||
}
|
||||
},
|
||||
}}
|
||||
>
|
||||
<AgentIcon name={row.name} />
|
||||
<div class="ui-agent-card-body">
|
||||
<div class="ui-agent-card-line1">
|
||||
<span class="ui-agent-card-name">{row.name}</span>
|
||||
icon={<AgentIcon name={row.name} />}
|
||||
name={<span class="ui-agent-card-name">{row.name}</span>}
|
||||
status={
|
||||
<>
|
||||
<Badge
|
||||
tone={tone}
|
||||
title={
|
||||
|
|
@ -105,39 +106,39 @@ export function AgentCard({
|
|||
) : null}
|
||||
</span>
|
||||
) : null}
|
||||
<span
|
||||
class="ui-agent-card-wanted"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
</>
|
||||
}
|
||||
trailing={
|
||||
<span
|
||||
class="ui-agent-card-wanted"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Badge
|
||||
variant="quiet"
|
||||
value={<TerminalIcon />}
|
||||
title={`open ${row.name}'s terminal in its own tab`}
|
||||
onClick={() => openTab({ href: term.page, label: term.label })}
|
||||
/>
|
||||
<WantedMenu
|
||||
row={row}
|
||||
pending={actions.pendingAgents.has(row.name)}
|
||||
onSelectUp={actions.selectUp}
|
||||
onSelectOffline={actions.selectOffline}
|
||||
onSelectPaused={actions.selectPaused}
|
||||
onDestroy={actions.requestDestroy}
|
||||
/>
|
||||
{error ? (
|
||||
<Badge
|
||||
variant="quiet"
|
||||
value={<TerminalIcon />}
|
||||
title={`open ${row.name}'s terminal in its own tab`}
|
||||
onClick={() => openTab({ href: term.page, label: term.label })}
|
||||
tone="negative"
|
||||
value="failed"
|
||||
title={error.detail ?? "the declaration failed"}
|
||||
/>
|
||||
<WantedMenu
|
||||
row={row}
|
||||
pending={actions.pendingAgents.has(row.name)}
|
||||
onSelectUp={actions.selectUp}
|
||||
onSelectOffline={actions.selectOffline}
|
||||
onSelectPaused={actions.selectPaused}
|
||||
onDestroy={actions.requestDestroy}
|
||||
/>
|
||||
{error ? (
|
||||
<Badge
|
||||
tone="negative"
|
||||
value="failed"
|
||||
title={error.detail ?? "the declaration failed"}
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
<div class="ui-agent-card-message">
|
||||
{row.snapshot?.status_text ?? "—"}
|
||||
</div>
|
||||
</div>
|
||||
<span class="ui-agent-card-chevron" aria-hidden="true" />
|
||||
</div>
|
||||
) : null}
|
||||
</span>
|
||||
}
|
||||
message={row.snapshot?.status_text ?? "—"}
|
||||
chevron={<span class="ui-agent-card-chevron" aria-hidden="true" />}
|
||||
/>
|
||||
{open ? (
|
||||
<div class="ui-agent-card-detail">
|
||||
<AgentDetail row={row} actions={actions} withTerminal inCard />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,42 @@
|
|||
// <AgentCardLayout> — the arrangement of an agent card's header, with a slot
|
||||
// for each part: the icon, the name, the status (freshness and login badges),
|
||||
// the trailing controls, the message line and the chevron. `AgentCard` fills
|
||||
// the slots with the real sub components; `AgentCardSkeleton` fills the same
|
||||
// slots with placeholder shapes, so the card that replaces it lands in the same
|
||||
// place.
|
||||
import type { ComponentChildren, JSX } from "preact";
|
||||
import "./AgentCard.css";
|
||||
|
||||
export function AgentCardLayout({
|
||||
icon,
|
||||
name,
|
||||
status,
|
||||
trailing,
|
||||
message,
|
||||
chevron,
|
||||
rowProps,
|
||||
}: {
|
||||
icon: ComponentChildren;
|
||||
name: ComponentChildren;
|
||||
status: ComponentChildren;
|
||||
trailing: ComponentChildren;
|
||||
message: ComponentChildren;
|
||||
chevron?: ComponentChildren;
|
||||
/** Attributes of the header row, for the card that opens on a click. */
|
||||
rowProps?: JSX.HTMLAttributes<HTMLDivElement>;
|
||||
}) {
|
||||
return (
|
||||
<div class="ui-agent-card-row" {...rowProps}>
|
||||
{icon}
|
||||
<div class="ui-agent-card-body">
|
||||
<div class="ui-agent-card-line1">
|
||||
{name}
|
||||
{status}
|
||||
{trailing}
|
||||
</div>
|
||||
<div class="ui-agent-card-message">{message}</div>
|
||||
</div>
|
||||
{chevron}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
// <AgentCardSkeleton> — what an agent card looks like while the roster is
|
||||
// loading: the same `AgentCardLayout` as `AgentCard`, with a placeholder shape
|
||||
// in each slot instead of the icon, name, badges, controls and message.
|
||||
import { Skeleton } from "../../ui/skeleton/Skeleton.js";
|
||||
import { Card } from "../../ui/card/Card.js";
|
||||
import { AgentCardLayout } from "./AgentCardLayout.js";
|
||||
|
||||
export function AgentCardSkeleton() {
|
||||
return (
|
||||
<Card>
|
||||
<AgentCardLayout
|
||||
icon={<Skeleton shape="block" width="5em" height="5em" />}
|
||||
name={<Skeleton width="7em" />}
|
||||
status={<Skeleton width="9em" height="1.6em" />}
|
||||
trailing={
|
||||
// As tall as the real controls (a 2.75em touch target at the badge's
|
||||
// 0.85em font size), so the message line sits where it does on the
|
||||
// card.
|
||||
<span class="ui-agent-card-wanted" style={{ minHeight: "2.34em" }}>
|
||||
<Skeleton shape="circle" width="1.6em" height="1.6em" />
|
||||
<Skeleton width="5em" height="1.6em" />
|
||||
</span>
|
||||
}
|
||||
message={<Skeleton width="55%" />}
|
||||
// The real chevron's box with no arrow, so the trailing controls end at
|
||||
// the same x.
|
||||
chevron={
|
||||
<span
|
||||
class="ui-agent-card-chevron ui-agent-card-chevron-blank"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/** A few of them, for the page's list while its first answer is awaited. */
|
||||
export function AgentListSkeleton({ count = 3 }: { count?: number }) {
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: count }, (_, i) => (
|
||||
<AgentCardSkeleton key={i} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -7,7 +7,9 @@
|
|||
// swarm-controller's API is up or not.
|
||||
import { useRef, useState } from "preact/hooks";
|
||||
import type { ComponentChildren } from "preact";
|
||||
import { AgentListSkeleton } from "../agent/roster/AgentCardSkeleton.js";
|
||||
import { Card } from "../ui/card/Card.js";
|
||||
import { SkeletonRegion } from "../ui/skeleton/Skeleton.js";
|
||||
import { FilterableView } from "../ui/filterable-view/FilterableView.js";
|
||||
import {
|
||||
MultiselectFilter,
|
||||
|
|
@ -483,6 +485,14 @@ export function ComponentsPage() {
|
|||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Skeleton">
|
||||
<Sample label="agent card placeholders">
|
||||
<SkeletonRegion label="the agents">
|
||||
<AgentListSkeleton count={2} />
|
||||
</SkeletonRegion>
|
||||
</Sample>
|
||||
</Section>
|
||||
|
||||
<Section title="MultiselectFilter">
|
||||
<Sample label="trigger + checkbox popover">
|
||||
<MultiselectFilterSample />
|
||||
|
|
|
|||
|
|
@ -32,9 +32,11 @@ import { useState } from "preact/hooks";
|
|||
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
|
||||
import { useAgentActions } from "../../agent/roster/AgentActions.js";
|
||||
import { AgentCard } from "../../agent/roster/AgentCard.js";
|
||||
import { AgentListSkeleton } from "../../agent/roster/AgentCardSkeleton.js";
|
||||
import { FRESHNESS, type AgentRow } from "../../agent/AgentTypes.js";
|
||||
import { Button } from "../../ui/button/Button.js";
|
||||
import { Dialog } from "../../ui/dialog/Dialog.js";
|
||||
import { SkeletonRegion } from "../../ui/skeleton/Skeleton.js";
|
||||
import { FilterableView } from "../../ui/filterable-view/FilterableView.js";
|
||||
import {
|
||||
RefreshIntervalPicker,
|
||||
|
|
@ -154,7 +156,11 @@ export function AgentsPage() {
|
|||
problem={error}
|
||||
/>
|
||||
) : null}
|
||||
{!error && rows === null ? <p>loading…</p> : null}
|
||||
{!error && rows === null ? (
|
||||
<SkeletonRegion label="the agents">
|
||||
<AgentListSkeleton />
|
||||
</SkeletonRegion>
|
||||
) : null}
|
||||
{rows ? (
|
||||
<FilterableView
|
||||
view="cards"
|
||||
|
|
|
|||
67
frontend/packages/swarm-ui/src/ui/skeleton/Skeleton.css
Normal file
67
frontend/packages/swarm-ui/src/ui/skeleton/Skeleton.css
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
/* <Skeleton>: a tinted shape with a light band moving across it. Motion
|
||||
follows the page's setting, the same three rules the page switch uses
|
||||
(`shell/Shell.css`): off for an OS that asks for less motion, off when the
|
||||
setting says reduce, back on when the setting says allow. Where it is off
|
||||
the shape is a plain tinted block. */
|
||||
.ui-skeleton {
|
||||
display: block;
|
||||
border-radius: 0.3em;
|
||||
background-color: color-mix(in srgb, var(--muted) 22%, transparent);
|
||||
background-image: linear-gradient(
|
||||
90deg,
|
||||
transparent 0,
|
||||
color-mix(in srgb, var(--fg) 12%, transparent) 50%,
|
||||
transparent 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
background-repeat: no-repeat;
|
||||
background-position: 150% 0;
|
||||
animation: ui-skeleton-shimmer 1.4s ease-in-out infinite;
|
||||
}
|
||||
.ui-skeleton-line {
|
||||
height: 1em;
|
||||
max-width: 100%;
|
||||
}
|
||||
.ui-skeleton-circle {
|
||||
border-radius: 50%;
|
||||
}
|
||||
.ui-skeleton-block {
|
||||
border-radius: 0.5em;
|
||||
}
|
||||
@keyframes ui-skeleton-shimmer {
|
||||
to {
|
||||
background-position: -50% 0;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ui-skeleton {
|
||||
animation: none;
|
||||
background-image: none;
|
||||
}
|
||||
}
|
||||
:root[data-motion="reduce"] .ui-skeleton {
|
||||
animation: none;
|
||||
background-image: none;
|
||||
}
|
||||
:root[data-motion="allow"] .ui-skeleton {
|
||||
background-image: linear-gradient(
|
||||
90deg,
|
||||
transparent 0,
|
||||
color-mix(in srgb, var(--fg) 12%, transparent) 50%,
|
||||
transparent 100%
|
||||
);
|
||||
animation: ui-skeleton-shimmer 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* The words a screen reader gets, out of sight. */
|
||||
.ui-skeleton-label {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ui-skeleton-region {
|
||||
display: contents;
|
||||
}
|
||||
48
frontend/packages/swarm-ui/src/ui/skeleton/Skeleton.tsx
Normal file
48
frontend/packages/swarm-ui/src/ui/skeleton/Skeleton.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
// <Skeleton> — one grey placeholder shape that shimmers, for a view that is
|
||||
// waiting on the server. A component that shows loaded data has a twin that
|
||||
// fills the same layout with these shapes instead of its sub components (see
|
||||
// `AgentCardSkeleton`), so the page does not jump when the data lands.
|
||||
//
|
||||
// <SkeletonRegion> wraps a group of them for assistive tech: the shapes
|
||||
// themselves are `aria-hidden`, and the region says once that something is
|
||||
// loading.
|
||||
import type { ComponentChildren } from "preact";
|
||||
import "./Skeleton.css";
|
||||
|
||||
export type SkeletonShape = "line" | "circle" | "block";
|
||||
|
||||
export function Skeleton({
|
||||
shape = "line",
|
||||
width,
|
||||
height,
|
||||
class: extraClass,
|
||||
}: {
|
||||
/** `line`: a rounded bar the height of a line of text; `circle`; `block`: a
|
||||
* rounded square that fills `width` × `height`. */
|
||||
shape?: SkeletonShape;
|
||||
/** Any CSS length; a line fills its row when this is left out. */
|
||||
width?: string;
|
||||
height?: string;
|
||||
class?: string;
|
||||
}) {
|
||||
const classes = ["ui-skeleton", `ui-skeleton-${shape}`, extraClass]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
return <span class={classes} style={{ width, height }} aria-hidden="true" />;
|
||||
}
|
||||
|
||||
export function SkeletonRegion({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
/** What is loading, as a screen reader says it: "the agents". */
|
||||
label: string;
|
||||
children: ComponentChildren;
|
||||
}) {
|
||||
return (
|
||||
<div class="ui-skeleton-region" role="status">
|
||||
<span class="ui-skeleton-label">loading {label}</span>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue