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
803 lines
25 KiB
TypeScript
803 lines
25 KiB
TypeScript
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 } from "./BarWidget"
|
||
import { ChartWidget } from "./ChartWidget"
|
||
import { ClockWidget } from "./ClockWidget"
|
||
import { ForecastWidget } from "./ForecastWidget"
|
||
import { IconWidget } from "./IconWidget"
|
||
import { usePublishMessage } from "./queries"
|
||
|
||
/** Widget types that put a value into the graph rather than read one. */
|
||
export const INPUT_WIDGETS = new Set([
|
||
"button",
|
||
"switch",
|
||
"slider",
|
||
"input",
|
||
"dropdown",
|
||
])
|
||
|
||
export type WidgetKind = WidgetDef["type"]
|
||
|
||
/**
|
||
* What a widget can be pointed at, by payload type.
|
||
*
|
||
* A switch that reads a float has nothing to show and nothing safe to send, so
|
||
* the pairing is part of the document rather than a matter of taste. The same
|
||
* table is enforced on the server (`app/flow/dashboards.py`); a type missing
|
||
* from it takes anything.
|
||
*/
|
||
export const WIDGET_DTYPES: Partial<Record<WidgetKind, string[]>> = {
|
||
gauge: ["float", "int"],
|
||
// A chart reading the engine's ring. One that queries binds a `series`
|
||
// answer and a `record` request instead, checked in `widgetIssue`.
|
||
chart: ["float", "int"],
|
||
slider: ["float", "int"],
|
||
switch: ["bool"],
|
||
agenda: ["list"],
|
||
notification: ["record"],
|
||
bar: ["float", "int"],
|
||
forecast: ["list"],
|
||
// An icon maps weather strings, bool hints and numbers alike, and a clock
|
||
// binds nothing at all, so neither has a row to be held to.
|
||
}
|
||
|
||
/** Whether a message of this payload type may drive this kind of widget. */
|
||
export function acceptsDtype(kind: WidgetKind, dtype: string | undefined) {
|
||
const allowed = WIDGET_DTYPES[kind]
|
||
return !allowed || !dtype || allowed.includes(dtype)
|
||
}
|
||
|
||
export const WIDGET_LABELS: Record<WidgetKind, string> = {
|
||
stat: "Value",
|
||
gauge: "Gauge",
|
||
chart: "Chart",
|
||
markdown: "Text",
|
||
agenda: "Agenda",
|
||
notification: "Notification",
|
||
bar: "Bar",
|
||
icon: "Icon",
|
||
forecast: "Forecast",
|
||
clock: "Clock",
|
||
button: "Button",
|
||
switch: "Switch",
|
||
slider: "Slider",
|
||
input: "Input",
|
||
dropdown: "Dropdown",
|
||
}
|
||
|
||
/** Default footprint per type, in grid units. */
|
||
export const WIDGET_SIZES: Record<WidgetKind, { w: number; h: number }> = {
|
||
stat: { w: 3, h: 2 },
|
||
gauge: { w: 3, h: 3 },
|
||
chart: { w: 6, h: 4 },
|
||
markdown: { w: 6, h: 2 },
|
||
agenda: { w: 4, h: 4 },
|
||
notification: { w: 4, h: 2 },
|
||
bar: { w: 4, h: 2 },
|
||
icon: { w: 2, h: 2 },
|
||
forecast: { w: 6, h: 2 },
|
||
clock: { w: 3, h: 2 },
|
||
button: { w: 3, h: 2 },
|
||
switch: { w: 3, h: 2 },
|
||
slider: { w: 4, h: 2 },
|
||
input: { w: 4, h: 2 },
|
||
dropdown: { w: 4, h: 2 },
|
||
}
|
||
|
||
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 }
|
||
|
||
export const seriesOf = (widget: WidgetDef): Series[] =>
|
||
(config(widget).series ?? []) as Series[]
|
||
|
||
/**
|
||
* What is wrong with this widget's wiring, if anything.
|
||
*
|
||
* Both halves are checked from the document alone — the picker records the
|
||
* payload type it bound — so a wall panel can flag a broken tile without
|
||
* fetching the message catalogue first.
|
||
*/
|
||
export function widgetIssue(widget: WidgetDef): string | null {
|
||
// Neither draws a message: a clock reads the wall, markdown its own text.
|
||
if (widget.type === "markdown" || widget.type === "clock") return null
|
||
const cfg = config(widget)
|
||
|
||
if (widget.type === "chart" && cfg.source === "query") {
|
||
if (!text(cfg.request)) return "This chart does not ask for anything yet."
|
||
if (!text(cfg.message)) return "This chart has no answer to draw yet."
|
||
const answer = text(cfg.dtype)
|
||
if (answer && answer !== "series") {
|
||
return `${text(cfg.message)} is a ${answer}; a chart that queries draws a series.`
|
||
}
|
||
const asked = text(cfg.request_dtype)
|
||
if (asked && asked !== "record") {
|
||
return `${text(cfg.request)} is a ${asked}; a request is a record.`
|
||
}
|
||
return null
|
||
}
|
||
|
||
if (widget.type === "chart") {
|
||
const series = seriesOf(widget)
|
||
if (series.length === 0) return "This chart has no series yet."
|
||
const wrong = series.find(
|
||
(entry) => !entry.message || !acceptsDtype("chart", entry.dtype),
|
||
)
|
||
if (wrong) {
|
||
return wrong.message
|
||
? `${wrong.message} is a ${wrong.dtype}; a chart can only draw numbers.`
|
||
: "One of the series is not bound to a message."
|
||
}
|
||
return null
|
||
}
|
||
|
||
const input = INPUT_WIDGETS.has(widget.type)
|
||
const bound = text(cfg[input ? "target" : "message"])
|
||
if (!bound) {
|
||
return input
|
||
? "This control does not publish to a message yet."
|
||
: "This widget is not bound to a message yet."
|
||
}
|
||
const dtype = cfg.dtype === undefined ? undefined : text(cfg.dtype)
|
||
if (!acceptsDtype(widget.type, dtype)) {
|
||
return `${bound} is a ${dtype}; a ${WIDGET_LABELS[widget.type].toLowerCase()} cannot carry that.`
|
||
}
|
||
// Only a bar nests a second reading, and an unrecorded type binds anything.
|
||
if (!acceptsDtype(widget.type, text(cfg.inner_dtype) || undefined)) {
|
||
return `${text(cfg.inner)} is a ${text(cfg.inner_dtype)}; a bar nests numbers.`
|
||
}
|
||
if (widget.type === "icon" && !(cfg.rules as unknown[] | undefined)?.length) {
|
||
return "This icon has nothing mapped yet."
|
||
}
|
||
return null
|
||
}
|
||
|
||
/**
|
||
* 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>
|
||
}) {
|
||
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(
|
||
"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 ? (
|
||
<div
|
||
className={cn(
|
||
"flex 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}
|
||
{actions}
|
||
</span>
|
||
</div>
|
||
) : null}
|
||
<div className="flex min-h-0 flex-1 flex-col justify-center">
|
||
{children}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function Unbound() {
|
||
return <p className="text-sm text-muted-foreground">Pick a message.</p>
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Display
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function StatWidget({ widget }: WidgetProps) {
|
||
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>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* A dial, drawn as an arc.
|
||
*
|
||
* The number is always written out as well: a reading that only exists as an
|
||
* angle is unreadable to anyone who cannot judge one.
|
||
*/
|
||
function GaugeWidget({ widget }: WidgetProps) {
|
||
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"
|
||
/>
|
||
{fraction > 0 ? (
|
||
<path
|
||
d={arc(start, start + sweep * fraction)}
|
||
fill="none"
|
||
stroke="var(--primary)"
|
||
strokeWidth={9}
|
||
strokeLinecap="round"
|
||
/>
|
||
) : null}
|
||
<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>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* A very small markdown subset: headings, bold, code, links, list items.
|
||
*
|
||
* Enough for the labels and notes a dashboard carries, and not worth a parser.
|
||
*/
|
||
function MarkdownWidget({ widget }: WidgetProps) {
|
||
const content = text(config(widget).content)
|
||
const lines = content.split("\n")
|
||
return (
|
||
<div className="grid gap-1 text-sm">
|
||
{lines.map((line, index) => {
|
||
const heading = /^(#{1,3})\s+(.*)$/.exec(line)
|
||
const body = heading ? heading[2] : line.replace(/^[-*]\s+/, "")
|
||
const bullet = !heading && /^[-*]\s+/.test(line)
|
||
return (
|
||
<p
|
||
// Plain text: position is the only identity a line has.
|
||
key={`line-${index}`}
|
||
className={cn(
|
||
heading?.[1] === "#" && "text-lg font-medium",
|
||
heading?.[1] === "##" && "font-medium",
|
||
heading?.[1] === "###" && "text-muted-foreground",
|
||
bullet && "pl-4",
|
||
)}
|
||
>
|
||
{bullet ? "• " : ""}
|
||
{body}
|
||
</p>
|
||
)
|
||
})}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/** One item of an agenda, as the `list` message declares it. */
|
||
type AgendaItem = { title: string; ts: number; all_day?: boolean }
|
||
|
||
const DAY_MS = 86_400_000
|
||
|
||
/**
|
||
* Which day something falls on, said the way a person would.
|
||
*
|
||
* Today and tomorrow by name, the rest of the week by weekday, and anything
|
||
* further out by date — past a week "Thursday" stops telling you which one.
|
||
*/
|
||
function dayLabel(when: Date, now: Date): string {
|
||
const midnight = new Date(now).setHours(0, 0, 0, 0)
|
||
const days = Math.floor(
|
||
(new Date(when).setHours(0, 0, 0, 0) - midnight) / DAY_MS,
|
||
)
|
||
if (days === 0) return "Today"
|
||
if (days === 1) return "Tomorrow"
|
||
if (days < 7) return when.toLocaleDateString(undefined, { weekday: "long" })
|
||
return when.toLocaleDateString()
|
||
}
|
||
|
||
/**
|
||
* What is coming up, from a `list` of items the message declares.
|
||
*
|
||
* The shape is the widget's contract rather than a path per binding: every
|
||
* item is `{title, ts}` with an optional `all_day`, so a flow answering with
|
||
* a calendar decides what an entry is called and this only has to draw it.
|
||
*/
|
||
function AgendaWidget({ widget }: WidgetProps) {
|
||
const cfg = config(widget)
|
||
const message = text(cfg.message)
|
||
const live = useLiveValue(message || undefined)
|
||
if (!message) return <Unbound />
|
||
|
||
const now = new Date()
|
||
const today = new Date(now).setHours(0, 0, 0, 0) / 1000
|
||
const items = (Array.isArray(live?.value) ? live.value : [])
|
||
.filter(
|
||
(item): item is AgendaItem =>
|
||
typeof item?.title === "string" && Number.isFinite(item?.ts),
|
||
)
|
||
.filter((item) => item.ts >= today)
|
||
.sort((a, b) => a.ts - b.ts)
|
||
.slice(0, num(cfg.count, 5))
|
||
|
||
if (items.length === 0) {
|
||
return <p className="text-sm 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">
|
||
{items.map((item, index) => {
|
||
const when = new Date(item.ts * 1000)
|
||
return (
|
||
<li
|
||
// Two entries can share a title and a time; position is the identity.
|
||
key={`item-${index}`}
|
||
className="flex items-baseline gap-2"
|
||
>
|
||
<span className="shrink-0 text-muted-foreground tabular-nums">
|
||
{dayLabel(when, now)}
|
||
{item.all_day
|
||
? ""
|
||
: ` ${when.toLocaleTimeString(undefined, {
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
})}`}
|
||
</span>
|
||
<span className="truncate">{item.title}</span>
|
||
</li>
|
||
)
|
||
})}
|
||
</ul>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* The last thing worth saying, held until something replaces it.
|
||
*
|
||
* No state of its own: the live store already keeps the latest value of a
|
||
* message, so what was published stays on the panel until the next one lands.
|
||
*/
|
||
function NotificationWidget({ widget }: WidgetProps) {
|
||
const cfg = config(widget)
|
||
const message = text(cfg.message)
|
||
const live = useLiveValue(message || undefined)
|
||
if (!message) return <Unbound />
|
||
|
||
const record = (live?.value ?? null) as Record<string, unknown> | null
|
||
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 (
|
||
<div className="grid gap-1">
|
||
{title ? (
|
||
<p
|
||
className={cn(
|
||
"font-medium",
|
||
record?.severity === "error" && "text-destructive",
|
||
)}
|
||
>
|
||
{title}
|
||
</p>
|
||
) : null}
|
||
{body ? <p className="text-sm text-muted-foreground">{body}</p> : null}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Input
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/** Publishing, with the value shown as sent until the engine confirms it. */
|
||
function usePublish(widget: WidgetDef, dashboard: string) {
|
||
const cfg = config(widget)
|
||
const target = text(cfg.target)
|
||
const publish = usePublishMessage()
|
||
const live = useLiveValue(target || undefined)
|
||
return {
|
||
target,
|
||
live,
|
||
send: (value: unknown) => {
|
||
if (!target) return
|
||
publish.mutate({
|
||
name: target,
|
||
value,
|
||
dashboard,
|
||
widget: widget.id,
|
||
label: widget.title || widget.id,
|
||
kind: widget.type,
|
||
})
|
||
},
|
||
pending: publish.isPending,
|
||
}
|
||
}
|
||
|
||
function ButtonWidget({ widget, dashboard }: WidgetProps) {
|
||
const cfg = config(widget)
|
||
const { target, send, pending } = usePublish(widget, dashboard)
|
||
if (!target) return <Unbound />
|
||
return (
|
||
<Button
|
||
variant="secondary"
|
||
className="w-full"
|
||
disabled={pending}
|
||
onClick={() => send(cfg.value ?? true)}
|
||
>
|
||
{text(cfg.label, widget.title || "Send")}
|
||
</Button>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* A bool, published and read back — a latch either way it is drawn.
|
||
*
|
||
* `style: "button"` is a control that stays in rather than a track; both name
|
||
* the state in words, because a fill alone does not say what it means.
|
||
*/
|
||
function SwitchWidget({ widget, dashboard }: WidgetProps) {
|
||
const cfg = config(widget)
|
||
const { target, live, send } = usePublish(widget, dashboard)
|
||
if (!target) return <Unbound />
|
||
|
||
const on = live?.value === true
|
||
return cfg.style === "button" ? (
|
||
<Button
|
||
variant={on ? "default" : "secondary"}
|
||
className="w-full"
|
||
aria-pressed={on}
|
||
aria-label={widget.title || target}
|
||
onClick={() => send(!on)}
|
||
>
|
||
{on ? "On" : "Off"}
|
||
</Button>
|
||
) : (
|
||
<div className="flex items-center justify-between gap-2">
|
||
<span className="text-sm">{on ? "On" : "Off"}</span>
|
||
<Switch
|
||
checked={on}
|
||
aria-label={widget.title || target}
|
||
onCheckedChange={(checked) => send(checked)}
|
||
/>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function SliderWidget({ widget, dashboard }: WidgetProps) {
|
||
const cfg = config(widget)
|
||
const { target, live, send } = 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 live?.value === "number" ? live.value : min)
|
||
|
||
// A 20–22 °C setpoint at 0.1 is unusable without marks to aim at. Past
|
||
// fifty of them the ticks are a smear, so the browser gets none.
|
||
const steps = step > 0 ? (max - min) / step : 0
|
||
const ticks = Number.isFinite(steps) && steps > 0 && steps <= 50 ? steps : 0
|
||
const ticksId = `ticks-${widget.id}`
|
||
|
||
return (
|
||
<div className="grid gap-2">
|
||
<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>
|
||
{ticks ? (
|
||
<datalist id={ticksId}>
|
||
{Array.from({ length: Math.floor(ticks) + 1 }, (_, index) => (
|
||
<option key={index} value={min + index * step} />
|
||
))}
|
||
</datalist>
|
||
) : null}
|
||
<input
|
||
type="range"
|
||
min={min}
|
||
max={max}
|
||
step={step}
|
||
value={current}
|
||
list={ticks ? ticksId : undefined}
|
||
aria-label={widget.title || target}
|
||
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.
|
||
onPointerUp={() => {
|
||
if (dragging !== null) send(dragging)
|
||
setDragging(null)
|
||
}}
|
||
onKeyUp={() => {
|
||
if (dragging !== null) send(dragging)
|
||
setDragging(null)
|
||
}}
|
||
/>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function InputWidget({ widget, dashboard }: WidgetProps) {
|
||
const cfg = config(widget)
|
||
const { target, live, send } = 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 (
|
||
<Input
|
||
value={draft ?? text(live?.value)}
|
||
type={asNumber ? "number" : "text"}
|
||
aria-label={widget.title || target}
|
||
onChange={(event) => setDraft(event.target.value)}
|
||
onBlur={commit}
|
||
onKeyDown={(event) => {
|
||
if (event.key === "Enter") commit()
|
||
}}
|
||
/>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* One of N, published and read back.
|
||
*
|
||
* `style: "segmented"` shows every choice at once with the active one held —
|
||
* the same exclusive group, drawn for a panel that is looked at across a room.
|
||
*/
|
||
function DropdownWidget({ widget, dashboard }: WidgetProps) {
|
||
const cfg = config(widget)
|
||
const { target, live, send } = usePublish(widget, dashboard)
|
||
const options = (cfg.options ?? []) as { label?: string; value?: unknown }[]
|
||
if (!target) return <Unbound />
|
||
|
||
if (cfg.style === "segmented") {
|
||
return (
|
||
// The one segmented shape: a single border pill, no dividers,
|
||
// transparent segments, bg-accent on the selected one.
|
||
<fieldset className="flex w-full items-center gap-1 rounded-full border border-border p-1">
|
||
<legend className="sr-only">{widget.title || target}</legend>
|
||
{options.map((option) => {
|
||
const selected = text(option.value) === text(live?.value)
|
||
return (
|
||
<button
|
||
key={text(option.value)}
|
||
type="button"
|
||
aria-pressed={selected}
|
||
onClick={() => send(option.value)}
|
||
className={cn(
|
||
"h-11 min-w-0 flex-1 truncate rounded-full px-2.5 text-sm transition-colors md:h-8",
|
||
selected
|
||
? "bg-accent text-accent-foreground"
|
||
: "text-muted-foreground hover:bg-accent/50",
|
||
)}
|
||
>
|
||
{option.label ?? text(option.value)}
|
||
</button>
|
||
)
|
||
})}
|
||
</fieldset>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="grid gap-1.5">
|
||
<Label className="sr-only">{widget.title || target}</Label>
|
||
<Select
|
||
value={text(live?.value)}
|
||
onValueChange={(value) => send(asOriginal(value, options))}
|
||
>
|
||
<SelectTrigger aria-label={widget.title || target}>
|
||
<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>
|
||
)
|
||
}
|
||
|
||
/** 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 }
|
||
|
||
const RENDERERS: Partial<
|
||
Record<WidgetKind, (props: WidgetProps) => React.ReactNode>
|
||
> = {
|
||
stat: StatWidget,
|
||
gauge: GaugeWidget,
|
||
chart: ChartWidget,
|
||
markdown: MarkdownWidget,
|
||
agenda: AgendaWidget,
|
||
notification: NotificationWidget,
|
||
bar: BarWidget,
|
||
icon: IconWidget,
|
||
forecast: ForecastWidget,
|
||
clock: ClockWidget,
|
||
button: ButtonWidget,
|
||
switch: SwitchWidget,
|
||
slider: SliderWidget,
|
||
input: InputWidget,
|
||
dropdown: DropdownWidget,
|
||
}
|
||
|
||
export function WidgetBody({ widget, dashboard }: WidgetProps) {
|
||
const Renderer = RENDERERS[widget.type]
|
||
if (!Renderer) {
|
||
return (
|
||
<p className="text-sm text-muted-foreground">
|
||
{WIDGET_LABELS[widget.type]} widgets are not drawn yet.
|
||
</p>
|
||
)
|
||
}
|
||
return <Renderer widget={widget} dashboard={dashboard} />
|
||
}
|