Watch
0
0
Fork
You've already forked hyperhive
0

swarm-ui: the terminal message box has a placeholder and a spinner instead of a label and a sent line

The label 'message <agent>' is the input's placeholder (the label stays for
screen readers). The send button shows a spinner over its label while the
request is pending and keeps its width, and the 'sent' line is gone: the box
empties when the message is held. Spinner is a component of its own now, used
by the button and the refresh picker.
This commit is contained in:
iris 2026-11-06 19:37:26 +01:00 • committed by mara
commit c37205e8f5
12 changed files with 103 additions and 51 deletions

View file

@ -11,6 +11,3 @@
.ui-agent-input-form > :first-child {
flex: 1;
}
.ui-agent-input-sent {
margin-block-start: 0.25rem;
}

View file

@ -7,7 +7,6 @@ import { useState } from "preact/hooks";
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
import { TextField } from "../../ui/text-field/TextField.js";
import { Button } from "../../ui/button/Button.js";
import { Hint } from "../../ui/hint/Hint.js";
import { useSubmit } from "../../ui/submit/useSubmit.js";
import "./AgentInputForm.css";
@ -34,19 +33,16 @@ export function AgentInputForm({ agentName }: { agentName: string }) {
<TextField
id={`agent-input-${agentName}`}
label={`message ${agentName}`}
hideLabel
placeholder={`message ${agentName}`}
value={body}
required
onInput={setBody}
/>
<Button variant="primary" type="submit" disabled={submitting}>
{submitting ? "sending…" : "send"}
<Button variant="primary" type="submit" busy={submitting}>
send
</Button>
</form>
{state.status === "done" && (
<Hint tone="ok" class="ui-agent-input-sent">
sent
</Hint>
)}
{state.status === "error" && (
<ApiErrorPanel context="failed to send" problem={state.problem} />
)}

View file

@ -611,6 +611,11 @@ export function ComponentsPage() {
<Sample label="disabled">
<Button disabled>disabled</Button>
</Sample>
<Sample label="busy: a spinner over the label, same width">
<Button variant="primary" busy>
send
</Button>
</Sample>
</div>
</Section>

View file

@ -24,3 +24,17 @@
opacity: 0.6;
cursor: default;
}
.ui-button {
position: relative;
}
/* Busy: the label keeps its space, hidden, and the spinner sits over it. */
.ui-button-busy .ui-button-label {
visibility: hidden;
}
.ui-button-spinner {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}

View file

@ -5,6 +5,7 @@
// so far; a third reading gets added the day something needs it, not
// speculatively.
import type { ComponentChildren } from "preact";
import { Spinner } from "../spinner/Spinner.js";
import "./Button.css";
export type ButtonVariant = "primary" | "default";
@ -13,6 +14,7 @@ export function Button({
variant = "default",
type = "button",
disabled,
busy = false,
onClick,
describedBy,
children,
@ -20,6 +22,10 @@ export function Button({
variant?: ButtonVariant;
type?: "button" | "submit";
disabled?: boolean;
/** A request from this button is in flight: a spinner covers the label,
* which keeps its space so the button does not change width, and the button
* cannot be pressed again. */
busy?: boolean;
onClick?: (e: Event) => void;
/** Id of the element that says why the button is disabled. */
describedBy?: string;
@ -27,13 +33,19 @@ export function Button({
}) {
return (
<button
class={"ui-button ui-button-" + variant}
class={"ui-button ui-button-" + variant + (busy ? " ui-button-busy" : "")}
type={type}
disabled={disabled}
disabled={disabled || busy}
aria-busy={busy || undefined}
onClick={onClick}
aria-describedby={describedBy}
>
{children}
<span class="ui-button-label">{children}</span>
{busy ? (
<span class="ui-button-spinner">
<Spinner />
</span>
) : null}
</button>
);
}

View file

@ -45,3 +45,12 @@
box-sizing: border-box;
min-height: 2.75em;
}
/* A label that stays for assistive tech and is off the screen. */
.ui-form-field-label-hidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}

View file

@ -16,21 +16,27 @@ export function FormField({
label,
htmlFor,
labelId,
hideLabel = false,
children,
}: {
label: string;
htmlFor?: string;
labelId?: string;
/** Keep the label for assistive tech and take it off the screen, for a
* control whose placeholder says the same. */
hideLabel?: boolean;
children: ComponentChildren;
}) {
const labelClass =
"ui-form-field-label" + (hideLabel ? " ui-form-field-label-hidden" : "");
return (
<div class="ui-form-field">
{htmlFor !== undefined ? (
<label class="ui-form-field-label" for={htmlFor}>
<label class={labelClass} for={htmlFor}>
{label}
</label>
) : (
<span class="ui-form-field-label" id={labelId}>
<span class={labelClass} id={labelId}>
{label}
</span>
)}

View file

@ -43,32 +43,3 @@
font-size: 0.85em;
line-height: 1;
}
/* The spinner takes the clock's place: the same box, so the picker keeps its
width. Motion follows the page's setting, the same three rules the page
switch uses; where motion is off it is a static arc. */
.ui-refresh-picker-spinner {
box-sizing: border-box;
width: 1em;
height: 1em;
border: 2px solid currentColor;
border-right-color: transparent;
border-radius: 50%;
animation: ui-refresh-spin 0.8s linear infinite;
}
@keyframes ui-refresh-spin {
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.ui-refresh-picker-spinner {
animation: none;
}
}
:root[data-motion="reduce"] .ui-refresh-picker-spinner {
animation: none;
}
:root[data-motion="allow"] .ui-refresh-picker-spinner {
animation: ui-refresh-spin 0.8s linear infinite;
}

View file

@ -25,6 +25,7 @@
// hook only decides *when* to call it.
import { useEffect, useRef } from "preact/hooks";
import { Listbox } from "../select-field/Listbox.js";
import { Spinner } from "../spinner/Spinner.js";
import "./RefreshInterval.css";
// `null` means "off" throughout this module — no separate boolean, so
@ -59,7 +60,7 @@ export function RefreshIntervalPicker({
aria-hidden="true"
title={refreshing ? "refreshing" : undefined}
>
{refreshing ? <span class="ui-refresh-picker-spinner" /> : "🕐"}
{refreshing ? <Spinner /> : "🕐"}
</span>
<Listbox
id={id}

View file

@ -0,0 +1,26 @@
.ui-spinner {
display: inline-block;
box-sizing: border-box;
width: 1em;
height: 1em;
border: 2px solid currentColor;
border-right-color: transparent;
border-radius: 50%;
animation: ui-spinner-turn 0.8s linear infinite;
}
@keyframes ui-spinner-turn {
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.ui-spinner {
animation: none;
}
}
:root[data-motion="reduce"] .ui-spinner {
animation: none;
}
:root[data-motion="allow"] .ui-spinner {
animation: ui-spinner-turn 0.8s linear infinite;
}

View file

@ -0,0 +1,10 @@
// <Spinner> — a small ring that turns while a request is in flight: the
// refresh picker's clock slot, a button waiting on its request. It is one
// character wide and tall (`1em`), takes the colour of the text around it, and
// is hidden from assistive tech; whatever holds it says the state (`aria-busy`,
// a label). Where motion is off it is a static arc.
import "./Spinner.css";
export function Spinner() {
return <span class="ui-spinner" aria-hidden="true" />;
}

View file

@ -22,6 +22,7 @@ export function TextField({
title,
required,
placeholder,
hideLabel,
suggestions,
}: {
id: string;
@ -33,13 +34,15 @@ export function TextField({
title?: string;
required?: boolean;
placeholder?: string;
/** The label is for assistive tech only; the placeholder says it on screen. */
hideLabel?: boolean;
/** Values to pick from. The field stays free text: a value that is not in
* the list can still be typed. */
suggestions?: TextFieldSuggestion[];
}) {
const listId = `${id}-suggestions`;
return (
<FormField label={label} htmlFor={id}>
<FormField label={label} htmlFor={id} hideLabel={hideLabel}>
<input
id={id}
class="ui-form-control"