Rework the dashboard into two looks over one behaviour
A dashboard is a wall panel somebody hangs in their own hallway, so it now wears what they choose: a look, and a palette of their own colours. Two complete component sets live under `Dashboard/ui/` — `glass` (translucent panes over a slowly moving ground) and `material` (Material 3 tonal cards) — behind one prop contract. Every control's state, keyboard and `aria-` live in `ui/core` and are shared, so the two sets are the same dashboard drawn twice rather than two products: a set only decides what a control looks like while doing it. Four settings join the channel, each drivable by a flow like any other: `look`, `palette`, `background` and `touch`. A palette is an ordered list of hex colours — background, surface, primary, accent, text, then more chart colours — pasted from a coolors.co link or typed, written onto the canvas as the token variables everything already reads. Trailing roles are derived, so three colours are a whole dashboard, and derived text is held to AA rather than trusted (`theme.check.ts` measures it). A palette also decides light or dark, since its first colour is the ground. Widgets are measured against their own tile with container queries rather than against the viewport, animate through `motion`, and can be drawn without their title. The three reworks: - a bar draws a row per reading, up to eight, each in the dashboard's own data colours and each able to carry its own scale — replacing readings nested in one fill, which could only ever share one colour and stop at three. Documents written the old way are read as rows. - a chart's range picker moved to a column down its right-hand edge, which gives the plot back a whole row of a short tile. - the colour wheel became a disc: hue is the angle and saturation the distance from the middle, so a colour is one gesture rather than three, with brightness on a slider beside it. `index.css` and `lib/motion.ts` are untouched — the dashboard overrides token *values* on its canvas, never the blocks the two repos share.
This commit is contained in:
@@ -1,34 +1,21 @@
|
||||
import { createContext, useContext, useEffect, useState } from "react"
|
||||
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, segmentsOf } from "./BarWidget"
|
||||
import { BarWidget } from "./BarWidget"
|
||||
import { ChartWidget } from "./ChartWidget"
|
||||
import { ClockWidget } from "./ClockWidget"
|
||||
import { COLOR_DTYPES, ColorWidget, colorFormatOf } from "./ColorWidget"
|
||||
// The segmented thumb's rule lives beside the other widget CSS; a widget is
|
||||
// drawn by the editor as well as by the view, so the sheet is pulled in here.
|
||||
import { ColorWidget } from "./ColorWidget"
|
||||
// The grid's own rules live beside the components that use them; CSS is
|
||||
// chunked per entry, so the sheet is pulled in wherever a widget is drawn.
|
||||
import "./dashboard.css"
|
||||
import { ForecastWidget } from "./ForecastWidget"
|
||||
import { IconWidget } from "./IconWidget"
|
||||
import { usePublish } from "./publish"
|
||||
import { useUi } from "./ui"
|
||||
import { COLOR_DTYPES, colorFormatOf } from "./ui/core/color"
|
||||
import { config, num, rowsOf, text } from "./ui/core/config"
|
||||
|
||||
/** Widget types that put a value into the graph rather than read one. */
|
||||
export const INPUT_WIDGETS = new Set([
|
||||
@@ -113,30 +100,6 @@ export const WIDGET_SIZES: Record<WidgetKind, { w: number; h: number }> = {
|
||||
color: { w: 4, h: 4 },
|
||||
}
|
||||
|
||||
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 }
|
||||
|
||||
@@ -183,6 +146,22 @@ export function widgetIssue(widget: WidgetDef): string | null {
|
||||
return null
|
||||
}
|
||||
|
||||
// A bar draws a row per reading, so it is judged row by row — the same way
|
||||
// a chart is judged series by series.
|
||||
if (widget.type === "bar") {
|
||||
const rows = rowsOf(widget)
|
||||
if (rows.length === 0) return "This bar has no rows yet."
|
||||
const wrong = rows.find(
|
||||
(row) => !row.message || !acceptsDtype("bar", row.dtype),
|
||||
)
|
||||
if (wrong) {
|
||||
return wrong.message
|
||||
? `${wrong.message} is a ${wrong.dtype}; a bar draws numbers.`
|
||||
: "One of the rows is not bound to a message."
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const input = INPUT_WIDGETS.has(widget.type)
|
||||
const bound = text(cfg[input ? "target" : "message"])
|
||||
if (!bound) {
|
||||
@@ -197,15 +176,7 @@ export function widgetIssue(widget: WidgetDef): string | null {
|
||||
if (widget.type === "color") {
|
||||
const want = COLOR_DTYPES[colorFormatOf(widget)]
|
||||
if (dtype && dtype !== want) {
|
||||
return `${bound} is a ${dtype}; this wheel sends ${colorFormatOf(widget)}, which is a ${want}.`
|
||||
}
|
||||
}
|
||||
// Only a bar nests further readings, and an unrecorded type binds anything.
|
||||
// Read through `segmentsOf` so a stacked bar is judged segment by segment
|
||||
// rather than only in the one-reading shape it used to carry.
|
||||
for (const segment of segmentsOf(widget)) {
|
||||
if (!acceptsDtype(widget.type, segment.dtype || undefined)) {
|
||||
return `${segment.message} is a ${segment.dtype}; a bar nests numbers.`
|
||||
return `${bound} is a ${dtype}; this disc sends ${colorFormatOf(widget)}, which is a ${want}.`
|
||||
}
|
||||
}
|
||||
if (widget.type === "icon" && !(cfg.rules as unknown[] | undefined)?.length) {
|
||||
@@ -214,121 +185,8 @@ export function widgetIssue(widget: WidgetDef): string | null {
|
||||
return null
|
||||
}
|
||||
|
||||
const HeaderSlot = createContext<((node: React.ReactNode) => void) | null>(null)
|
||||
|
||||
/**
|
||||
* Draw something in the frame's header row, from inside the widget body.
|
||||
*
|
||||
* A widget's own chrome is state the widget holds — the window a chart is
|
||||
* showing lives in the chart — while the row it belongs on is the frame's. The
|
||||
* body hands the node up rather than the frame reaching down for it.
|
||||
*
|
||||
* `deps` name what makes the node different, as with any effect.
|
||||
*/
|
||||
export function useHeaderSlot(
|
||||
node: React.ReactNode,
|
||||
deps: React.DependencyList,
|
||||
) {
|
||||
const set = useContext(HeaderSlot)
|
||||
useEffect(() => {
|
||||
set?.(node)
|
||||
return () => set?.(null)
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: the caller names what makes the node different; the node itself is a fresh element on every render, so it cannot be one.
|
||||
}, deps)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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>
|
||||
}) {
|
||||
// What the body asked to have drawn up here — see `useHeaderSlot`. Held as
|
||||
// state rather than portalled into the row, so a widget with no title and
|
||||
// nothing to slot still costs no header at all.
|
||||
const [slotted, setSlotted] = useState<React.ReactNode>(null)
|
||||
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(
|
||||
// `relative` is what the transmit overlay hangs off: a control's pulse
|
||||
// is drawn over the whole tile and must take no layout box at all.
|
||||
"relative 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 || slotted ? (
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-w-0 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}
|
||||
{slotted}
|
||||
{actions}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{/* A scroller, not a clip: the header is an earlier sibling, so anything
|
||||
taller than the card would otherwise paint over the title instead of
|
||||
being reachable. Centring has to be `safe` — plain `center` overflows
|
||||
both edges at once and puts the top of a long body out of reach. */}
|
||||
<div className="flex min-h-0 flex-1 flex-col justify-center-safe overflow-y-auto">
|
||||
<HeaderSlot.Provider value={setSlotted}>{children}</HeaderSlot.Provider>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Unbound() {
|
||||
return <p className="text-sm text-muted-foreground">Pick a message.</p>
|
||||
return <p className="text-muted-foreground">Pick a message.</p>
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -336,23 +194,18 @@ function Unbound() {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function StatWidget({ widget }: WidgetProps) {
|
||||
const { Readout } = useUi()
|
||||
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>
|
||||
<Readout
|
||||
value={live?.value}
|
||||
precision={cfg.precision === undefined ? null : num(cfg.precision, 1)}
|
||||
unit={cfg.unit ? text(cfg.unit) : undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -363,81 +216,21 @@ function StatWidget({ widget }: WidgetProps) {
|
||||
* angle is unreadable to anyone who cannot judge one.
|
||||
*/
|
||||
function GaugeWidget({ widget }: WidgetProps) {
|
||||
const { Gauge } = useUi()
|
||||
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"
|
||||
/>
|
||||
{/* The same full arc as the track, revealed by the dash: `d` is not
|
||||
transitionable, so a reading that re-paths the arc can only jump.
|
||||
`pathLength` normalises it to 1, which makes the offset the
|
||||
fraction itself and saves measuring the geometry. */}
|
||||
<path
|
||||
className="widget-gauge-arc"
|
||||
d={arc(start, start + sweep)}
|
||||
pathLength={1}
|
||||
strokeDasharray={1}
|
||||
style={{ strokeDashoffset: 1 - fraction }}
|
||||
fill="none"
|
||||
stroke="var(--primary)"
|
||||
strokeWidth={9}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<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>
|
||||
<Gauge
|
||||
value={typeof live?.value === "number" ? live.value : null}
|
||||
min={num(cfg.min, 0)}
|
||||
max={num(cfg.max, 100)}
|
||||
precision={cfg.precision === undefined ? 1 : num(cfg.precision, 1)}
|
||||
unit={cfg.unit ? text(cfg.unit) : undefined}
|
||||
label={widget.title || message}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -451,7 +244,7 @@ function MarkdownWidget({ widget }: WidgetProps) {
|
||||
const content = text(config(widget).content)
|
||||
const lines = content.split("\n")
|
||||
return (
|
||||
<div className="grid gap-1 break-words text-sm">
|
||||
<div className="grid gap-1 break-words">
|
||||
{lines.map((line, index) => {
|
||||
const heading = /^(#{1,3})\s+(.*)$/.exec(line)
|
||||
const body = heading ? heading[2] : line.replace(/^[-*]\s+/, "")
|
||||
@@ -461,7 +254,7 @@ function MarkdownWidget({ widget }: WidgetProps) {
|
||||
// Plain text: position is the only identity a line has.
|
||||
key={`line-${index}`}
|
||||
className={cn(
|
||||
heading?.[1] === "#" && "text-lg font-medium",
|
||||
heading?.[1] === "#" && "text-[1.35em] font-medium",
|
||||
heading?.[1] === "##" && "font-medium",
|
||||
heading?.[1] === "###" && "text-muted-foreground",
|
||||
bullet && "pl-4",
|
||||
@@ -523,13 +316,13 @@ function AgendaWidget({ widget }: WidgetProps) {
|
||||
.slice(0, num(cfg.count, 5))
|
||||
|
||||
if (items.length === 0) {
|
||||
return <p className="text-sm text-muted-foreground">Nothing coming up.</p>
|
||||
return <p className="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">
|
||||
<ul className="mb-auto grid gap-1.5">
|
||||
{items.map((item, index) => {
|
||||
const when = new Date(item.ts * 1000)
|
||||
return (
|
||||
@@ -571,22 +364,27 @@ function NotificationWidget({ widget }: WidgetProps) {
|
||||
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 <p className="text-muted-foreground">Nothing to report.</p>
|
||||
}
|
||||
|
||||
const failed = record?.severity === "error"
|
||||
return (
|
||||
<div className="grid gap-1 break-words">
|
||||
{title ? (
|
||||
<p
|
||||
className={cn(
|
||||
"font-medium",
|
||||
record?.severity === "error" && "text-destructive",
|
||||
"flex items-baseline gap-1.5 font-medium",
|
||||
failed && "text-destructive",
|
||||
)}
|
||||
>
|
||||
{/* Never colour alone: a notice that went wrong says so in a word as
|
||||
well as in red. */}
|
||||
{failed ? <span className="sr-only">Error: </span> : null}
|
||||
{failed ? <span aria-hidden>⚠</span> : null}
|
||||
{title}
|
||||
</p>
|
||||
) : null}
|
||||
{body ? <p className="text-sm text-muted-foreground">{body}</p> : null}
|
||||
{body ? <p className="text-muted-foreground">{body}</p> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -596,6 +394,7 @@ function NotificationWidget({ widget }: WidgetProps) {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ButtonWidget({ widget, dashboard }: WidgetProps) {
|
||||
const { Button } = useUi()
|
||||
const cfg = config(widget)
|
||||
const { target, send, pending, pulse, locked } = usePublish(widget, dashboard)
|
||||
if (!target) return <Unbound />
|
||||
@@ -605,14 +404,11 @@ function ButtonWidget({ widget, dashboard }: WidgetProps) {
|
||||
<>
|
||||
{pulse}
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full min-w-0"
|
||||
variant="tonal"
|
||||
disabled={pending || locked}
|
||||
onClick={() => send(cfg.value ?? true)}
|
||||
>
|
||||
<span className="truncate">
|
||||
{text(cfg.label, widget.title || "Send")}
|
||||
</span>
|
||||
{text(cfg.label, widget.title || "Send")}
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
@@ -625,6 +421,7 @@ function ButtonWidget({ widget, dashboard }: WidgetProps) {
|
||||
* the state in words, because a fill alone does not say what it means.
|
||||
*/
|
||||
function SwitchWidget({ widget, dashboard }: WidgetProps) {
|
||||
const { Button, Switch } = useUi()
|
||||
const cfg = config(widget)
|
||||
const { target, value, send, pulse, locked } = usePublish(widget, dashboard)
|
||||
if (!target) return <Unbound />
|
||||
@@ -634,168 +431,82 @@ function SwitchWidget({ widget, dashboard }: WidgetProps) {
|
||||
<>
|
||||
{pulse}
|
||||
<Button
|
||||
variant={on ? "default" : "secondary"}
|
||||
className="w-full min-w-0"
|
||||
aria-pressed={on}
|
||||
aria-label={widget.title || target}
|
||||
variant={on ? "filled" : "tonal"}
|
||||
pressed={on}
|
||||
label={widget.title || target}
|
||||
disabled={locked}
|
||||
onClick={() => send(!on)}
|
||||
>
|
||||
<span className="truncate">{on ? "On" : "Off"}</span>
|
||||
{on ? "On" : "Off"}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
{pulse}
|
||||
<span className="text-sm">{on ? "On" : "Off"}</span>
|
||||
<span>{on ? "On" : "Off"}</span>
|
||||
<Switch
|
||||
checked={on}
|
||||
aria-label={widget.title || target}
|
||||
label={widget.title || target}
|
||||
disabled={locked}
|
||||
onCheckedChange={(checked) => send(checked)}
|
||||
onChange={(checked) => send(checked)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* How many intervals the scale under a slider is cut into.
|
||||
*
|
||||
* A mark lands on a step wherever the range divides evenly, so a value aimed
|
||||
* at is one the slider can stop on. Five labels is what stays readable across a
|
||||
* room; a range of five steps or fewer is simply labelled in full.
|
||||
*/
|
||||
function tickIntervals(steps: number): number {
|
||||
if (!Number.isFinite(steps) || steps <= 0) return 4
|
||||
if (steps <= 5) return Math.max(1, Math.round(steps))
|
||||
return [4, 3, 2].find((count) => Number.isInteger(steps / count)) ?? 4
|
||||
}
|
||||
|
||||
/**
|
||||
* The scale under the track, drawn rather than declared.
|
||||
*
|
||||
* A `datalist` gives unlabelled marks at best — no browser renders
|
||||
* `<option label>` for a range — and a 20–22 °C setpoint is unusable without
|
||||
* numbers to aim at.
|
||||
*/
|
||||
function SliderTicks({
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
}: {
|
||||
min: number
|
||||
max: number
|
||||
step: number
|
||||
}) {
|
||||
const span = max - min
|
||||
if (!(span > 0)) return null
|
||||
const intervals = tickIntervals(step > 0 ? span / step : 0)
|
||||
// Taken off the step, so 0–1 at 0.01 reads "0.25" and 0–100 at 1 reads "25"
|
||||
// without a precision setting of its own.
|
||||
const digits = (String(step).split(".")[1] ?? "").length
|
||||
|
||||
return (
|
||||
// Decoration: the input itself announces min, max and where it stands.
|
||||
<div
|
||||
aria-hidden
|
||||
className="relative h-4 text-xs text-muted-foreground tabular-nums"
|
||||
>
|
||||
{Array.from({ length: intervals + 1 }, (_, index) => {
|
||||
const percent = (index / intervals) * 100
|
||||
return (
|
||||
<span
|
||||
key={index}
|
||||
className="absolute top-0"
|
||||
// Shifted by its own share of itself: the first label sits flush
|
||||
// left and the last flush right, so neither hangs off the tile.
|
||||
style={{
|
||||
left: `${percent}%`,
|
||||
transform: `translateX(-${percent}%)`,
|
||||
}}
|
||||
>
|
||||
{Number((min + (span * index) / intervals).toFixed(digits))}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SliderWidget({ widget, dashboard }: WidgetProps) {
|
||||
const { Readout, Slider } = useUi()
|
||||
const cfg = config(widget)
|
||||
const { target, value, send, pulse, locked } = 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 value === "number" ? value : min)
|
||||
|
||||
const unit = cfg.unit ? text(cfg.unit) : undefined
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
{pulse}
|
||||
<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>
|
||||
<input
|
||||
type="range"
|
||||
<Readout
|
||||
value={typeof value === "number" ? value : min}
|
||||
precision={null}
|
||||
unit={unit}
|
||||
size="inline"
|
||||
/>
|
||||
<Slider
|
||||
value={typeof value === "number" ? value : min}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={current}
|
||||
aria-label={widget.title || target}
|
||||
max={num(cfg.max, 100)}
|
||||
step={num(cfg.step, 1)}
|
||||
unit={unit}
|
||||
ticks
|
||||
label={widget.title || target}
|
||||
disabled={locked}
|
||||
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. Letting go hands the
|
||||
// value to the hold, so the handle stays where it was put.
|
||||
onPointerUp={() => {
|
||||
if (dragging !== null) send(dragging)
|
||||
setDragging(null)
|
||||
}}
|
||||
onKeyUp={() => {
|
||||
if (dragging !== null) send(dragging)
|
||||
setDragging(null)
|
||||
}}
|
||||
onCommit={send}
|
||||
/>
|
||||
<SliderTicks min={min} max={max} step={step} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InputWidget({ widget, dashboard }: WidgetProps) {
|
||||
const { Input } = useUi()
|
||||
const cfg = config(widget)
|
||||
const { target, value, send, pulse, locked } = 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 (
|
||||
<>
|
||||
{pulse}
|
||||
<Input
|
||||
value={draft ?? text(value)}
|
||||
type={asNumber ? "number" : "text"}
|
||||
aria-label={widget.title || target}
|
||||
label={widget.title || target}
|
||||
disabled={locked}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") commit()
|
||||
onChange={setDraft}
|
||||
onCommit={() => {
|
||||
if (draft === null) return
|
||||
send(asNumber ? Number(draft) || 0 : draft)
|
||||
setDraft(null)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
@@ -809,99 +520,46 @@ function InputWidget({ widget, dashboard }: WidgetProps) {
|
||||
* the same exclusive group, drawn for a panel that is looked at across a room.
|
||||
*/
|
||||
function DropdownWidget({ widget, dashboard }: WidgetProps) {
|
||||
const { Segmented, Select } = useUi()
|
||||
const cfg = config(widget)
|
||||
const { target, value, send, pulse, locked } = usePublish(widget, dashboard)
|
||||
const options = (cfg.options ?? []) as { label?: string; value?: unknown }[]
|
||||
if (!target) return <Unbound />
|
||||
|
||||
if (cfg.style === "segmented") {
|
||||
const chosen = options.findIndex(
|
||||
(option) => text(option.value) === text(value),
|
||||
)
|
||||
return (
|
||||
// The pulse stays outside the fieldset: that one is positioned itself,
|
||||
// and the overlay belongs to the tile rather than to the control.
|
||||
<>
|
||||
{pulse}
|
||||
{/* The one segmented shape: a single border pill, no dividers,
|
||||
transparent segments, bg-accent on the selected one — held by a
|
||||
thumb that slides rather than a fill that jumps from cell to cell.
|
||||
A `fieldset` carries `min-inline-size: min-content` from the UA
|
||||
sheet, which `w-full` does not override. */}
|
||||
<fieldset
|
||||
className="relative grid w-full min-w-0 items-center rounded-full border border-border p-1"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${options.length}, minmax(0, 1fr))`,
|
||||
}}
|
||||
>
|
||||
<legend className="sr-only">{widget.title || target}</legend>
|
||||
{chosen >= 0 ? (
|
||||
// Equal tracks and no gap, so a segment is exactly its share of the
|
||||
// padded box and the thumb needs no measuring.
|
||||
<span
|
||||
aria-hidden
|
||||
className="widget-segment-thumb pointer-events-none absolute inset-y-1 rounded-full bg-accent"
|
||||
style={{
|
||||
left: `calc(0.25rem + ${chosen} * (100% - 0.5rem) / ${options.length})`,
|
||||
width: `calc((100% - 0.5rem) / ${options.length})`,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{options.map((option, index) => (
|
||||
<button
|
||||
key={text(option.value)}
|
||||
type="button"
|
||||
aria-pressed={index === chosen}
|
||||
disabled={locked}
|
||||
onClick={() => send(option.value)}
|
||||
className={cn(
|
||||
"relative z-10 h-11 min-w-0 truncate rounded-full px-2.5 text-sm transition-colors disabled:opacity-50 md:h-8",
|
||||
index === chosen
|
||||
? "text-accent-foreground"
|
||||
: "text-muted-foreground hover:bg-accent/50",
|
||||
)}
|
||||
>
|
||||
{option.label ?? text(option.value)}
|
||||
</button>
|
||||
))}
|
||||
</fieldset>
|
||||
<Segmented
|
||||
value={text(value)}
|
||||
options={options.map(
|
||||
(option) =>
|
||||
[text(option.value), option.label ?? text(option.value)] as const,
|
||||
)}
|
||||
label={widget.title || target}
|
||||
disabled={locked}
|
||||
onChange={(picked) =>
|
||||
send(options.find((o) => text(o.value) === picked)?.value ?? picked)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-1.5">
|
||||
<>
|
||||
{pulse}
|
||||
<Label className="sr-only">{widget.title || target}</Label>
|
||||
<Select
|
||||
value={text(value)}
|
||||
onValueChange={(selected) => send(asOriginal(selected, options))}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="w-full"
|
||||
aria-label={widget.title || target}
|
||||
disabled={locked}
|
||||
>
|
||||
<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>
|
||||
options={options}
|
||||
label={widget.title || target}
|
||||
disabled={locked}
|
||||
onChange={send}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** 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 }
|
||||
@@ -931,7 +589,7 @@ export function WidgetBody({ widget, dashboard }: WidgetProps) {
|
||||
const Renderer = RENDERERS[widget.type]
|
||||
if (!Renderer) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<p className="text-muted-foreground">
|
||||
{WIDGET_LABELS[widget.type]} widgets are not drawn yet.
|
||||
</p>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user