Marks as dots inside the track: ticks, a selector's options and a slider's reading on the track's midline and the ring's centre line, coloured by the fill or the handle over them; the selector ring fills to its option

This commit is contained in:
Melvin Strobl
2026-10-14 16:42:56 +02:00
parent 42c752c94e
commit bae4b15505
12 changed files with 387 additions and 112 deletions
+34 -14
View File
@@ -75,12 +75,10 @@ stands in for the value until one arrives. On a round face it takes the middle
slider's value.
It is also marked where it sits on the slider's scale, as a thermostat marks
the room's temperature: the handle is what is set, the mark what is. The mark
is a bar across the track or the ring, in the text colour, at the reading's
fraction of the scale, (reading − min) / (max − min) held to 0–1, so a reading
outside the range sits at that end. It is drawn over the track and its fill
and under the handle, and is longer than the handle, so it still shows where
the two meet. It takes no pointer, and there is none until a value arrives.
the room's temperature: the handle is what is set, the dot what is. The dot is
at the reading's fraction of the scale, (reading − min) / (max − min) held to
0–1, so a reading outside the range sits at that end. It takes no pointer, and
there is none until a value arrives. See [Dots on the track](#dots-on-the-track).
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
@@ -92,9 +90,31 @@ panel), centred on a line as tall as the control (32; 48 touch-friendly).
| 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 |
| Mark (row) | a bar 2 wide and the handle's diameter + 8 long (16 + 8 = 24; 24 + 8 = 32 touch-friendly), centred on the track's midline (the track is 8 thick). Its centre sits on the handle's travel: handle ÷ 2 + fraction × (track length − handle) from the track's left end, so mark and handle line up at the same value |
| Mark (fader) | the same turned: 2 high, handle + 8 wide, centred on the track, measured up from its bottom end |
| Locked | the mark at 0.45 opacity, as the handle |
### Dots on the track
A slider's ticks, a selector's options on a round face and a slider's reading
are dots inside the track, on its midline — on a round face the ring's centre
line, radius c − 20. Each is drawn in the colour that reads on what covers it:
the track, the value's fill, or the handle.
| Dot | Size, tile / ring | On the track | On the fill | On the handle |
|---|---|---|---|---|
| Stop: a tick, an option | half the track's thickness (4 on the 8 track) / 6 | `--muted-foreground` | `--primary-foreground` at 70 % | hidden under it |
| Reading | three quarters of the track's thickness (6) / 10 | `--foreground` | `--primary-foreground` | Fluksio `--foreground`, Material `--primary-foreground`, Glass black at 75 % |
The reading is the larger dot in the stronger colour, and it is the one dot
that shows on the handle, so a reading at the setpoint is still visible.
| Part | Value |
|---|---|
| Place (tile) | the dot's centre on the handle's travel: handle ÷ 2 + fraction × (track length − handle) from the track's left end (from its bottom end on a fader), so a dot and the handle line up at the same value |
| Place (ring) | the dot's centre at the fraction of the sweep, as the handle |
| Ticks | with `ticks` on (the default), n intervals of the scale — the steps when there are 5 or fewer, else the first of 4, 3 or 2 that divides the steps evenly, else 4 — and a dot at each i/n, both ends included. A row keeps its labels under the track; a fader has none |
| Options | a dot per option at i/(n − 1) of the sweep (one option: the middle). The selector's ring has a value arc from the start to the selected option, so the options up to it are on the fill |
| Colour change | where the fill or the handle covers a dot, that part of it takes the colour for the fill or the handle, so a dot changes colour as the fill's end or the handle passes over it, and follows them while they move. A device may instead switch a whole dot when the fill's end or the handle's centre passes the dot's centre |
| Order | track, dots, fill, the dots again over the fill, handle, the reading again over the handle |
| Locked | in a tile, the dots at 0.45 opacity, as the handle; on the ring, the whole ring at 0.45 |
## Charts
@@ -260,9 +280,9 @@ look's muted text colour.
| Centre reading | centred at (c, c), line height 1, its unit after it at 0.4 em, muted. Size: 72, or 240 ÷ (0.6 × the characters written) rounded down where that is smaller, never below 24 |
| Ring band | 40 along the edge |
| Ring arc | start 120°, sweep 300° (ends at 60°), centre-line radius c − 20, stroke 16 with round caps |
| Slider | track over the sweep, value arc from the start to the value (none at the minimum), handle 28 across; the value as the centre reading |
| Slider with a reading | the reading as the centre reading; the gap line holds the title (muted) and the slider's value. The reading's mark: a bar 36 long and 3 wide, square-ended, in the text colour, along the radius at the reading's fraction of the sweep (the same angle the handle has at that value), centred on the ring's centre line, so from radius c − 38 to c − 2; over the track and value arc, under the handle |
| Selector | a 6 mark per option at i/(n−1) of the sweep (one option: the middle), the selected one under the handle; its label as the centre reading, at most 36 |
| Slider | track over the sweep, value arc from the start to the value (none at the minimum), handle 28 across, its ticks as [dots](#dots-on-the-track); the value as the centre reading |
| Slider with a reading | the reading as the centre reading; the gap line holds the title (muted) and the slider's value. The reading as a dot 10 across on the centre line at its fraction of the sweep ([Dots on the track](#dots-on-the-track)) |
| Selector | a value arc from the start to the selected option (none at the first), a dot 6 across per option at i/(n−1) of the sweep (one option: the middle), the selected one under the handle; its label as the centre reading, at most 36 |
| Colour | hue h at h/360 of the sweep in 150 strokes of 2°, red at both ends; the handle filled with the colour; a swatch 96 across in the middle, with a hairline border |
| Gauge | track and value arc, no handle; the value as the centre reading |
| Gap line | centred at y = S − 28, 13, at most 160 wide |
@@ -311,10 +331,10 @@ dashboard's look's.
| Ring value set by a notch, a key or the engine | the value arc's end, the handle (slider, selector, colour) | from where it is drawn to the new fraction of the sweep | spring | each notch retargets from where it is, at the speed it has |
| Ring value set by a drag | the same | follows the pointer exactly | none | — |
| Hue across red | the colour handle | a change of more than half the sweep jumps to the other end | none | — |
| Slider's reading on the ring, gauge on the ring | the reading's mark; the value arc | from where it is drawn to the new fraction | spring | retargets |
| Slider's reading on the ring, gauge on the ring | the reading's dot; the value arc | from where it is drawn to the new fraction; a dot's colour follows the fill and the handle as they pass | spring | retargets |
| Numbers on the ring | centre reading, gap line | switch at once, with the notch | none | — |
| Value face, Value and Gauge tiles | the number | counts to the new value, written to its precision | spring | retargets |
| Slider tile set elsewhere | the fill's end, the handle, the reading's mark | from where it is drawn to the new fraction | spring | a drag or a key moves it at once |
| Slider tile set elsewhere | the fill's end, the handle, the reading's dot | from where it is drawn to the new fraction | spring | a drag or a key moves it at once |
| Gauge and Bar tiles | the value arc, each bar's fill | from where it is drawn to the new fraction | spring | retargets |
| Dashboard change (rectangular panel) | every tile | the old dashboard goes at once; each new tile opacity 0 → 1, y +8 → 0 | spring | — |
| Rail | the current entry's marker | slides to the new entry | spring | retargets |
+112 -41
View File
@@ -1,5 +1,5 @@
import { motion, useTransform } from "motion/react"
import { useEffect, useRef } from "react"
import { type MotionValue, motion, useTransform } from "motion/react"
import { useEffect, useId, useRef } from "react"
import type { WidgetDef } from "@/client"
import { cn } from "@/lib/utils"
@@ -21,6 +21,7 @@ import {
showTitle,
text,
} from "./ui/core/config"
import { tickIntervals } from "./ui/core/controls"
import {
CENTRE_LABEL,
CENTRE_SWATCH,
@@ -34,7 +35,6 @@ import {
RING_INSET,
RING_MARK,
RING_READING,
RING_READING_STROKE,
RING_STROKE,
ringArc,
ringPoint,
@@ -78,10 +78,13 @@ type Dial = {
/** Written out; empty for a colour, whose handle is the reading. */
reading: (value: unknown) => string
unit?: string
/** A value arc from the start to the reading: a slider and a gauge. */
/** A value arc from the start to the reading: a slider, a selector and a
* gauge. */
fill?: boolean
handle?: boolean
marks?: number[]
/** Dots along the sweep, as fractions of it: a selector's options, a
* slider's ticks. */
stops?: number[]
hue?: boolean
}
@@ -109,8 +112,9 @@ function dialOf(widget: WidgetDef, live: unknown): Dial {
const option = options[index as number]
return option ? (option.label ?? text(option.value)) : "—"
},
fill: true,
handle: true,
marks: options.map((_, index) => at(index) ?? 0.5),
stops: options.map((_, index) => at(index) ?? 0.5),
}
}
@@ -159,6 +163,8 @@ function dialOf(widget: WidgetDef, live: unknown): Dial {
const digits = (String(step).split(".")[1] ?? "").length
const fit = (value: number) =>
Number(Math.min(max, Math.max(min, value)).toFixed(digits))
// The tile's scale, as dots: the same intervals its labels are cut into.
const intervals = tickIntervals((max - min) / step)
return {
value: typeof live === "number" ? live : min,
at: (value) => fractionOf(value as number, min, max),
@@ -170,6 +176,10 @@ function dialOf(widget: WidgetDef, live: unknown): Dial {
unit,
fill: true,
handle: true,
stops:
cfg.ticks !== false && max > min
? Array.from({ length: intervals + 1 }, (_, index) => index / intervals)
: [],
}
}
@@ -333,14 +343,16 @@ export function Ring({
// Written to the slider's own decimals and in its unit: the two are read
// against each other.
const other = beside.message ? dial.reading(read?.value ?? null) : 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.
// The same reading, placed: a dot 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 = useTransform(useEased(actual ?? 0), (fraction) => {
const from = ringPoint(size, fraction, -RING_READING / 2)
const to = ringPoint(size, fraction, RING_READING / 2)
return `M${from.x} ${from.y}L${to.x} ${to.y}`
})
const readAt = useEased(actual ?? 0)
const readX = useTransform(readAt, (fraction) => ringPoint(size, fraction).x)
const readY = useTransform(readAt, (fraction) => ringPoint(size, fraction).y)
const readDot = actual === null ? null : { x: readX, y: readY }
// What the dots are drawn under: the value arc and the handle, which
// repaint whatever part of a dot they cover (`RingDots`).
const id = useId().replace(/[^\w-]/g, "")
const whole = ringArc(size, 0, 1)
const name = widget.title || text(cfg.target, widget.id)
@@ -397,38 +409,23 @@ export function Ring({
className="dui-ring-track"
/>
)}
<RingDots size={size} stops={dial.stops} reading={readDot} />
{/* Revealed rather than re-pathed, as the gauge tile's arc is: `d`
does not ease, and `pathLength` makes the reveal the fraction. */}
{dial.fill ? (
<motion.path
d={whole}
fill="none"
strokeWidth={RING_STROKE}
strokeLinecap="round"
className="dui-ring-fill"
style={{ pathLength: along, opacity: shown }}
/>
) : null}
{dial.marks?.map((mark, index) => {
const point = ringPoint(size, mark)
return (
<circle
// Two options may share a label; position is the identity.
key={`mark-${index}`}
cx={point.x}
cy={point.y}
r={RING_MARK / 2}
className="dui-ring-mark"
<>
<motion.path
d={whole}
fill="none"
strokeWidth={RING_STROKE}
strokeLinecap="round"
className="dui-ring-fill"
style={{ pathLength: along, opacity: shown }}
/>
)
})}
{/* Under the handle, so where the two meet the value being set wins. */}
{actual !== null ? (
<motion.path
d={across}
strokeWidth={RING_READING_STROKE}
className="dui-ring-reading"
/>
<g className="dui-ring-reached" mask={`url(#${id}-fill)`}>
<RingDots size={size} stops={dial.stops} reading={readDot} />
</g>
</>
) : null}
{dial.handle && at !== null ? (
<motion.circle
@@ -439,7 +436,36 @@ export function Ring({
style={dial.hue ? { fill: cssOf(current as Triple) } : undefined}
/>
) : null}
{readDot && dial.handle && at !== null ? (
<g className="dui-ring-on-handle" clipPath={`url(#${id}-handle)`}>
<RingDots size={size} reading={readDot} />
</g>
) : null}
</g>
<defs>
{/* The value arc again, as a stencil: a dot it has reached is
drawn in the colour that reads on it, to the pixel it covers. */}
<mask
id={`${id}-fill`}
maskUnits="userSpaceOnUse"
x={0}
y={0}
width={size}
height={size}
>
<motion.path
d={whole}
fill="none"
stroke="#fff"
strokeWidth={RING_STROKE}
strokeLinecap="round"
style={{ pathLength: along, opacity: shown }}
/>
</mask>
<clipPath id={`${id}-handle`}>
<motion.circle cx={handleX} cy={handleY} r={RING_HANDLE / 2} />
</clipPath>
</defs>
{turns || editing ? (
<path
d={whole}
@@ -527,6 +553,51 @@ export function Ring({
)
}
/**
* The ring's dots: its stops, and what a slider reads beside its value.
*
* Drawn on the ring's centre line, inside the band, and drawn up to three
* times: on the track, again through the value arc as a stencil, and the
* reading once more through the handle. Each copy is in the colour that reads
* on what it sits on, so a dot changes colour exactly where the arc or the
* handle passes over it — and the reading still shows on the handle.
*/
function RingDots({
size,
stops = [],
reading,
}: {
size: number
stops?: number[]
reading: { x: MotionValue<number>; y: MotionValue<number> } | null
}) {
return (
<>
{stops.map((stop, index) => {
const point = ringPoint(size, stop)
return (
<circle
// Two options may share a label; position is the identity.
key={`stop-${index}`}
cx={point.x}
cy={point.y}
r={RING_MARK / 2}
className="dui-ring-stop"
/>
)
})}
{reading ? (
<motion.circle
cx={reading.x}
cy={reading.y}
r={RING_READING / 2}
className="dui-ring-reading"
/>
) : null}
</>
)
}
/**
* The colour ring's track: every hue in order along the sweep, red at both
* ends. Drawn as short strokes — an SVG stroke takes no conic gradient — each
@@ -254,6 +254,11 @@ export function useSliderDrag({
fraction,
/** Where the mark sits along the track, 0..1 and held to it; null for none. */
markAt: mark === null ? null : markAt,
/** The scale's ticks as dots in the track, 0..1, in either orientation. */
stops:
ticks && span > 0
? Array.from({ length: intervals + 1 }, (_, index) => index / intervals)
: [],
inputProps: {
type: "range" as const,
min,
@@ -412,43 +412,162 @@
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 {
/* The colours of the dots inside a slider's track and along the ring, by what
each copy sits on: the track, the fill, the handle. A look whose handle is
not the page colour restates the last. */
.dui-slider,
.dui-ring {
--dui-stop: var(--muted-foreground);
--dui-stop-reached: color-mix(
in srgb,
var(--primary-foreground) 70%,
transparent
);
--dui-reading: var(--foreground);
--dui-reading-reached: var(--primary-foreground);
--dui-reading-on-handle: var(--foreground);
}
/*
* Dots inside the track, on its midline: a tick's stop, and what a slider reads
* beside its value — such as the room's temperature beside a setpoint — as a
* larger one in a stronger colour. Both sit on the handle's own travel, so a
* dot lines up with the handle at the same value.
*
* Drawn up to three times, each copy in the colour that reads on what it sits
* on: on the track; again over the fill, cut to the fill's extent; and the
* reading once more over the handle, cut to the handle. So a dot changes
* colour exactly where the fill or the handle passes it, as the ring's do.
* After the input only for its `:disabled`, as none of them takes a pointer.
*/
.dui-slider-dots,
.dui-slider-reached,
.dui-slider-on-thumb {
position: absolute;
background: var(--foreground);
pointer-events: none;
}
.dui-slider[data-orientation="horizontal"] .dui-slider-mark {
.dui-slider-dots {
overflow: hidden;
border-radius: 9999px;
}
.dui-slider[data-orientation="horizontal"] .dui-slider-dots {
left: 0;
right: 0;
top: 50%;
height: var(--dui-track-size, 0.5rem);
translate: 0 -50%;
}
.dui-slider[data-orientation="vertical"] .dui-slider-dots {
top: 0;
bottom: 0;
left: 50%;
width: var(--dui-track-size, 0.5rem);
translate: -50% 0;
}
.dui-slider-reached {
inset: 0;
}
.dui-slider[data-orientation="horizontal"] .dui-slider-reached {
clip-path: inset(0 calc((1 - var(--dui-fraction, 0)) * 100%) 0 0);
}
.dui-slider[data-orientation="vertical"] .dui-slider-reached {
clip-path: inset(calc((1 - var(--dui-fraction, 0)) * 100%) 0 0 0);
}
/* Over the handle, which comes before it. */
.dui-slider-on-thumb {
inset: 0;
z-index: 1;
}
.dui-slider[data-orientation="horizontal"] .dui-slider-on-thumb {
clip-path: circle(
calc(var(--dui-thumb) / 2) at
calc(
var(--dui-thumb) /
2 +
var(--dui-fraction, 0) *
(100% - var(--dui-thumb))
)
50%
);
}
.dui-slider[data-orientation="vertical"] .dui-slider-on-thumb {
clip-path: circle(
calc(var(--dui-thumb) / 2) at 50%
calc(
100% -
var(--dui-thumb) /
2 -
var(--dui-fraction, 0) *
(100% - var(--dui-thumb))
)
);
}
.dui-slider-stop,
.dui-slider-reading {
position: absolute;
border-radius: 50%;
}
.dui-slider-stop {
width: calc(var(--dui-track-size, 0.5rem) / 2);
height: calc(var(--dui-track-size, 0.5rem) / 2);
background: var(--dui-stop);
}
.dui-slider-reading {
width: calc(var(--dui-track-size, 0.5rem) * 0.75);
height: calc(var(--dui-track-size, 0.5rem) * 0.75);
background: var(--dui-reading);
}
.dui-slider-reached .dui-slider-stop {
background: var(--dui-stop-reached);
}
.dui-slider-reached .dui-slider-reading {
background: var(--dui-reading-reached);
}
.dui-slider-on-thumb .dui-slider-reading {
background: var(--dui-reading-on-handle);
}
.dui-slider[data-orientation="horizontal"]
:is(.dui-slider-stop, .dui-slider-reading) {
top: 50%;
left: calc(
var(--dui-thumb) /
2 +
var(--dui-mark, 0) *
var(--dui-at, 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 {
.dui-slider[data-orientation="vertical"]
:is(.dui-slider-stop, .dui-slider-reading) {
left: 50%;
bottom: calc(
var(--dui-thumb) /
2 +
var(--dui-mark, 0) *
var(--dui-at, 0) *
(100% - var(--dui-thumb))
);
width: calc(var(--dui-thumb) + 0.5rem);
height: 2px;
translate: -50% 50%;
}
.dui-slider-input:disabled ~ .dui-slider-mark {
.dui-slider-input:disabled ~ .dui-slider-dots,
.dui-slider-input:disabled ~ .dui-slider-on-thumb {
opacity: 0.45;
}
@@ -685,13 +804,25 @@
opacity: 1;
}
.dui-ring-mark {
fill: var(--muted-foreground);
/* The ring's dots, in the colours the slider's track uses for its own. */
.dui-ring-stop {
fill: var(--dui-stop);
}
/* What a slider reads beside its value, as the tile's track marks it. */
.dui-ring-reading {
stroke: var(--foreground);
fill: var(--dui-reading);
}
.dui-ring-reached .dui-ring-stop {
fill: var(--dui-stop-reached);
}
.dui-ring-reached .dui-ring-reading {
fill: var(--dui-reading-reached);
}
.dui-ring-on-handle .dui-ring-reading {
fill: var(--dui-reading-on-handle);
}
/* Page dots, one per face, in the ring's gap. The current one changes over
@@ -0,0 +1,55 @@
/**
* What a slider marks inside its track: a dot per tick, and what it reads
* beside its value as a larger one.
*
* The same markup in every look — the dots are geometry, and `core.css` draws
* them in the look's tokens — so it lives here rather than three times over.
* The copies over the fill and over the handle are what let a dot change
* colour exactly where either passes it.
*/
import { type MotionStyle, type MotionValue, motion } from "motion/react"
export function SliderMarks({
stops,
mark,
}: {
/** Where each tick sits along the track, 0..1. */
stops: number[]
/** Where the reading sits, 0..1; null for none. */
mark: MotionValue<number> | null
}) {
if (stops.length === 0 && mark === null) return null
const dots = (
<>
{stops.map((at) => (
<span
key={at}
className="dui-slider-stop"
style={{ "--dui-at": at } as React.CSSProperties}
/>
))}
{mark ? (
<motion.span
className="dui-slider-reading"
style={{ "--dui-at": mark } as MotionStyle}
/>
) : null}
</>
)
return (
<>
<span className="dui-slider-dots" aria-hidden>
{dots}
<span className="dui-slider-reached">{dots}</span>
</span>
{mark ? (
<span className="dui-slider-on-thumb" aria-hidden>
<motion.span
className="dui-slider-reading"
style={{ "--dui-at": mark } as MotionStyle}
/>
</span>
) : null}
</>
)
}
@@ -22,13 +22,12 @@ export const RING_SWEEP = 300
export const RING_STROKE = 16
/** The handle's diameter. */
export const RING_HANDLE = 28
/** A dropdown's option marks, as a diameter. */
/** A stop on the ring — a selector's option, a slider's tick — as a dot this
* wide on the ring's centre line. */
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
/** What a slider reads beside its value, as a larger dot on the centre line:
* told from a stop by its size and its stronger colour. */
export const RING_READING = 10
/** 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
@@ -26,6 +26,7 @@ import type {
} from "../core/contract"
import { useSegmented, useSliderDrag, useSwitch } from "../core/controls"
import { useLook } from "../core/look"
import { SliderMarks } from "../core/marks"
import { LOOK } from "../core/motion"
export function Button({
@@ -65,7 +66,8 @@ export function Switch(props: SwitchProps) {
}
export function Slider(props: SliderProps) {
const { fraction, inputProps, marks, markAt, trackRef } = useSliderDrag(props)
const { fraction, inputProps, marks, markAt, stops, trackRef } =
useSliderDrag(props)
const orientation = props.orientation ?? "horizontal"
return (
<motion.div
@@ -85,15 +87,7 @@ 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 : (
<motion.span
className="dui-slider-mark"
aria-hidden
style={{ "--dui-mark": markAt } as MotionStyle}
/>
)}
<SliderMarks stops={stops} mark={markAt} />
<span className="dui-slider-thumb" aria-hidden />
</div>
{marks.length > 0 ? (
@@ -26,6 +26,7 @@ import {
useSwitch,
} from "../core/controls"
import { useLook } from "../core/look"
import { SliderMarks } from "../core/marks"
import { LOOK } from "../core/motion"
export function Button({
@@ -76,7 +77,8 @@ export function Switch(props: SwitchProps) {
}
export function Slider(props: SliderProps) {
const { fraction, inputProps, marks, markAt, trackRef } = useSliderDrag(props)
const { fraction, inputProps, marks, markAt, stops, trackRef } =
useSliderDrag(props)
const orientation = props.orientation ?? "horizontal"
return (
<motion.div
@@ -96,15 +98,7 @@ 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 : (
<motion.span
className="dui-slider-mark"
aria-hidden
style={{ "--dui-mark": markAt } as MotionStyle}
/>
)}
<SliderMarks stops={stops} mark={markAt} />
<span className="dui-slider-thumb" aria-hidden />
</div>
{marks.length > 0 ? (
@@ -435,6 +435,11 @@
filter: drop-shadow(0 2px 8px rgb(0 0 0 / 0.35));
}
/* A reading's dot on the handle, which is white here in either theme. */
[data-look="glass"] :is(.dui-ring, .dui-slider) {
--dui-reading-on-handle: rgb(0 0 0 / 0.75);
}
[data-look="glass"] .dui-ring-swatch {
stroke: rgb(255 255 255 / 0.85);
stroke-width: 2px;
@@ -28,6 +28,7 @@ import {
useSwitch,
} from "../core/controls"
import { useLook } from "../core/look"
import { SliderMarks } from "../core/marks"
import { LOOK } from "../core/motion"
/** The state layer plus whatever presses are still fading. */
@@ -105,7 +106,8 @@ export function Switch(props: SwitchProps) {
}
export function Slider(props: SliderProps) {
const { fraction, inputProps, marks, markAt, trackRef } = useSliderDrag(props)
const { fraction, inputProps, marks, markAt, stops, trackRef } =
useSliderDrag(props)
const orientation = props.orientation ?? "horizontal"
return (
<motion.div
@@ -125,15 +127,7 @@ 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 : (
<motion.span
className="dui-slider-mark"
aria-hidden
style={{ "--dui-mark": markAt } as MotionStyle}
/>
)}
<SliderMarks stops={stops} mark={markAt} />
<span className="dui-slider-thumb" aria-hidden />
</div>
{marks.length > 0 ? (
@@ -334,6 +334,11 @@
fill: var(--primary);
}
/* A reading's dot on the handle, which is primary here. */
[data-look="material"] :is(.dui-ring, .dui-slider) {
--dui-reading-on-handle: var(--primary-foreground);
}
[data-look="material"] .dui-ring-swatch {
stroke: var(--card);
stroke-width: 3px;
+4 -2
View File
@@ -432,8 +432,10 @@ test("a slider writes what it sets against under its value", async ({
await expect(page.getByTestId("slider-reading")).toHaveText(
/^Level\s*80\.0\s*kW$/,
)
// And placed on the track, as a mark beside the handle.
await expect(page.locator(".dui-slider-mark")).toBeVisible()
// And placed on the track, as a dot inside it.
await expect(
page.locator(".dui-slider-dots > .dui-slider-reading"),
).toBeVisible()
})
test("a reading is written to its own precision while it is moving", async ({