Bump dashboards: bar, icon, forecast and clock widget types

Plumbing only: the widget-type literal and its dtype table on both sides,
the regenerated client, a curated lucide map and four stubs the renderers
are wired to. Also a latching switch and a segmented dropdown, both a
`style` on the control that already publishes and reads back, plus the
option editor a dropdown never had.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HTsT1isxUjw5gtkJk8WhuA
This commit is contained in:
2026-08-20 08:31:36 +02:00
co-authored by Claude Opus 5
parent 6be718f672
commit dbaa3518b9
11 changed files with 389 additions and 22 deletions
+139 -13
View File
@@ -156,6 +156,49 @@ function ModePicker({
)
}
/** Which chrome an input wears, in the same segmented shape as the mode. */
function StylePicker({
value,
options,
onChange,
}: {
value: string
options: readonly (readonly [string, string])[]
onChange: (style: string) => void
}) {
return (
<fieldset
data-testid="widget-style"
className="flex w-fit items-center gap-1 rounded-full border border-border p-1"
>
<legend className="sr-only">How this control is drawn</legend>
{options.map(([style, label]) => (
<button
key={style}
type="button"
aria-pressed={value === style}
onClick={() => onChange(style)}
className={cn(
"rounded-full px-2.5 py-1 text-xs transition-colors",
value === style
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:bg-accent/50",
)}
>
{label}
</button>
))}
</fieldset>
)
}
/** What a text field was meant to send: a bool, a number, or the text itself. */
function coerce(raw: string): unknown {
const asNumber = Number(raw)
if (raw === "true" || raw === "false") return raw === "true"
return raw !== "" && Number.isFinite(asNumber) ? asNumber : raw
}
/**
* What one widget shows or does.
*
@@ -183,6 +226,9 @@ export function WidgetPanel({
const series = seriesOf(widget)
const setSeries = (next: Series[]) => set({ series: next })
// Nothing wrote these until now, so a dropdown's choices were uneditable.
const options = (cfg.options ?? []) as { label?: string; value?: unknown }[]
const setOptions = (next: typeof options) => set({ options: next })
const querying = cfg.source === "query"
// What this chart would refresh at with nothing configured. The viewer can
// pick another window on the widget, which moves it.
@@ -334,7 +380,8 @@ export function WidgetPanel({
</div>
)}
</div>
) : (
) : // A clock reads the wall; a picker would bind a message nothing reads.
widget.type === "clock" ? null : (
<MessagePicker
kind={widget.type}
value={str(cfg[isInput ? "target" : "message"])}
@@ -526,21 +573,100 @@ export function WidgetPanel({
<Input
value={str(cfg.value)}
placeholder="true"
onChange={(event) => {
const raw = event.target.value
const asNumber = Number(raw)
set({
value:
raw === "true" || raw === "false"
? raw === "true"
: raw !== "" && Number.isFinite(asNumber)
? asNumber
: raw,
})
}}
onChange={(event) => set({ value: coerce(event.target.value) })}
/>
</div>
) : null}
{widget.type === "dropdown" ? (
<div className="grid gap-2">
<span className={PANEL_SECTION}>Options</span>
{options.map((option, index) => (
<div
// Position is the only identity an option row has.
key={`option-${index}`}
className="flex items-end gap-1.5"
>
<Input
className="min-w-0 flex-1"
value={str(option.label)}
placeholder="Label"
aria-label="Option label"
onChange={(event) =>
setOptions(
options.map((other, at) =>
at === index
? { ...other, label: event.target.value }
: other,
),
)
}
/>
<Input
className="w-28"
value={str(option.value)}
placeholder="Value"
aria-label="Option value"
onChange={(event) =>
setOptions(
options.map((other, at) =>
at === index
? { ...other, value: coerce(event.target.value) }
: other,
),
)
}
/>
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
aria-label="Remove option"
onClick={() =>
setOptions(options.filter((_, at) => at !== index))
}
>
<X />
</Button>
</div>
))}
<Button
variant="outline"
size="sm"
className="h-8 justify-self-start"
onClick={() => setOptions([...options, {}])}
data-testid="add-option"
>
<Plus />
Add option
</Button>
</div>
) : null}
{widget.type === "switch" || widget.type === "dropdown" ? (
<div className="grid gap-1.5">
<Label className="text-sm font-normal">Style</Label>
{widget.type === "switch" ? (
<StylePicker
value={str(cfg.style) || "track"}
options={[
["track", "Track"],
["button", "Button"],
]}
onChange={(style) => set({ style })}
/>
) : (
<StylePicker
value={str(cfg.style) || "list"}
options={[
["list", "List"],
["segmented", "Segmented"],
]}
onChange={(style) => set({ style })}
/>
)}
</div>
) : null}
</div>
</SidePanel>
)