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:
co-authored by
Claude Opus 5.5
parent
a6a1f2a2c3
commit
5b2769c141
@@ -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"
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user