Add the forecast strip widget: N dimming columns over a list message
This commit is contained in:
@@ -1,6 +1,80 @@
|
|||||||
|
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"
|
||||||
|
|
||||||
/** A forecast — a stub until the forecast widget is written. */
|
/** One column of a forecast, as the `list` message declares it. */
|
||||||
export function ForecastWidget(_props: WidgetProps) {
|
type ForecastItem = {
|
||||||
return <p className="text-sm text-muted-foreground">—</p>
|
label?: string
|
||||||
|
icon?: string
|
||||||
|
value?: string | number
|
||||||
|
color?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = (widget: WidgetProps["widget"]) =>
|
||||||
|
(widget.config ?? {}) as Record<string, unknown>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What comes next, as a strip of columns over a `list` message.
|
||||||
|
*
|
||||||
|
* The shape is the widget's contract rather than a path per column: every item
|
||||||
|
* is `{label, icon, value}` with an optional `color`, naming its icon from the
|
||||||
|
* same vocabulary the icon widget offers, so a flow answering with a forecast
|
||||||
|
* decides what a step is called and this only has to draw it. Later columns are
|
||||||
|
* dimmed progressively — a forecast is less certain the further out it reads.
|
||||||
|
*/
|
||||||
|
export function ForecastWidget({ widget }: WidgetProps) {
|
||||||
|
const cfg = config(widget)
|
||||||
|
const message = cfg.message ? String(cfg.message) : ""
|
||||||
|
const live = useLiveValue(message || undefined)
|
||||||
|
if (!message) {
|
||||||
|
return <p className="text-sm text-muted-foreground">Pick a message.</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = Number(cfg.count)
|
||||||
|
const items: ForecastItem[] = (Array.isArray(live?.value) ? live.value : [])
|
||||||
|
.filter((item): item is ForecastItem => !!item && typeof item === "object")
|
||||||
|
.slice(0, Number.isFinite(count) ? count : 5)
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
return <p className="text-sm text-muted-foreground">Nothing forecast.</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-0 items-stretch justify-between gap-2">
|
||||||
|
{items.map((item, index) => {
|
||||||
|
const Glyph = ICONS[item.icon ?? ""] ?? null
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
// Two steps can carry the same label and reading; position is the
|
||||||
|
// identity, and it is what the dimming is computed from.
|
||||||
|
key={`column-${index}`}
|
||||||
|
data-testid="forecast-column"
|
||||||
|
className="flex min-w-0 flex-col items-center gap-1"
|
||||||
|
// Content rather than a panel, so this fades the column as a whole
|
||||||
|
// instead of text over a surface; an inline value also keeps the
|
||||||
|
// ramp out of an arbitrary Tailwind class.
|
||||||
|
style={{ opacity: Math.max(0.45, 1 - index * 0.14) }}
|
||||||
|
>
|
||||||
|
<span className="w-full truncate text-center text-xs text-muted-foreground">
|
||||||
|
{item.label ?? ""}
|
||||||
|
</span>
|
||||||
|
{Glyph ? (
|
||||||
|
<Glyph
|
||||||
|
className={cn(
|
||||||
|
"size-5 shrink-0",
|
||||||
|
ICON_COLORS[item.color ?? ""] ?? ICON_COLORS.default,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<span className="w-full truncate text-center text-sm tabular-nums">
|
||||||
|
{item.value === null || item.value === undefined
|
||||||
|
? ""
|
||||||
|
: String(item.value)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user