swarm-ui: compact refresh-interval picker, moved into the panel header

Per mara's review on this PR: the labelled SelectField-based picker
read as way too heavy for a passive-until-touched setting - a full
label+bordered-control form field row above the table. Replace it with
a compact inline "clock icon - value - chevron" control (a native
<select> still drives the interaction, just stripped of SelectField/
FormField's chrome), quiet until hovered/focused the same way
LinksMenu's header button is.

Also move it out of the panel body entirely: Panel gains an `actions`
slot in its title row (right-aligned via margin-left: auto), so the
picker sits next to the "hives" heading instead of taking its own row
and pushing the table down - per the design guide's own "a control
belongs next to the thing it affects" rule.

Verified with headless chromium screenshots (full page + a tight
close-up crop) against the built bundle: the picker now reads as
"(clock) 30s (chevron)" inline with the panel title, no extra vertical
space taken from the table. Added a Panel "with actions" demo to
ComponentsPage.
This commit is contained in:
iris 2026-08-18 22:49:04 +02:00 committed by mara
commit fc44891ab5
6 changed files with 139 additions and 21 deletions

View file

@ -0,0 +1,42 @@
/* Compact "clock icon · value · chevron" control see RefreshInterval.tsx
for why this isn't `SelectField`'s label+bordered-control chrome. Quiet
until interacted with (no border/fill at rest), matching LinksMenu's
header-button treatment so it reads as chrome, not a form. */
.ui-refresh-picker {
position: relative;
display: inline-flex;
align-items: center;
gap: 0.35em;
min-height: 2.75em;
padding: 0 0.6em;
border: 1px solid transparent;
border-radius: 0.4em;
color: var(--muted);
font-size: 0.9em;
cursor: pointer;
}
.ui-refresh-picker:hover,
.ui-refresh-picker:focus-within {
border-color: var(--border);
background: var(--bg);
color: var(--fg);
}
/* The select drives the interaction (native listbox, keyboard, a11y) but
contributes no chrome of its own its own box is invisible, sized to
just its selected option's text, and its default arrow is stripped
since the chevron span stands in for it. */
.ui-refresh-picker-select {
appearance: none;
border: none;
background: none;
color: inherit;
font: inherit;
padding: 0;
margin: 0;
cursor: pointer;
}
.ui-refresh-picker-icon,
.ui-refresh-picker-chevron {
font-size: 0.85em;
line-height: 1;
}

View file

@ -8,12 +8,25 @@
// file since neither is useful without the other and splitting them
// would be two files for one concern.
//
// The picker itself is a compact "clock icon · value · chevron" inline
// control, not a labelled `SelectField` — per mara's review, a full
// label+bordered-control form field reads as way too heavy for a
// passive-until-touched setting that lives in a panel's title row
// (`Panel`'s `actions` slot), not a form. Visually quiet (no border/
// fill until hovered/focused) the same way `LinksMenu`'s header button
// is, so it reads as chrome rather than another input to fill in. A
// native `<select>` still drives it (no custom dropdown/listbox to
// reinvent) — `appearance: none` strips its default arrow so the
// chevron span can stand in for it, and the visible "value" text is
// just whichever `<option>` the browser is already rendering as
// selected, not a second copy of the label kept in sync by hand.
//
// The hook, not the picker, owns re-fetch safety: a caller passes its
// own fetch callback, and it's the CALLER's job to make sure that
// callback doesn't clobber an input the operator is mid-edit on — this
// hook only decides *when* to call it.
import { useEffect, useRef } from 'preact/hooks';
import { SelectField, type SelectOption } from '../select-field/SelectField.js';
import './RefreshInterval.css';
// `null` means "off" throughout this module — no separate boolean, so
// there's exactly one way to represent "not polling".
@ -26,10 +39,6 @@ const PRESETS: { value: RefreshIntervalMs; label: string }[] = [
{ value: 60_000, label: '1m' },
];
// `SelectField` only speaks strings, so presets round-trip through the
// label side of `PRESETS` rather than a second parallel list.
const OPTIONS: SelectOption[] = PRESETS.map((p) => ({ value: p.label, label: p.label }));
export function RefreshIntervalPicker({
id,
value,
@ -41,16 +50,37 @@ export function RefreshIntervalPicker({
}) {
const current = PRESETS.find((p) => p.value === value) ?? PRESETS[0];
return (
<SelectField
id={id}
label="refresh"
value={current.label}
onChange={(label) => {
const preset = PRESETS.find((p) => p.label === label);
if (preset) onChange(preset.value);
}}
options={OPTIONS}
/>
// A `<label>` wrapper (not a bare `<span>`) so the icon/chevron
// padding is part of the click/tap target too, not just the native
// select box itself — same touch-target-floor reasoning as the rest
// of the kit, just without a visible label string to hang it off
// of (hence the `aria-label` on the `<select>` for the accessible
// name a sighted-only icon+chevron can't supply).
<label class="ui-refresh-picker" for={id}>
<span class="ui-refresh-picker-icon" aria-hidden="true">
🕐
</span>
<select
id={id}
class="ui-refresh-picker-select"
aria-label="refresh interval"
value={current.label}
onChange={(e) => {
const label = (e.target as HTMLSelectElement).value;
const preset = PRESETS.find((p) => p.label === label);
if (preset) onChange(preset.value);
}}
>
{PRESETS.map((p) => (
<option key={p.label} value={p.label}>
{p.label}
</option>
))}
</select>
<span class="ui-refresh-picker-chevron" aria-hidden="true">
</span>
</label>
);
}