Rework the dashboard into two looks over one behaviour

A dashboard is a wall panel somebody hangs in their own hallway, so it
now wears what they choose: a look, and a palette of their own colours.

Two complete component sets live under `Dashboard/ui/` — `glass`
(translucent panes over a slowly moving ground) and `material` (Material
3 tonal cards) — behind one prop contract. Every control's state,
keyboard and `aria-` live in `ui/core` and are shared, so the two sets
are the same dashboard drawn twice rather than two products: a set only
decides what a control looks like while doing it.

Four settings join the channel, each drivable by a flow like any other:
`look`, `palette`, `background` and `touch`. A palette is an ordered list
of hex colours — background, surface, primary, accent, text, then more
chart colours — pasted from a coolors.co link or typed, written onto the
canvas as the token variables everything already reads. Trailing roles
are derived, so three colours are a whole dashboard, and derived text is
held to AA rather than trusted (`theme.check.ts` measures it). A palette
also decides light or dark, since its first colour is the ground.

Widgets are measured against their own tile with container queries rather
than against the viewport, animate through `motion`, and can be drawn
without their title. The three reworks:

- a bar draws a row per reading, up to eight, each in the dashboard's own
  data colours and each able to carry its own scale — replacing readings
  nested in one fill, which could only ever share one colour and stop at
  three. Documents written the old way are read as rows.
- a chart's range picker moved to a column down its right-hand edge, which
  gives the plot back a whole row of a short tile.
- the colour wheel became a disc: hue is the angle and saturation the
  distance from the middle, so a colour is one gesture rather than three,
  with brightness on a slider beside it.

