@charcuterie/ui 3.8.0 → 3.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,412 @@
1
+ import { useUniqueId } from "@charcuterie/logic"
2
+ import type { IntentName } from "@charcuterie/tokens"
3
+ import type {
4
+ ComponentPropsWithRef,
5
+ KeyboardEvent,
6
+ PointerEvent,
7
+ ReactNode,
8
+ } from "react"
9
+ import { useRef, useState } from "react"
10
+
11
+ import {
12
+ FOCUS_RING_CLASS,
13
+ INTENT_SOLID_FILL_CLASS,
14
+ } from "../intentStyles.ts"
15
+ import { toClassName } from "../toClassName.ts"
16
+ import { VisuallyHidden } from "../VisuallyHidden/VisuallyHidden.tsx"
17
+ import {
18
+ clampToRange,
19
+ fromFraction,
20
+ snapToStep,
21
+ toPercent,
22
+ } from "./sliderValue.ts"
23
+
24
+ export type SliderSize = "lg" | "md" | "sm"
25
+
26
+ export type SliderProps = Omit<
27
+ ComponentPropsWithRef<"div">,
28
+ "children" | "defaultValue" | "onChange"
29
+ > & {
30
+ intent?: IntentName
31
+ isDisabled?: boolean
32
+ isLabelVisible?: boolean
33
+ /**
34
+ * Read-only in the `aria-readonly` sense: focusable, full contrast,
35
+ * announced as unchangeable. A media scrubber for a stream with no
36
+ * seekable range is this, not `isDisabled` — the position is still
37
+ * worth reading.
38
+ */
39
+ isReadOnly?: boolean
40
+ isValueShown?: boolean
41
+ /** Required. A slider with no name is an unlabelled number. */
42
+ label: string
43
+ /**
44
+ * PageUp / PageDown. Defaults to ten steps, which is the APG's
45
+ * suggestion and the only sensible answer that does not need the
46
+ * caller to know the range.
47
+ */
48
+ largeStep?: number
49
+ max?: number
50
+ min?: number
51
+ /**
52
+ * Fires on every movement — each arrow key, and each pointer sample
53
+ * while dragging. Use it to paint. See `onChangeEnd` for committing.
54
+ */
55
+ onChange?: (value: number) => void
56
+ /**
57
+ * Fires once, when the interaction ends: pointer release, or key up.
58
+ *
59
+ * This exists because the expensive consumer of a slider is almost
60
+ * never the paint. A seek scrubber that issued its network call from
61
+ * `onChange` would send one request per pointer sample and arrive at
62
+ * the right offset last, after a queue of stale ones.
63
+ */
64
+ onChangeEnd?: (value: number) => void
65
+ size?: SliderSize
66
+ step?: number
67
+ value?: number
68
+ /**
69
+ * How the value reads aloud and, when `isValueShown`, on screen.
70
+ * Without it a scrubber announces "1274", which is a number of
71
+ * seconds nobody has.
72
+ */
73
+ valueFormat?: (value: number) => string
74
+ }
75
+
76
+ const TRACK_SIZE_CLASS: Record<SliderSize, string> = {
77
+ sm: "h-1",
78
+ md: "h-2",
79
+ lg: "h-3",
80
+ }
81
+
82
+ const THUMB_SIZE_CLASS: Record<SliderSize, string> = {
83
+ sm: "size-3",
84
+ md: "size-4",
85
+ lg: "size-5",
86
+ }
87
+
88
+ /** Half of each `THUMB_SIZE_CLASS`, as the inline-start pull-back. */
89
+ const THUMB_OFFSET: Record<SliderSize, string> = {
90
+ sm: "-0.375rem",
91
+ md: "-0.5rem",
92
+ lg: "-0.625rem",
93
+ }
94
+
95
+ /**
96
+ * A single-value range control: the thing `<input type="range">` is,
97
+ * without the OS painting it.
98
+ *
99
+ * The fleet had no way to ask for a value along a range, so QueuePilot's
100
+ * Now-playing bar could not have a scrubber
101
+ * ([decision](../../../../docs/decisions/2026-08-22-the-slider-is-a-div-with-role-slider-not-an-input-range.md)).
102
+ * `ProgressBar` looks like the answer and is not: it is output, it takes
103
+ * no input, and `role="progressbar"` tells assistive technology exactly
104
+ * that.
105
+ *
106
+ * ## Why a `div` and not `<input type="range">`
107
+ *
108
+ * The same reason `Select` is deprecated. A range input's thumb and
109
+ * track are UA pseudo-elements — `::-webkit-slider-thumb`,
110
+ * `::-moz-range-track` — and they are not one surface: the two engines
111
+ * disagree about which of them the track's fill even belongs to, so a
112
+ * token-styled range is two stylesheets that drift. Windows paints its
113
+ * own. `role="slider"` on a focusable `div` is the APG's own pattern,
114
+ * gets the same AT support, and every pixel of it is ours.
115
+ *
116
+ * ## Drag does not commit
117
+ *
118
+ * `onChange` fires per movement, `onChangeEnd` once on release. Both are
119
+ * optional and the component is fully controlled through `value`, so a
120
+ * caller that wants the simple thing passes `onChange` alone and a
121
+ * caller with a network call behind it splits them.
122
+ *
123
+ * While dragging, the component holds the value locally and paints from
124
+ * that rather than from the `value` prop. Without it a controlled
125
+ * scrubber whose owner only commits `onChangeEnd` would render the thumb
126
+ * pinned under the finger at the old position — the drag would look
127
+ * broken while behaving correctly.
128
+ */
129
+ export const Slider = ({
130
+ className,
131
+ intent = "accent",
132
+ isDisabled = false,
133
+ isLabelVisible = false,
134
+ isReadOnly = false,
135
+ isValueShown = false,
136
+ label,
137
+ largeStep,
138
+ max = 100,
139
+ min = 0,
140
+ onChange,
141
+ onChangeEnd,
142
+ size = "md",
143
+ step = 1,
144
+ value = 0,
145
+ valueFormat,
146
+ ...divProps
147
+ }: SliderProps): ReactNode => {
148
+ const labelId = useUniqueId()
149
+
150
+ const trackRef = useRef<HTMLDivElement>(null)
151
+
152
+ // Null means "not dragging" — distinct from a drag that happens to
153
+ // sit at 0, which a bare number could not express.
154
+ const [draggedValue, setDraggedValue] = useState<
155
+ number | null
156
+ >(null)
157
+
158
+ const isInert = isDisabled || isReadOnly
159
+
160
+ const committed = snapToStep(value, min, max, step)
161
+
162
+ const shown = draggedValue ?? committed
163
+
164
+ const percent = toPercent(shown, min, max)
165
+
166
+ const bigStep =
167
+ largeStep == null
168
+ ? (Math.max(min, max) - Math.min(min, max)) / 10
169
+ : largeStep
170
+
171
+ const emit = (next: number, isEnd: boolean): void => {
172
+ if (next !== shown) onChange?.(next)
173
+
174
+ if (isEnd) {
175
+ setDraggedValue(null)
176
+
177
+ onChangeEnd?.(next)
178
+
179
+ return
180
+ }
181
+
182
+ setDraggedValue(next)
183
+ }
184
+
185
+ /** The value the pointer at `clientX` is pointing at. */
186
+ const valueAt = (clientX: number): number => {
187
+ const track = trackRef.current
188
+
189
+ if (!track) return shown
190
+
191
+ const box = track.getBoundingClientRect()
192
+
193
+ if (box.width <= 0) return shown
194
+
195
+ // `left`/`right` here are physical, from the layout box, and stay
196
+ // physical on purpose — `isRtl` is what turns them into a value.
197
+ // The logical-properties rule is about `className`, not geometry.
198
+ const fraction = (clientX - box.left) / box.width
199
+
200
+ const isRtl =
201
+ getComputedStyle(track).direction === "rtl"
202
+
203
+ return fromFraction(fraction, min, max, step, isRtl)
204
+ }
205
+
206
+ const onPointerDown = (
207
+ event: PointerEvent<HTMLDivElement>,
208
+ ): void => {
209
+ if (isInert || event.button !== 0) return
210
+
211
+ // Capture on the TRACK, so a drag that leaves the element vertically
212
+ // (a thumb dragged off the bar, which is most of them) keeps
213
+ // reporting instead of stopping dead at the boundary.
214
+ event.currentTarget.setPointerCapture(event.pointerId)
215
+
216
+ event.currentTarget.focus()
217
+
218
+ emit(valueAt(event.clientX), false)
219
+ }
220
+
221
+ const onPointerMove = (
222
+ event: PointerEvent<HTMLDivElement>,
223
+ ): void => {
224
+ if (isInert || draggedValue == null) return
225
+
226
+ emit(valueAt(event.clientX), false)
227
+ }
228
+
229
+ const onPointerUp = (
230
+ event: PointerEvent<HTMLDivElement>,
231
+ ): void => {
232
+ if (isInert || draggedValue == null) return
233
+
234
+ event.currentTarget.releasePointerCapture(
235
+ event.pointerId,
236
+ )
237
+
238
+ emit(valueAt(event.clientX), true)
239
+ }
240
+
241
+ const onKeyDown = (
242
+ event: KeyboardEvent<HTMLDivElement>,
243
+ ): void => {
244
+ if (isInert) return
245
+
246
+ // Home/End are absolute; the rest are relative. `null` means the key
247
+ // is not ours and the event goes on its way — scrolling with the
248
+ // arrows off a focused slider is not something to swallow silently,
249
+ // but a slider that ignores its own arrow keys is worse.
250
+ const next = ((): number | null => {
251
+ switch (event.key) {
252
+ case "ArrowDown":
253
+ case "ArrowLeft":
254
+ return shown - step
255
+ case "ArrowRight":
256
+ case "ArrowUp":
257
+ return shown + step
258
+ case "End":
259
+ return Math.max(min, max)
260
+ case "Home":
261
+ return Math.min(min, max)
262
+ case "PageDown":
263
+ return shown - bigStep
264
+ case "PageUp":
265
+ return shown + bigStep
266
+ default:
267
+ return null
268
+ }
269
+ })()
270
+
271
+ if (next == null) return
272
+
273
+ event.preventDefault()
274
+
275
+ // Keys commit immediately. A key press is a discrete interaction —
276
+ // there is no release to wait for the way a drag has one, and an
277
+ // arrow key that only committed on `keyup` would feel lagged.
278
+ emit(snapToStep(next, min, max, step), true)
279
+ }
280
+
281
+ const valueText = valueFormat?.(
282
+ clampToRange(shown, min, max),
283
+ )
284
+
285
+ return (
286
+ <div
287
+ {...divProps}
288
+ className={toClassName(
289
+ "flex flex-col gap-1",
290
+ // A definite floor, because the root has no intrinsic width of
291
+ // its own: the track is `w-full` and the label is the only
292
+ // content, so a slider with no visible label in a shrink-to-fit
293
+ // parent (`items-start`, an inline-flex row) collapses to the
294
+ // width of the thumb. Every gate passed on exactly that —
295
+ // `role`, ARIA, keyboard and axe are all correct on a control
296
+ // 16px wide. `<input type="range">` has an intrinsic default
297
+ // width for the same reason; this is ours.
298
+ "min-w-40",
299
+ className,
300
+ )}
301
+ >
302
+ {isLabelVisible || isValueShown ? (
303
+ <div className="flex items-baseline justify-between gap-2 text-sm">
304
+ {isLabelVisible ? (
305
+ <span
306
+ className="text-content-secondary"
307
+ id={labelId}
308
+ >
309
+ {label}
310
+ </span>
311
+ ) : (
312
+ <VisuallyHidden id={labelId}>
313
+ {label}
314
+ </VisuallyHidden>
315
+ )}
316
+
317
+ {isValueShown ? (
318
+ <span className="text-content-muted tabular-nums">
319
+ {valueText ?? shown}
320
+ </span>
321
+ ) : null}
322
+ </div>
323
+ ) : (
324
+ <VisuallyHidden id={labelId}>
325
+ {label}
326
+ </VisuallyHidden>
327
+ )}
328
+
329
+ {/* The role, the tab stop and the pointer target are all the
330
+ track. Putting them on the thumb — which is what a hand-rolled
331
+ slider usually does, because the thumb is the thing that looks
332
+ draggable — gives the widget a 16px box, so a click on the bar
333
+ does nothing and a Playwright bounding box is the handle. */}
334
+ <div
335
+ aria-disabled={isDisabled || undefined}
336
+ aria-labelledby={labelId}
337
+ aria-orientation="horizontal"
338
+ aria-readonly={isReadOnly || undefined}
339
+ aria-valuemax={Math.max(min, max)}
340
+ aria-valuemin={Math.min(min, max)}
341
+ aria-valuenow={shown}
342
+ aria-valuetext={valueText}
343
+ className={toClassName(
344
+ "relative flex w-full touch-none items-center rounded-full",
345
+ // The hit area is the row, which is taller than the bar: a
346
+ // 2px track is not a pointer target, and a finger is 44px.
347
+ "min-h-(--control-height-sm) cursor-pointer",
348
+ FOCUS_RING_CLASS,
349
+ // NOT `ARIA_DISABLED_CLASS`. That constant paints a
350
+ // box-shaped control — `bg-surface-sunken` plus a border —
351
+ // which is right for `ButtonLink` and wrong here: a slider's
352
+ // visible body is a 2px track inside a 40px hit row, so the
353
+ // sunken fill lands on the whole row and draws a grey
354
+ // rectangle nothing else in the component has. Every gate
355
+ // passed on it; the screenshot is what showed it.
356
+ //
357
+ // `pointer-events-none` is the half that carries over, and it
358
+ // is load-bearing rather than cosmetic: without a real
359
+ // `:disabled` the hover rules still match.
360
+ isDisabled
361
+ ? "pointer-events-none opacity-55"
362
+ : "",
363
+ isReadOnly ? "cursor-default" : "",
364
+ )}
365
+ onKeyDown={onKeyDown}
366
+ onPointerCancel={onPointerUp}
367
+ onPointerDown={onPointerDown}
368
+ onPointerMove={onPointerMove}
369
+ onPointerUp={onPointerUp}
370
+ ref={trackRef}
371
+ role="slider"
372
+ tabIndex={isDisabled ? -1 : 0}
373
+ >
374
+ <span
375
+ className={toClassName(
376
+ "w-full overflow-hidden rounded-full bg-surface-sunken",
377
+ TRACK_SIZE_CLASS[size],
378
+ )}
379
+ >
380
+ <span
381
+ className={toClassName(
382
+ "block h-full rounded-full",
383
+ INTENT_SOLID_FILL_CLASS[intent],
384
+ )}
385
+ style={{ inlineSize: `${percent}%` }}
386
+ />
387
+ </span>
388
+
389
+ <span
390
+ aria-hidden="true"
391
+ className={toClassName(
392
+ "absolute rounded-full border-2 border-surface-raised shadow-sm",
393
+ THUMB_SIZE_CLASS[size],
394
+ INTENT_SOLID_FILL_CLASS[intent],
395
+ )}
396
+ // Pulled back by half its own width so it sits ON the
397
+ // position rather than starting at it, which is what keeps
398
+ // 100% from hanging off the end of the track.
399
+ //
400
+ // `margin-inline-start`, not `translate: -50%`. `translate`
401
+ // is physical, so in RTL it would shift the thumb the wrong
402
+ // way and 0% would hang off the start edge — the one bug a
403
+ // logical `inset-inline-start` looks like it already solved.
404
+ style={{
405
+ insetInlineStart: `${percent}%`,
406
+ marginInlineStart: THUMB_OFFSET[size],
407
+ }}
408
+ />
409
+ </div>
410
+ </div>
411
+ )
412
+ }
@@ -0,0 +1,156 @@
1
+ import { describe, expect, test } from "vitest"
2
+
3
+ import {
4
+ clampToRange,
5
+ fromFraction,
6
+ roundToStepPrecision,
7
+ snapToStep,
8
+ toPercent,
9
+ } from "./sliderValue.ts"
10
+
11
+ describe("clampToRange", () => {
12
+ test("it holds a value inside the range", () => {
13
+ expect(clampToRange(5, 0, 10)).toBe(5)
14
+
15
+ expect(clampToRange(-3, 0, 10)).toBe(0)
16
+
17
+ expect(clampToRange(30, 0, 10)).toBe(10)
18
+ })
19
+
20
+ test("a reversed range still has two ends", () => {
21
+ expect(clampToRange(5, 10, 0)).toBe(5)
22
+
23
+ expect(clampToRange(-1, 10, 0)).toBe(0)
24
+ })
25
+
26
+ // A NaN reaching the style attribute renders `inline-size: NaN%`,
27
+ // which paints nothing and reports nothing. An infinity is ordered,
28
+ // so it clamps to the end it is nearest rather than to the floor.
29
+ test("a NaN falls to the floor and an infinity clamps", () => {
30
+ expect(clampToRange(Number.NaN, 4, 10)).toBe(4)
31
+
32
+ expect(
33
+ clampToRange(Number.POSITIVE_INFINITY, 4, 10),
34
+ ).toBe(10)
35
+
36
+ expect(
37
+ clampToRange(Number.NEGATIVE_INFINITY, 4, 10),
38
+ ).toBe(4)
39
+ })
40
+ })
41
+
42
+ describe("snapToStep", () => {
43
+ test("it lands on the nearest step", () => {
44
+ expect(snapToStep(7, 0, 10, 5)).toBe(5)
45
+
46
+ expect(snapToStep(8, 0, 10, 5)).toBe(10)
47
+ })
48
+
49
+ /**
50
+ * The anchoring rule. Stepping a 7–19 range by 4 must be able to
51
+ * reach 7 — anchored on zero its reachable values are 8, 12 and 16,
52
+ * and neither end of its own range is one of them.
53
+ */
54
+ test("steps are anchored on min, not on zero", () => {
55
+ expect(snapToStep(7, 7, 19, 4)).toBe(7)
56
+
57
+ expect(snapToStep(10, 7, 19, 4)).toBe(11)
58
+
59
+ expect(snapToStep(19, 7, 19, 4)).toBe(19)
60
+ })
61
+
62
+ test("a snap never escapes the range", () => {
63
+ // 0–10 by 4: the nearest step to 10 is 12, which is not a value
64
+ // this slider has.
65
+ expect(snapToStep(10, 0, 10, 4)).toBe(10)
66
+ })
67
+
68
+ test("a zero or non-finite step means continuous", () => {
69
+ expect(snapToStep(7.3, 0, 10, 0)).toBe(7.3)
70
+
71
+ expect(snapToStep(7.3, 0, 10, Number.NaN)).toBe(7.3)
72
+
73
+ expect(snapToStep(-2, 0, 10, 0)).toBe(0)
74
+ })
75
+
76
+ test("a float step does not accumulate drift", () => {
77
+ // 0.1 * 3 is 0.30000000000000004 in IEEE 754, and that reaches
78
+ // aria-valuenow and the visible value both.
79
+ expect(snapToStep(0.3, 0, 1, 0.1)).toBe(0.3)
80
+
81
+ expect(snapToStep(0.25, 0, 1, 0.1)).toBe(0.3)
82
+ })
83
+ })
84
+
85
+ describe("roundToStepPrecision", () => {
86
+ test("it rounds to the decimals the step declares", () => {
87
+ expect(
88
+ roundToStepPrecision(0.30000000000000004, 0.1),
89
+ ).toBe(0.3)
90
+
91
+ expect(roundToStepPrecision(2.5, 1)).toBe(3)
92
+ })
93
+
94
+ // "1e-7" has no visible decimal point, so counting characters after
95
+ // a "." would report zero decimals and round it to an integer.
96
+ test("an exponential step is left alone", () => {
97
+ expect(roundToStepPrecision(0.00000012, 1e-7)).toBe(
98
+ 0.00000012,
99
+ )
100
+ })
101
+ })
102
+
103
+ describe("toPercent", () => {
104
+ test("it maps the range onto 0-100", () => {
105
+ expect(toPercent(0, 0, 200)).toBe(0)
106
+
107
+ expect(toPercent(100, 0, 200)).toBe(50)
108
+
109
+ expect(toPercent(200, 0, 200)).toBe(100)
110
+ })
111
+
112
+ test("an offset range starts at its own floor", () => {
113
+ expect(toPercent(7, 7, 19)).toBe(0)
114
+
115
+ expect(toPercent(19, 7, 19)).toBe(100)
116
+ })
117
+
118
+ // A single-valued slider is degenerate but legal, and it should paint
119
+ // at the start rather than divide by zero and disappear.
120
+ test("a zero-width range is 0 rather than NaN", () => {
121
+ expect(toPercent(5, 5, 5)).toBe(0)
122
+ })
123
+ })
124
+
125
+ describe("fromFraction", () => {
126
+ test("it reads a position along the track", () => {
127
+ expect(fromFraction(0, 0, 100, 1)).toBe(0)
128
+
129
+ expect(fromFraction(0.5, 0, 100, 1)).toBe(50)
130
+
131
+ expect(fromFraction(1, 0, 100, 1)).toBe(100)
132
+ })
133
+
134
+ test("a pointer past either end is still in range", () => {
135
+ expect(fromFraction(-0.4, 0, 100, 1)).toBe(0)
136
+
137
+ expect(fromFraction(1.8, 0, 100, 1)).toBe(100)
138
+ })
139
+
140
+ /**
141
+ * `getBoundingClientRect()` is physical and knows nothing about
142
+ * writing direction, so without this a right-to-left slider runs
143
+ * backwards under the pointer while its keyboard path is correct.
144
+ */
145
+ test("right-to-left inverts the track", () => {
146
+ expect(fromFraction(0, 0, 100, 1, true)).toBe(100)
147
+
148
+ expect(fromFraction(0.25, 0, 100, 1, true)).toBe(75)
149
+
150
+ expect(fromFraction(1, 0, 100, 1, true)).toBe(0)
151
+ })
152
+
153
+ test("it snaps to the step like everything else", () => {
154
+ expect(fromFraction(0.44, 0, 10, 5)).toBe(5)
155
+ })
156
+ })
@@ -0,0 +1,149 @@
1
+ /**
2
+ * The arithmetic behind `Slider`, kept out of the component so it can be
3
+ * tested in the node project rather than driven through a pointer.
4
+ *
5
+ * Every function here is total: a caller may pass a `NaN`, a reversed
6
+ * range or a zero step, and the answer is still a number inside the
7
+ * range. A slider that renders a thumb at `NaN%` is invisible, and the
8
+ * DOM says nothing about it.
9
+ */
10
+
11
+ /** `value` forced into `[min, max]`, whichever way round they arrive. */
12
+ export const clampToRange = (
13
+ value: number,
14
+ min: number,
15
+ max: number,
16
+ ): number => {
17
+ const low = Math.min(min, max)
18
+
19
+ const high = Math.max(min, max)
20
+
21
+ // NaN only. An infinity is an ORDERED value and clamps like any
22
+ // other — `Math.min`/`Math.max` already put it at the right end, and
23
+ // sending +Infinity to the floor would be the wrong end of the range.
24
+ if (Number.isNaN(value)) return low
25
+
26
+ return Math.min(high, Math.max(low, value))
27
+ }
28
+
29
+ /**
30
+ * `value` snapped to the nearest `min + n * step`, then clamped.
31
+ *
32
+ * Anchored on `min` rather than on zero, which is the difference
33
+ * between a 0–100 slider (where both agree) and a 7–19 one stepping by
34
+ * 4: anchored on zero its reachable values are 8, 12, 16 and neither
35
+ * end of its own range is one of them.
36
+ *
37
+ * A non-positive or non-finite `step` means "continuous" and snaps
38
+ * nothing — it must not divide by zero and hand back `NaN`.
39
+ */
40
+ export const snapToStep = (
41
+ value: number,
42
+ min: number,
43
+ max: number,
44
+ step: number,
45
+ ): number => {
46
+ const clamped = clampToRange(value, min, max)
47
+
48
+ if (!Number.isFinite(step) || step <= 0) return clamped
49
+
50
+ const low = Math.min(min, max)
51
+
52
+ const snapped =
53
+ low + Math.round((clamped - low) / step) * step
54
+
55
+ // Re-clamp: the last step can land past `max` when the range is not
56
+ // a whole number of steps (0–10 by 3 snaps 10 to 9, but 0–10 by 4
57
+ // would snap 10 to 12 without this).
58
+ const bounded = clampToRange(snapped, min, max)
59
+
60
+ // Float steps accumulate: 0.1 * 3 is 0.30000000000000004, and that
61
+ // reaches `aria-valuenow` and the visible value both. Round to the
62
+ // decimals the step itself declares.
63
+ return roundToStepPrecision(bounded, step)
64
+ }
65
+
66
+ /**
67
+ * `value` rounded to as many decimals as `step` has.
68
+ *
69
+ * `Number.EPSILON` scaling is deliberately not used — the input here is
70
+ * always already a multiple of `step` give or take float drift, so the
71
+ * decimal count of `step` is exactly the precision that can be real.
72
+ */
73
+ export const roundToStepPrecision = (
74
+ value: number,
75
+ step: number,
76
+ ): number => {
77
+ if (!Number.isFinite(step) || step <= 0) return value
78
+
79
+ const text = String(step)
80
+
81
+ // An exponential step ("1e-7") has no visible decimal point, so
82
+ // `split(".")` would report zero decimals and round it to an integer.
83
+ if (text.includes("e") || text.includes("E")) return value
84
+
85
+ const decimals = text.split(".")[1]?.length ?? 0
86
+
87
+ if (decimals === 0) return Math.round(value)
88
+
89
+ const scale = 10 ** decimals
90
+
91
+ return Math.round(value * scale) / scale
92
+ }
93
+
94
+ /**
95
+ * Where the thumb sits, 0–100, for `inline-size` / `inset-inline-start`.
96
+ *
97
+ * A zero-width range is 0% rather than `NaN`: `min === max` is a
98
+ * degenerate but legal slider (a single-valued one), and it should
99
+ * paint at the start instead of disappearing.
100
+ */
101
+ export const toPercent = (
102
+ value: number,
103
+ min: number,
104
+ max: number,
105
+ ): number => {
106
+ const low = Math.min(min, max)
107
+
108
+ const high = Math.max(min, max)
109
+
110
+ const span = high - low
111
+
112
+ if (span <= 0) return 0
113
+
114
+ return (
115
+ ((clampToRange(value, low, high) - low) / span) * 100
116
+ )
117
+ }
118
+
119
+ /**
120
+ * The value a pointer at `fraction` (0–1 along the track) is asking for.
121
+ *
122
+ * `isRtl` inverts it. The caller measures with `getBoundingClientRect()`,
123
+ * which is physical and knows nothing about writing direction, so a
124
+ * right-to-left slider would otherwise run backwards.
125
+ */
126
+ export const fromFraction = (
127
+ fraction: number,
128
+ min: number,
129
+ max: number,
130
+ step: number,
131
+ isRtl = false,
132
+ ): number => {
133
+ const safe = Number.isFinite(fraction) ? fraction : 0
134
+
135
+ const bounded = Math.min(1, Math.max(0, safe))
136
+
137
+ const along = isRtl ? 1 - bounded : bounded
138
+
139
+ const low = Math.min(min, max)
140
+
141
+ const high = Math.max(min, max)
142
+
143
+ return snapToStep(
144
+ low + along * (high - low),
145
+ min,
146
+ max,
147
+ step,
148
+ )
149
+ }