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"
|
import type { WidgetProps } from "./widgets"
|
||||||
|
|
||||||
/** An icon — a stub until the icon widget is written. */
|
/** One row of the mapping, as the document stores it. */
|
||||||
export function IconWidget(_props: WidgetProps) {
|
type Rule = { at?: unknown; icon?: string; color?: string; label?: string }
|
||||||
return <p className="text-sm text-muted-foreground">—</p>
|
|
||||||
|
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