@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.
- package/dist/Slider/Slider.d.ts +85 -0
- package/dist/Slider/Slider.d.ts.map +1 -0
- package/dist/Slider/Slider.js +193 -0
- package/dist/Slider/Slider.js.map +1 -0
- package/dist/Slider/sliderValue.d.ts +48 -0
- package/dist/Slider/sliderValue.d.ts.map +1 -0
- package/dist/Slider/sliderValue.js +98 -0
- package/dist/Slider/sliderValue.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/Slider/Slider.mdx +78 -0
- package/src/Slider/Slider.stories.tsx +256 -0
- package/src/Slider/Slider.test.tsx +241 -0
- package/src/Slider/Slider.tsx +412 -0
- package/src/Slider/sliderValue.test.ts +156 -0
- package/src/Slider/sliderValue.ts +149 -0
- package/src/index.ts +5 -0
- package/src/sourceRules.test.ts +7 -1
|
@@ -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
|
+
}
|