Slider reading in a tile: a line under the value in every look, row and fader; Beside it on every dashboard; tile geometry in the docs

This commit is contained in:
Melvin Strobl
2026-10-14 15:51:04 +02:00
parent 2008150692
commit d05d3c4665
4 changed files with 106 additions and 15 deletions
+24 -6
View File
@@ -48,7 +48,7 @@ still names the widget for accessibility and published values.
| **Button** | fixed value | one-shot action |
| **Buttons** | a value per button | grid of actions |
| **Switch** | `bool` | on/off |
| **Slider** | `float`, `int` | min, max, step; horizontal or vertical |
| **Slider** | `float`, `int` | min, max, step; horizontal or vertical; a reading beside it |
| **Input** | text or number | free entry |
| **Selector** | selected value | menu or row of choices |
| **Colour** | list or string | HSV, RGB or hex |
@@ -63,6 +63,27 @@ endpoints feeding the nodes that consume those messages.
Bindings are checked in the editor and on the server. See
[Payload types](../reference/payload-types.md).
### A slider's reading
A Slider can also show what it sets against, such as the room's temperature
beside a setpoint: **Beside it** binds `reading: {message, dtype, label}` to a
`float` or `int`. The reading is only shown, never published. It is written in
the slider's unit and to its step's decimals (a step of 0.5 writes 21 as
`21.0`); `—` stands in for the value until one arrives. On a round face it
takes the middle (see [Round dashboards](#round-dashboards)); in a tile it is a
line under the slider's value.
Sizes are CSS pixels of the canvas, as in the round geometry below. The
slider's own value is at the tile's text size (14; 16 on a touch-friendly
panel), centred on a line as tall as the control (32; 48 touch-friendly).
| Part | Value |
|---|---|
| Line | 16 high, text 12: the label (muted, at most 96 wide, cut with an ellipsis), 6, the value (text colour), 6, the unit (muted). Without a label, the value and unit alone |
| Row | directly under the slider's value, right-aligned with it, in the column right of the track (12 apart). With ticks the line shares the ticks' row, so the tile grows no taller; without them it adds 16 |
| Row width | the value column is as wide as its wider line, but at most half the row; the track takes the rest. What does not fit is cut from the label; the value and unit never are |
| Fader | directly under the slider's value, both centred under the track, 8 below it; the line no wider than the tile inside its padding, cut from the label first |
## Charts
Choose the chart's data source:
@@ -188,11 +209,8 @@ at once and is published once, 300 ms after the last notch or when the pointer
is released. A Gauge only shows. On any other face, turning a knob only wakes
the screen.
A Slider can also show what it sets against, such as the room's temperature
beside a setpoint: **Beside it** binds `reading: {message, dtype, label}` to a
`float` or `int`. That reading is written large in the middle, in the slider's
unit and to its step's decimals, under its label; the slider's own title and
value move to the line in the ring's gap.
A Slider's reading (**Beside it**) is written large in the middle, under its
label; the slider's own title and value move to the line in the ring's gap.
Value, Switch, Button, Clock, Icon, Notification and Text have faces of their
own. Every other type is drawn as its rectangular body in the middle of the
+3 -3
View File
@@ -511,7 +511,7 @@ export function WidgetPanel({
onChange({ config: { ...cfg, ...changes } })
const series = seriesOf(widget)
// What a slider on a round face reads beside its own value.
// What a slider reads beside its own value.
const reading = (cfg.reading ?? {}) as Series
const runsPick = (cfg.runs ?? {}) as RunsPick
const setSeries = (next: Series[]) => set({ series: next })
@@ -1133,9 +1133,9 @@ export function WidgetPanel({
</div>
) : null}
{widget.type === "slider" && round ? (
{widget.type === "slider" ? (
<div className="grid gap-1.5">
<FieldLabel help="A reading drawn large in the middle of the face — the room's temperature beside the setpoint this sets. The slider's own value then moves to the line at the bottom. Written in the slider's unit and to its step's decimals.">
<FieldLabel help="A reading shown with the value this sets, such as the room's temperature beside its setpoint, in the slider's unit and to its step's decimals. A tile writes it under the value; a round face draws it large in the middle.">
Beside it
</FieldLabel>
<div className="flex items-end gap-1.5">
+54 -6
View File
@@ -19,7 +19,14 @@ import { PlayerWidget } from "./PlayerWidget"
import { usePublish } from "./publish"
import { useUi } from "./ui"
import { COLOR_DTYPES, colorFormatOf } from "./ui/core/color"
import { buttonsOf, config, num, rowsOf, text } from "./ui/core/config"
import {
buttonsOf,
config,
decimalsOf,
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([
@@ -659,6 +666,9 @@ function SliderWidget({ widget, dashboard }: WidgetProps) {
const cfg = config(widget)
const { target, value, send, pulse, locked } = usePublish(widget, dashboard)
const min = num(cfg.min, 0)
// What it sets against: a room's temperature beside its setpoint.
const beside = (cfg.reading ?? {}) as Series
const read = useBoundValue(beside.message || undefined)
if (!target) return <Unbound />
const unit = cfg.unit ? text(cfg.unit) : undefined
@@ -686,6 +696,30 @@ function SliderWidget({ widget, dashboard }: WidgetProps) {
const readout = (
<Readout value={reading} precision={null} unit={unit} size="inline" />
)
// A line under the value, smaller, so the value being set is still the one
// that reads. In the slider's unit and to its step's decimals, as a round
// face writes it: the two are read against each other.
const against = beside.message ? (
<span
data-testid="slider-reading"
className="flex h-4 max-w-full items-center gap-1.5 text-xs"
>
{beside.label ? (
<span className="min-w-0 max-w-24 truncate text-muted-foreground">
{beside.label}
</span>
) : null}
{/* The label gives way first: the number is what the line is for. */}
<span className="shrink-0">
<Readout
value={read?.value}
precision={decimalsOf(num(cfg.step, 1))}
unit={unit}
size="inline"
/>
</span>
</span>
) : null
// A column takes the tile's height and puts the reading under it, which is
// what a fader looks like. A volume control is the case it exists for: it is
@@ -695,7 +729,10 @@ function SliderWidget({ widget, dashboard }: WidgetProps) {
<div className="flex h-full min-h-0 flex-col items-center gap-2">
{pulse}
{control}
<div className="shrink-0">{readout}</div>
<div className="flex max-w-full shrink-0 flex-col items-center">
{readout}
{against}
</div>
</div>
)
}
@@ -706,10 +743,21 @@ function SliderWidget({ widget, dashboard }: WidgetProps) {
<div className="flex items-start gap-3">
{pulse}
<div className="min-w-0 flex-1">{control}</div>
{/* Held to the control's own height, so the value sits on the track's
midline whether or not there is a row of ticks under it. */}
<div className="flex h-[var(--dui-control)] shrink-0 items-center">
{readout}
{/* The reading, if any, under the value: in the ticks' row when there
is one, so it costs the tile no height. Held to half the row, so a
long label in a small tile cannot squeeze the track to nothing. */}
<div
className={cn(
"flex shrink-0 flex-col items-end",
against && "max-w-1/2",
)}
>
{/* Held to the control's own height, so the value sits on the track's
midline whether or not there is a row of ticks under it. */}
<div className="flex h-[var(--dui-control)] items-center">
{readout}
</div>
{against}
</div>
</div>
)
+25
View File
@@ -249,6 +249,21 @@ test.beforeAll(async ({ browser }) => {
unit: " kW",
},
},
{
id: "against",
type: "slider",
title: "Against",
layout: { lg: { x: 6, y: 0, w: 4, h: 2 } },
config: {
target: w("setpoint"),
dtype: "float",
min: 0,
max: 100,
step: 0.5,
unit: " kW",
reading: { message: w("level"), dtype: "float", label: "Level" },
},
},
]
const stacked = await (
await api(page, `/dashboards/${stackName}`, { method: "PUT", data: stack })
@@ -409,6 +424,16 @@ test("rows are drawn in the order they were configured", async ({ page }) => {
await expect(rows.nth(2)).toContainText(/grid/i)
})
test("a slider writes what it sets against under its value", async ({
page,
}) => {
await openPanel(page, stackName)
// In the slider's unit and to its step's decimals, not the reading's own.
await expect(page.getByTestId("slider-reading")).toHaveText(
/^Level\s*80\.0\s*kW$/,
)
})
test("a reading is written to its own precision while it is moving", async ({
page,
}) => {