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:
@@ -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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user