`index.css` and `lib/motion.ts` are untouched — the dashboard overrides
token *values* on its canvas, never the blocks the two repos share.
This commit is contained in:
2026-08-23 21:52:14 +02:00
parent d4c5af4d5a
commit 0b5ce4fcbb
52 changed files with 5517 additions and 1568 deletions
+109 -451
View File
@@ -1,34 +1,21 @@
import { createContext, useContext, useEffect, useState } from "react"
import { useState } from "react"
import type { WidgetDef } from "@/client"
import { useLiveValue } from "@/components/Flow/liveStore"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"
import { BarWidget, segmentsOf } from "./BarWidget"
import { BarWidget } from "./BarWidget"
import { ChartWidget } from "./ChartWidget"
import { ClockWidget } from "./ClockWidget"
import { COLOR_DTYPES, ColorWidget, colorFormatOf } from "./ColorWidget"
// The segmented thumb's rule lives beside the other widget CSS; a widget is
// drawn by the editor as well as by the view, so the sheet is pulled in here.
import { ColorWidget } from "./ColorWidget"
// The grid's own rules live beside the components that use them; CSS is
// chunked per entry, so the sheet is pulled in wherever a widget is drawn.
import "./dashboard.css"
import { ForecastWidget } from "./ForecastWidget"
import { IconWidget } from "./IconWidget"
import { usePublish } from "./publish"
import { useUi } from "./ui"
import { COLOR_DTYPES, colorFormatOf } from "./ui/core/color"
import { config, num, rowsOf, text } from "./ui/core/config"
/** Widget types that put a value into the graph rather than read one. */
export const INPUT_WIDGETS = new Set([
@@ -113,30 +100,6 @@ export const WIDGET_SIZES: Record<WidgetKind, { w: number; h: number }> = {
color: { w: 4, h: 4 },
}
function config(widget: WidgetDef): Record<string, unknown> {
return (widget.config ?? {}) as Record<string, unknown>
}
function text(value: unknown, fallback = ""): string {
return value === null || value === undefined ? fallback : String(value)
}
function num(value: unknown, fallback: number): number {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : fallback
}
/** Formats a reading the way a panel across the room should read it. */
function format(value: unknown, precision: number | null): string {
if (value === null || value === undefined) return "—"
if (typeof value === "boolean") return value ? "On" : "Off"
if (typeof value === "number") {
return precision === null ? String(value) : value.toFixed(precision)
}
if (typeof value === "object") return JSON.stringify(value)
return String(value)
}
/** One line of a chart, as the document stores it. */
export type Series = { message?: string; dtype?: string; label?: string }
@@ -183,6 +146,22 @@ export function widgetIssue(widget: WidgetDef): string | null {
return null
}
// A bar draws a row per reading, so it is judged row by row — the same way
// a chart is judged series by series.
if (widget.type === "bar") {
const rows = rowsOf(widget)
if (rows.length === 0) return "This bar has no rows yet."
const wrong = rows.find(
(row) => !row.message || !acceptsDtype("bar", row.dtype),
)
if (wrong) {
return wrong.message
? `${wrong.message} is a ${wrong.dtype}; a bar draws numbers.`
: "One of the rows is not bound to a message."
}
return null
}
const input = INPUT_WIDGETS.has(widget.type)
const bound = text(cfg[input ? "target" : "message"])
if (!bound) {
@@ -197,15 +176,7 @@ export function widgetIssue(widget: WidgetDef): string | null {
if (widget.type === "color") {
const want = COLOR_DTYPES[colorFormatOf(widget)]
if (dtype && dtype !== want) {
return `${bound} is a ${dtype}; this wheel sends ${colorFormatOf(widget)}, which is a ${want}.`
}
}
// Only a bar nests further readings, and an unrecorded type binds anything.
// Read through `segmentsOf` so a stacked bar is judged segment by segment
// rather than only in the one-reading shape it used to carry.
for (const segment of segmentsOf(widget)) {
if (!acceptsDtype(widget.type, segment.dtype || undefined)) {
return `${segment.message} is a ${segment.dtype}; a bar nests numbers.`
return `${bound} is a ${dtype}; this disc sends ${colorFormatOf(widget)}, which is a ${want}.`
}
}
if (widget.type === "icon" && !(cfg.rules as unknown[] | undefined)?.length) {
@@ -214,121 +185,8 @@ export function widgetIssue(widget: WidgetDef): string | null {
return null
}
const HeaderSlot = createContext<((node: React.ReactNode) => void) | null>(null)
/**
* Draw something in the frame's header row, from inside the widget body.
*
* A widget's own chrome is state the widget holds — the window a chart is
* showing lives in the chart — while the row it belongs on is the frame's. The
* body hands the node up rather than the frame reaching down for it.
*
* `deps` name what makes the node different, as with any effect.
*/
export function useHeaderSlot(
node: React.ReactNode,
deps: React.DependencyList,
) {
const set = useContext(HeaderSlot)
useEffect(() => {
set?.(node)
return () => set?.(null)
// biome-ignore lint/correctness/useExhaustiveDependencies: the caller names what makes the node different; the node itself is a fresh element on every render, so it cannot be one.
}, deps)
}
/**
* The frame every widget sits in.
*
* A card rather than floating chrome: a dashboard is content, and the panels
* that float are the ones that sit over something.
*/
export function WidgetFrame({
title,
children,
actions,
issue,
grip,
className,
onClick,
}: {
title?: string
children: React.ReactNode
actions?: React.ReactNode
/** Mis-wired: the same red dot and tooltip a failing node carries. */
issue?: string | null
/** Make the header the handle the editor drags the widget by. */
grip?: boolean
className?: string
onClick?: React.MouseEventHandler<HTMLDivElement>
}) {
// What the body asked to have drawn up here — see `useHeaderSlot`. Held as
// state rather than portalled into the row, so a widget with no title and
// nothing to slot still costs no header at all.
const [slotted, setSlotted] = useState<React.ReactNode>(null)
return (
// A card is not a control: the click only picks it in edit mode, and every
// interactive element inside keeps its own role and keyboard handling.
// biome-ignore lint/a11y/useKeyWithClickEvents: see above.
// biome-ignore lint/a11y/noStaticElementInteractions: see above.
<div
data-testid="widget-frame"
className={cn(
// `relative` is what the transmit overlay hangs off: a control's pulse
// is drawn over the whole tile and must take no layout box at all.
"relative flex h-full flex-col gap-2 overflow-hidden rounded-lg border border-border bg-card p-4 shadow-e1",
className,
)}
onClick={onClick}
>
{title || actions || issue || grip || slotted ? (
<div
className={cn(
"flex min-w-0 items-start justify-between gap-2",
grip && "widget-grip -m-1 cursor-grab p-1 active:cursor-grabbing",
)}
>
{title ? (
<span className="truncate text-sm text-muted-foreground">
{title}
</span>
) : (
<span />
)}
<span className="flex shrink-0 items-center gap-1.5">
{issue ? (
<Tooltip>
<TooltipTrigger asChild>
<span
role="img"
className="size-2 shrink-0 rounded-full bg-destructive"
aria-label={issue}
data-testid="widget-issue"
/>
</TooltipTrigger>
<TooltipContent className="max-w-xs break-words">
{issue}
</TooltipContent>
</Tooltip>
) : null}
{slotted}
{actions}
</span>
</div>
) : null}
{/* A scroller, not a clip: the header is an earlier sibling, so anything
taller than the card would otherwise paint over the title instead of
being reachable. Centring has to be `safe` — plain `center` overflows
both edges at once and puts the top of a long body out of reach. */}
<div className="flex min-h-0 flex-1 flex-col justify-center-safe overflow-y-auto">
<HeaderSlot.Provider value={setSlotted}>{children}</HeaderSlot.Provider>
</div>
</div>
)
}
function Unbound() {
return <p className="text-sm text-muted-foreground">Pick a message.</p>
return <p className="text-muted-foreground">Pick a message.</p>
}
// ---------------------------------------------------------------------------
@@ -336,23 +194,18 @@ function Unbound() {
// ---------------------------------------------------------------------------
function StatWidget({ widget }: WidgetProps) {
const { Readout } = useUi()
const cfg = config(widget)
const message = text(cfg.message)
const live = useLiveValue(message || undefined)
if (!message) return <Unbound />
const precision = cfg.precision === undefined ? null : num(cfg.precision, 1)
return (
<div className="flex items-baseline gap-1.5">
<span className="truncate text-3xl tabular-nums">
{format(live?.value, precision)}
</span>
{cfg.unit ? (
<span className="text-base text-muted-foreground">
{text(cfg.unit)}
</span>
) : null}
</div>
<Readout
value={live?.value}
precision={cfg.precision === undefined ? null : num(cfg.precision, 1)}
unit={cfg.unit ? text(cfg.unit) : undefined}
/>
)
}
@@ -363,81 +216,21 @@ function StatWidget({ widget }: WidgetProps) {
* angle is unreadable to anyone who cannot judge one.
*/
function GaugeWidget({ widget }: WidgetProps) {
const { Gauge } = useUi()
const cfg = config(widget)
const message = text(cfg.message)
const live = useLiveValue(message || undefined)
if (!message) return <Unbound />
const min = num(cfg.min, 0)
const max = num(cfg.max, 100)
const value = typeof live?.value === "number" ? live.value : null
const fraction =
value === null
? 0
: Math.min(1, Math.max(0, (value - min) / (max - min || 1)))
// A 240° arc, the shape a dial is expected to have.
const radius = 42
const sweep = 240
const start = 150
const point = (angle: number) => {
const radians = (angle * Math.PI) / 180
return [50 + radius * Math.cos(radians), 50 + radius * Math.sin(radians)]
}
const arc = (from: number, to: number) => {
const [x1, y1] = point(from)
const [x2, y2] = point(to)
const large = Math.abs(to - from) > 180 ? 1 : 0
return `M ${x1} ${y1} A ${radius} ${radius} 0 ${large} 1 ${x2} ${y2}`
}
return (
<div className="flex min-h-0 flex-1 items-center justify-center">
<svg
viewBox="0 0 100 78"
className="h-full max-h-full w-full"
role="img"
aria-label={`${format(value, 1)} of ${max}`}
>
<path
d={arc(start, start + sweep)}
fill="none"
stroke="var(--muted)"
strokeWidth={9}
strokeLinecap="round"
/>
{/* The same full arc as the track, revealed by the dash: `d` is not
transitionable, so a reading that re-paths the arc can only jump.
`pathLength` normalises it to 1, which makes the offset the
fraction itself and saves measuring the geometry. */}
<path
className="widget-gauge-arc"
d={arc(start, start + sweep)}
pathLength={1}
strokeDasharray={1}
style={{ strokeDashoffset: 1 - fraction }}
fill="none"
stroke="var(--primary)"
strokeWidth={9}
strokeLinecap="round"
/>
<text
x={50}
y={54}
// User units of the viewBox, not the text scale: the readout has to
// stay proportional to the dial at whatever size the tile is.
fontSize={13}
textAnchor="middle"
className="fill-foreground tabular-nums"
>
{format(
value,
cfg.precision === undefined ? 1 : num(cfg.precision, 1),
)}
{cfg.unit ? text(cfg.unit) : ""}
</text>
</svg>
</div>
<Gauge
value={typeof live?.value === "number" ? live.value : null}
min={num(cfg.min, 0)}
max={num(cfg.max, 100)}
precision={cfg.precision === undefined ? 1 : num(cfg.precision, 1)}
unit={cfg.unit ? text(cfg.unit) : undefined}
label={widget.title || message}
/>
)
}
@@ -451,7 +244,7 @@ function MarkdownWidget({ widget }: WidgetProps) {
const content = text(config(widget).content)
const lines = content.split("\n")
return (
<div className="grid gap-1 break-words text-sm">
<div className="grid gap-1 break-words">
{lines.map((line, index) => {
const heading = /^(#{1,3})\s+(.*)$/.exec(line)
const body = heading ? heading[2] : line.replace(/^[-*]\s+/, "")
@@ -461,7 +254,7 @@ function MarkdownWidget({ widget }: WidgetProps) {
// Plain text: position is the only identity a line has.
key={`line-${index}`}
className={cn(
heading?.[1] === "#" && "text-lg font-medium",
heading?.[1] === "#" && "text-[1.35em] font-medium",
heading?.[1] === "##" && "font-medium",
heading?.[1] === "###" && "text-muted-foreground",
bullet && "pl-4",
@@ -523,13 +316,13 @@ function AgendaWidget({ widget }: WidgetProps) {
.slice(0, num(cfg.count, 5))
if (items.length === 0) {
return <p className="text-sm text-muted-foreground">Nothing coming up.</p>
return <p className="text-muted-foreground">Nothing coming up.</p>
}
return (
// The frame centres a single reading in its card; a list reads from the
// top, so it takes the slack below it.
<ul className="mb-auto grid gap-1.5 text-sm">
<ul className="mb-auto grid gap-1.5">
{items.map((item, index) => {
const when = new Date(item.ts * 1000)
return (
@@ -571,22 +364,27 @@ function NotificationWidget({ widget }: WidgetProps) {
const title = text(record?.title)
const body = text(record?.body)
if (!title && !body) {
return <p className="text-sm text-muted-foreground">Nothing to report.</p>
return <p className="text-muted-foreground">Nothing to report.</p>
}
const failed = record?.severity === "error"
return (
<div className="grid gap-1 break-words">
{title ? (
<p
className={cn(
"font-medium",
record?.severity === "error" && "text-destructive",
"flex items-baseline gap-1.5 font-medium",
failed && "text-destructive",
)}
>
{/* Never colour alone: a notice that went wrong says so in a word as
well as in red. */}
{failed ? <span className="sr-only">Error: </span> : null}
{failed ? <span aria-hidden></span> : null}
{title}
</p>
) : null}
{body ? <p className="text-sm text-muted-foreground">{body}</p> : null}
{body ? <p className="text-muted-foreground">{body}</p> : null}
</div>
)
}
@@ -596,6 +394,7 @@ function NotificationWidget({ widget }: WidgetProps) {
// ---------------------------------------------------------------------------
function ButtonWidget({ widget, dashboard }: WidgetProps) {
const { Button } = useUi()
const cfg = config(widget)
const { target, send, pending, pulse, locked } = usePublish(widget, dashboard)
if (!target) return <Unbound />
@@ -605,14 +404,11 @@ function ButtonWidget({ widget, dashboard }: WidgetProps) {
<>
{pulse}
<Button
variant="secondary"
className="w-full min-w-0"
variant="tonal"
disabled={pending || locked}
onClick={() => send(cfg.value ?? true)}
>
<span className="truncate">
{text(cfg.label, widget.title || "Send")}
</span>
{text(cfg.label, widget.title || "Send")}
</Button>
</>
)
@@ -625,6 +421,7 @@ function ButtonWidget({ widget, dashboard }: WidgetProps) {
* the state in words, because a fill alone does not say what it means.
*/
function SwitchWidget({ widget, dashboard }: WidgetProps) {
const { Button, Switch } = useUi()
const cfg = config(widget)
const { target, value, send, pulse, locked } = usePublish(widget, dashboard)
if (!target) return <Unbound />
@@ -634,168 +431,82 @@ function SwitchWidget({ widget, dashboard }: WidgetProps) {
<>
{pulse}
<Button
variant={on ? "default" : "secondary"}
className="w-full min-w-0"
aria-pressed={on}
aria-label={widget.title || target}
variant={on ? "filled" : "tonal"}
pressed={on}
label={widget.title || target}
disabled={locked}
onClick={() => send(!on)}
>
<span className="truncate">{on ? "On" : "Off"}</span>
{on ? "On" : "Off"}
</Button>
</>
) : (
<div className="flex items-center justify-between gap-2">
{pulse}
<span className="text-sm">{on ? "On" : "Off"}</span>
<span>{on ? "On" : "Off"}</span>
<Switch
checked={on}
aria-label={widget.title || target}
label={widget.title || target}
disabled={locked}
onCheckedChange={(checked) => send(checked)}
onChange={(checked) => send(checked)}
/>
</div>
)
}
/**
* How many intervals the scale under a slider is cut into.
*
* A mark lands on a step wherever the range divides evenly, so a value aimed
* at is one the slider can stop on. Five labels is what stays readable across a
* room; a range of five steps or fewer is simply labelled in full.
*/
function tickIntervals(steps: number): number {
if (!Number.isFinite(steps) || steps <= 0) return 4
if (steps <= 5) return Math.max(1, Math.round(steps))
return [4, 3, 2].find((count) => Number.isInteger(steps / count)) ?? 4
}
/**
* The scale under the track, drawn rather than declared.
*
* A `datalist` gives unlabelled marks at best — no browser renders
* `<option label>` for a range — and a 2022 °C setpoint is unusable without
* numbers to aim at.
*/
function SliderTicks({
min,
max,
step,
}: {
min: number
max: number
step: number
}) {
const span = max - min
if (!(span > 0)) return null
const intervals = tickIntervals(step > 0 ? span / step : 0)
// Taken off the step, so 01 at 0.01 reads "0.25" and 0100 at 1 reads "25"
// without a precision setting of its own.
const digits = (String(step).split(".")[1] ?? "").length
return (
// Decoration: the input itself announces min, max and where it stands.
<div
aria-hidden
className="relative h-4 text-xs text-muted-foreground tabular-nums"
>
{Array.from({ length: intervals + 1 }, (_, index) => {
const percent = (index / intervals) * 100
return (
<span
key={index}
className="absolute top-0"
// Shifted by its own share of itself: the first label sits flush
// left and the last flush right, so neither hangs off the tile.
style={{
left: `${percent}%`,
transform: `translateX(-${percent}%)`,
}}
>
{Number((min + (span * index) / intervals).toFixed(digits))}
</span>
)
})}
</div>
)
}
function SliderWidget({ widget, dashboard }: WidgetProps) {
const { Readout, Slider } = useUi()
const cfg = config(widget)
const { target, value, send, pulse, locked } = usePublish(widget, dashboard)
const min = num(cfg.min, 0)
const max = num(cfg.max, 100)
const step = num(cfg.step, 1)
// While dragging, the handle follows the finger rather than the engine.
const [dragging, setDragging] = useState<number | null>(null)
if (!target) return <Unbound />
const current = dragging ?? (typeof value === "number" ? value : min)
const unit = cfg.unit ? text(cfg.unit) : undefined
return (
<div className="grid gap-2">
{pulse}
<div className="flex items-baseline justify-between">
<span className="text-2xl tabular-nums">{current}</span>
{cfg.unit ? (
<span className="text-sm text-muted-foreground">
{text(cfg.unit)}
</span>
) : null}
</div>
<input
type="range"
<Readout
value={typeof value === "number" ? value : min}
precision={null}
unit={unit}
size="inline"
/>
<Slider
value={typeof value === "number" ? value : min}
min={min}
max={max}
step={step}
value={current}
aria-label={widget.title || target}
max={num(cfg.max, 100)}
step={num(cfg.step, 1)}
unit={unit}
ticks
label={widget.title || target}
disabled={locked}
className="h-11 w-full accent-[var(--primary)] md:h-8"
onChange={(event) => setDragging(Number(event.target.value))}
// Only the release publishes: dragging would otherwise send a value
// per pixel and flood whatever is listening. Letting go hands the
// value to the hold, so the handle stays where it was put.
onPointerUp={() => {
if (dragging !== null) send(dragging)
setDragging(null)
}}
onKeyUp={() => {
if (dragging !== null) send(dragging)
setDragging(null)
}}
onCommit={send}
/>
<SliderTicks min={min} max={max} step={step} />
</div>
)
}
function InputWidget({ widget, dashboard }: WidgetProps) {
const { Input } = useUi()
const cfg = config(widget)
const { target, value, send, pulse, locked } = usePublish(widget, dashboard)
const [draft, setDraft] = useState<string | null>(null)
if (!target) return <Unbound />
const asNumber = cfg.dtype === "float" || cfg.dtype === "int"
const commit = () => {
if (draft === null) return
send(asNumber ? Number(draft) || 0 : draft)
setDraft(null)
}
return (
<>
{pulse}
<Input
value={draft ?? text(value)}
type={asNumber ? "number" : "text"}
aria-label={widget.title || target}
label={widget.title || target}
disabled={locked}
onChange={(event) => setDraft(event.target.value)}
onBlur={commit}
onKeyDown={(event) => {
if (event.key === "Enter") commit()
onChange={setDraft}
onCommit={() => {
if (draft === null) return
send(asNumber ? Number(draft) || 0 : draft)
setDraft(null)
}}
/>
</>
@@ -809,99 +520,46 @@ function InputWidget({ widget, dashboard }: WidgetProps) {
* the same exclusive group, drawn for a panel that is looked at across a room.
*/
function DropdownWidget({ widget, dashboard }: WidgetProps) {
const { Segmented, Select } = useUi()
const cfg = config(widget)
const { target, value, send, pulse, locked } = usePublish(widget, dashboard)
const options = (cfg.options ?? []) as { label?: string; value?: unknown }[]
if (!target) return <Unbound />
if (cfg.style === "segmented") {
const chosen = options.findIndex(
(option) => text(option.value) === text(value),
)
return (
// The pulse stays outside the fieldset: that one is positioned itself,
// and the overlay belongs to the tile rather than to the control.
<>
{pulse}
{/* The one segmented shape: a single border pill, no dividers,
transparent segments, bg-accent on the selected one — held by a
thumb that slides rather than a fill that jumps from cell to cell.
A `fieldset` carries `min-inline-size: min-content` from the UA
sheet, which `w-full` does not override. */}
<fieldset
className="relative grid w-full min-w-0 items-center rounded-full border border-border p-1"
style={{
gridTemplateColumns: `repeat(${options.length}, minmax(0, 1fr))`,
}}
>
<legend className="sr-only">{widget.title || target}</legend>
{chosen >= 0 ? (
// Equal tracks and no gap, so a segment is exactly its share of the
// padded box and the thumb needs no measuring.
<span
aria-hidden
className="widget-segment-thumb pointer-events-none absolute inset-y-1 rounded-full bg-accent"
style={{
left: `calc(0.25rem + ${chosen} * (100% - 0.5rem) / ${options.length})`,
width: `calc((100% - 0.5rem) / ${options.length})`,
}}
/>
) : null}
{options.map((option, index) => (
<button
key={text(option.value)}
type="button"
aria-pressed={index === chosen}
disabled={locked}
onClick={() => send(option.value)}
className={cn(
"relative z-10 h-11 min-w-0 truncate rounded-full px-2.5 text-sm transition-colors disabled:opacity-50 md:h-8",
index === chosen
? "text-accent-foreground"
: "text-muted-foreground hover:bg-accent/50",
)}
>
{option.label ?? text(option.value)}
</button>
))}
</fieldset>
<Segmented
value={text(value)}
options={options.map(
(option) =>
[text(option.value), option.label ?? text(option.value)] as const,
)}
label={widget.title || target}
disabled={locked}
onChange={(picked) =>
send(options.find((o) => text(o.value) === picked)?.value ?? picked)
}
/>
</>
)
}
return (
<div className="grid gap-1.5">
<>
{pulse}
<Label className="sr-only">{widget.title || target}</Label>
<Select
value={text(value)}
onValueChange={(selected) => send(asOriginal(selected, options))}
>
<SelectTrigger
className="w-full"
aria-label={widget.title || target}
disabled={locked}
>
<SelectValue placeholder="Choose" />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={text(option.value)} value={text(option.value)}>
{option.label ?? text(option.value)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
options={options}
label={widget.title || target}
disabled={locked}
onChange={send}
/>
</>
)
}
/** Radix hands back a string; the message wants whatever was configured. */
function asOriginal(selected: string, options: { value?: unknown }[]): unknown {
const match = options.find((option) => text(option.value) === selected)
return match ? match.value : selected
}
// ---------------------------------------------------------------------------
export type WidgetProps = { widget: WidgetDef; dashboard: string }
@@ -931,7 +589,7 @@ export function WidgetBody({ widget, dashboard }: WidgetProps) {
const Renderer = RENDERERS[widget.type]
if (!Renderer) {
return (
<p className="text-sm text-muted-foreground">
<p className="text-muted-foreground">
{WIDGET_LABELS[widget.type]} widgets are not drawn yet.
</p>
)