Add the icon-by-value widget: first matching rule picks glyph, colour and label

This commit is contained in:
2026-08-20 08:35:34 +02:00
parent efa6a14795
commit 2a0b1f3725
@@ -1,6 +1,70 @@
import type { WidgetDef } from "@/client"
import { useLiveValue } from "@/components/Flow/liveStore"
import { cn } from "@/lib/utils"
import { ICON_COLORS, ICONS } from "./icons"
import type { WidgetProps } from "./widgets"
/** An icon — a stub until the icon widget is written. */
export function IconWidget(_props: WidgetProps) {
return <p className="text-sm text-muted-foreground"></p>
/** One row of the mapping, as the document stores it. */
type Rule = { at?: unknown; icon?: string; color?: string; label?: string }
const config = (widget: WidgetDef) =>
(widget.config ?? {}) as Record<string, unknown>
const text = (value: unknown, fallback = "") =>
value === null || value === undefined ? fallback : String(value)
/** What a mapped value was meant to be: 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
}
/**
* Whether a row claims this reading.
*
* Equality, plus `>=` when both sides are numbers: a ladder written from the
* top down then reads as thresholds, one field per row instead of a pair.
*/
const matches = (value: unknown, at: unknown) =>
value === at ||
(typeof value === "number" && typeof at === "number" && value >= at)
/**
* A glyph picked by what a message says.
*
* Rows are checked top to bottom and the first match wins, so a weather
* condition, a boolean hint and a threshold ladder all fit the same widget.
*/
export function IconWidget({ widget }: WidgetProps) {
const cfg = config(widget)
const message = text(cfg.message)
const live = useLiveValue(message || undefined)
const value = live?.value
const rules = (cfg.rules ?? []) as Rule[]
const matched =
value === null || value === undefined
? undefined
: rules.find((rule) => matches(value, coerce(text(rule.at))))
const Glyph = ICONS[matched?.icon ?? text(cfg.icon)] ?? null
// Unbound, unmatched, or pointed at a glyph that is not in the map.
if (!Glyph) return <p className="text-3xl text-muted-foreground"></p>
return (
<div className="flex min-w-0 flex-col items-center justify-center gap-1">
<Glyph
className={cn("size-10", ICON_COLORS[matched?.color ?? "default"])}
// State is never carried by colour alone.
aria-label={matched?.label || text(value, "—")}
data-testid="icon-glyph"
/>
{matched?.label ? (
<span className="w-full truncate text-center text-sm text-muted-foreground">
{matched.label}
</span>
) : null}
</div>
)
}