Add the icon-by-value widget: first matching rule picks glyph, colour and label
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user