Slider: the set value to its step's decimals; the reading marked on the track and on the ring; mark geometry in the docs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Melvin Strobl
2026-10-14 16:00:53 +02:00
co-authored by Claude Opus 5.5
parent a6a1f2a2c3
commit 5b2769c141
11 changed files with 141 additions and 14 deletions
@@ -33,6 +33,8 @@ import {
RING_HANDLE,
RING_INSET,
RING_MARK,
RING_READING,
RING_READING_STROKE,
RING_STROKE,
ringArc,
ringPoint,
@@ -326,6 +328,16 @@ export function Ring({
// against each other.
const other = beside.message ? dial.reading(read?.value ?? null) : null
const handle = dial.handle && at !== null ? ringPoint(size, at) : null
// The same reading, placed: a bar across the band where it sits on the
// slider's scale, held to the sweep at either end.
const actual = typeof read?.value === "number" ? dial.at(read.value) : null
const across =
actual === null
? null
: [
ringPoint(size, actual, -RING_READING / 2),
ringPoint(size, actual, RING_READING / 2),
]
const whole = ringArc(size, 0, 1)
const name = widget.title || text(cfg.target, widget.id)
@@ -417,6 +429,17 @@ export function Ring({
/>
)
})}
{/* Under the handle, so where the two meet the value being set wins. */}
{across ? (
<line
x1={across[0].x}
y1={across[0].y}
x2={across[1].x}
y2={across[1].y}
strokeWidth={RING_READING_STROKE}
className="dui-ring-reading"
/>
) : null}
{handle ? (
<circle
cx={handle.x}
@@ -70,6 +70,9 @@ export type SliderProps = {
orientation?: "horizontal" | "vertical"
/** The scale under the track. Horizontal only; a column has no room. */
ticks?: boolean
/** A second value marked on the track, such as the room's temperature
* beside a setpoint. Only shown; null or absent marks nothing. */
mark?: number | null
/** Only the release publishes: a drag would send a value per pixel. */
onCommit: (value: number) => void
}
@@ -185,6 +185,7 @@ export function useSliderDrag({
disabled,
orientation = "horizontal",
ticks = false,
mark = null,
onCommit,
}: {
value: number
@@ -196,6 +197,7 @@ export function useSliderDrag({
disabled?: boolean
orientation?: "horizontal" | "vertical"
ticks?: boolean
mark?: number | null
onCommit: (value: number) => void
}) {
const [draft, setDraft] = useState<number | null>(null)
@@ -245,6 +247,8 @@ export function useSliderDrag({
/** Goes on the slider, whose width is the scale's width. */
trackRef,
fraction: fractionOf(current, min, max),
/** Where the mark sits along the track, 0..1 and held to it; null for none. */
markAt: mark === null ? null : fractionOf(mark, min, max),
inputProps: {
type: "range" as const,
min,
@@ -412,6 +412,46 @@
translate: -50% 50%;
}
/* A second value on the track, such as what a setpoint is set against: a bar
across the track, on the handle's own travel so the two line up at the same
value, and longer than the handle so it still shows when they meet. Under
the input, so the hit area is the input's alone. */
.dui-slider-mark {
position: absolute;
background: var(--foreground);
pointer-events: none;
}
.dui-slider[data-orientation="horizontal"] .dui-slider-mark {
top: 50%;
left: calc(
var(--dui-thumb) /
2 +
var(--dui-mark, 0) *
(100% - var(--dui-thumb))
);
width: 2px;
height: calc(var(--dui-thumb) + 0.5rem);
translate: -50% -50%;
}
.dui-slider[data-orientation="vertical"] .dui-slider-mark {
left: 50%;
bottom: calc(
var(--dui-thumb) /
2 +
var(--dui-mark, 0) *
(100% - var(--dui-thumb))
);
width: calc(var(--dui-thumb) + 0.5rem);
height: 2px;
translate: -50% 50%;
}
.dui-slider-input:disabled ~ .dui-slider-mark {
opacity: 0.45;
}
.dui-slider-input:focus-visible ~ .dui-slider-thumb {
outline: 2px solid var(--ring);
outline-offset: 2px;
@@ -649,6 +689,11 @@
fill: var(--muted-foreground);
}
/* What a slider reads beside its value, as the tile's track marks it. */
.dui-ring-reading {
stroke: var(--foreground);
}
/* Page dots, one per face, in the ring's gap. */
.dui-dot-mark {
border-radius: 50%;
@@ -24,6 +24,11 @@ export const RING_STROKE = 16
export const RING_HANDLE = 28
/** A dropdown's option marks, as a diameter. */
export const RING_MARK = 6
/** What a slider reads beside its value, marked on the ring: a bar across the
* band this long and this wide, longer than the handle so it still shows
* where the two meet. */
export const RING_READING = RING_HANDLE + 8
export const RING_READING_STROKE = 3
/** How long the ring waits after the last notch before it publishes. */
export const RING_SETTLE_MS = 300
/** The gap's line — a slider's title and value, when what it reads is in
@@ -93,10 +98,11 @@ export const ringArc = (size: number, from: number, to: number) =>
size / 2,
)
/** A point on the ring's centre line, `at` a fraction along the sweep. */
export function ringPoint(size: number, at: number) {
/** A point on the ring's centre line, `at` a fraction along the sweep, or
* `out` beyond it (inward when negative). */
export function ringPoint(size: number, at: number, out = 0) {
const radians = ((RING_START + at * RING_SWEEP) * Math.PI) / 180
const radius = ringRadius(size)
const radius = ringRadius(size) + out
return {
x: size / 2 + radius * Math.cos(radians),
y: size / 2 + radius * Math.sin(radians),
@@ -65,7 +65,7 @@ export function Switch(props: SwitchProps) {
}
export function Slider(props: SliderProps) {
const { fraction, inputProps, marks, trackRef } = useSliderDrag(props)
const { fraction, inputProps, marks, markAt, trackRef } = useSliderDrag(props)
const orientation = props.orientation ?? "horizontal"
return (
<div
@@ -85,6 +85,15 @@ export function Slider(props: SliderProps) {
the keyboard, the pointer and every `aria-`, and owes the look
nothing. */}
<input {...inputProps} className="dui-slider-input" />
{/* Under the handle, so where the two meet the value being set wins;
after the input only for its `:disabled`, as it takes no pointer. */}
{markAt === null ? null : (
<span
className="dui-slider-mark"
aria-hidden
style={{ "--dui-mark": markAt } as React.CSSProperties}
/>
)}
<span className="dui-slider-thumb" aria-hidden />
</div>
{marks.length > 0 ? (
@@ -76,7 +76,7 @@ export function Switch(props: SwitchProps) {
}
export function Slider(props: SliderProps) {
const { fraction, inputProps, marks, trackRef } = useSliderDrag(props)
const { fraction, inputProps, marks, markAt, trackRef } = useSliderDrag(props)
const orientation = props.orientation ?? "horizontal"
return (
<div
@@ -96,6 +96,15 @@ export function Slider(props: SliderProps) {
the keyboard, the pointer and every `aria-`, and owes the look
nothing. */}
<input {...inputProps} className="dui-slider-input" />
{/* Under the handle, so where the two meet the value being set wins;
after the input only for its `:disabled`, as it takes no pointer. */}
{markAt === null ? null : (
<span
className="dui-slider-mark"
aria-hidden
style={{ "--dui-mark": markAt } as React.CSSProperties}
/>
)}
<span className="dui-slider-thumb" aria-hidden />
</div>
{marks.length > 0 ? (
@@ -105,7 +105,7 @@ export function Switch(props: SwitchProps) {
}
export function Slider(props: SliderProps) {
const { fraction, inputProps, marks, trackRef } = useSliderDrag(props)
const { fraction, inputProps, marks, markAt, trackRef } = useSliderDrag(props)
const orientation = props.orientation ?? "horizontal"
return (
<div
@@ -125,6 +125,15 @@ export function Slider(props: SliderProps) {
the keyboard, the pointer and every `aria-`, and owes the look
nothing. */}
<input {...inputProps} className="dui-slider-input" />
{/* Under the handle, so where the two meet the value being set wins;
after the input only for its `:disabled`, as it takes no pointer. */}
{markAt === null ? null : (
<span
className="dui-slider-mark"
aria-hidden
style={{ "--dui-mark": markAt } as React.CSSProperties}
/>
)}
<span className="dui-slider-thumb" aria-hidden />
</div>
{marks.length > 0 ? (
@@ -674,6 +674,9 @@ function SliderWidget({ widget, dashboard }: WidgetProps) {
const unit = cfg.unit ? text(cfg.unit) : undefined
const vertical = cfg.orientation === "vertical"
const reading = typeof value === "number" ? value : min
// Both the value and what it is set against are written to the step's
// decimals, as a round face writes them, so the two always match.
const digits = decimalsOf(num(cfg.step, 1))
const control = (
<Slider
value={reading}
@@ -688,17 +691,19 @@ function SliderWidget({ widget, dashboard }: WidgetProps) {
// that row is the difference between fitting and being cut off. A
// column has no room for them at all.
ticks={cfg.ticks !== false}
mark={typeof read?.value === "number" ? read.value : null}
label={widget.title || target}
disabled={locked}
onCommit={send}
/>
)
const readout = (
<Readout value={reading} precision={null} unit={unit} size="inline" />
<Readout value={reading} precision={digits} 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.
// face writes it: the two are read against each other. Its mark on the
// track is the same reading, placed.
const against = beside.message ? (
<span
data-testid="slider-reading"
@@ -713,7 +718,7 @@ function SliderWidget({ widget, dashboard }: WidgetProps) {
<span className="shrink-0">
<Readout
value={read?.value}
precision={decimalsOf(num(cfg.step, 1))}
precision={digits}
unit={unit}
size="inline"
/>