@charcuterie/ui 3.32.0 → 3.34.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.
Files changed (76) hide show
  1. package/README.md +30 -0
  2. package/dist/AdaptiveGrid/useAdaptiveColumns.d.ts +16 -0
  3. package/dist/AdaptiveGrid/useAdaptiveColumns.d.ts.map +1 -1
  4. package/dist/AdaptiveGrid/useAdaptiveColumns.js +1 -0
  5. package/dist/AdaptiveGrid/useAdaptiveColumns.js.map +1 -1
  6. package/dist/Board/Board.d.ts.map +1 -1
  7. package/dist/Board/Board.js +2 -2
  8. package/dist/Board/Board.js.map +1 -1
  9. package/dist/RangeSlider/RangeSlider.d.ts +117 -0
  10. package/dist/RangeSlider/RangeSlider.d.ts.map +1 -0
  11. package/dist/RangeSlider/RangeSlider.js +242 -0
  12. package/dist/RangeSlider/RangeSlider.js.map +1 -0
  13. package/dist/RangeSlider/rangeSliderValue.d.ts +102 -0
  14. package/dist/RangeSlider/rangeSliderValue.d.ts.map +1 -0
  15. package/dist/RangeSlider/rangeSliderValue.js +116 -0
  16. package/dist/RangeSlider/rangeSliderValue.js.map +1 -0
  17. package/dist/SegmentedControl/SegmentedControl.d.ts +12 -1
  18. package/dist/SegmentedControl/SegmentedControl.d.ts.map +1 -1
  19. package/dist/SegmentedControl/SegmentedControl.js +3 -3
  20. package/dist/SegmentedControl/SegmentedControl.js.map +1 -1
  21. package/dist/SegmentedControl/SegmentedOption.d.ts +2 -1
  22. package/dist/SegmentedControl/SegmentedOption.d.ts.map +1 -1
  23. package/dist/SegmentedControl/SegmentedOption.js +7 -2
  24. package/dist/SegmentedControl/SegmentedOption.js.map +1 -1
  25. package/dist/Slider/Slider.d.ts +6 -1
  26. package/dist/Slider/Slider.d.ts.map +1 -1
  27. package/dist/Slider/Slider.js +8 -22
  28. package/dist/Slider/Slider.js.map +1 -1
  29. package/dist/TimecodeInput/TimecodeInput.d.ts +88 -0
  30. package/dist/TimecodeInput/TimecodeInput.d.ts.map +1 -0
  31. package/dist/TimecodeInput/TimecodeInput.js +258 -0
  32. package/dist/TimecodeInput/TimecodeInput.js.map +1 -0
  33. package/dist/TimecodeInput/timecode.d.ts +132 -0
  34. package/dist/TimecodeInput/timecode.d.ts.map +1 -0
  35. package/dist/TimecodeInput/timecode.js +246 -0
  36. package/dist/TimecodeInput/timecode.js.map +1 -0
  37. package/dist/VirtualizedGrid/VirtualizedGrid.d.ts +35 -0
  38. package/dist/VirtualizedGrid/VirtualizedGrid.d.ts.map +1 -1
  39. package/dist/VirtualizedGrid/VirtualizedGrid.js +37 -1
  40. package/dist/VirtualizedGrid/VirtualizedGrid.js.map +1 -1
  41. package/dist/index.d.ts +7 -0
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +3 -0
  44. package/dist/index.js.map +1 -1
  45. package/dist/sliderStyles.d.ts +39 -0
  46. package/dist/sliderStyles.d.ts.map +1 -0
  47. package/dist/sliderStyles.js +50 -0
  48. package/dist/sliderStyles.js.map +1 -0
  49. package/package.json +1 -1
  50. package/src/AdaptiveGrid/useAdaptiveColumns.ts +17 -0
  51. package/src/Board/Board.test.tsx +8 -0
  52. package/src/Board/Board.tsx +7 -1
  53. package/src/RangeSlider/RangeSlider.mdx +85 -0
  54. package/src/RangeSlider/RangeSlider.stories.tsx +346 -0
  55. package/src/RangeSlider/RangeSlider.test.tsx +452 -0
  56. package/src/RangeSlider/RangeSlider.tsx +595 -0
  57. package/src/RangeSlider/rangeSliderValue.test.ts +217 -0
  58. package/src/RangeSlider/rangeSliderValue.ts +185 -0
  59. package/src/SegmentedControl/SegmentedControl.stories.tsx +50 -0
  60. package/src/SegmentedControl/SegmentedControl.test.tsx +36 -1
  61. package/src/SegmentedControl/SegmentedControl.tsx +14 -0
  62. package/src/SegmentedControl/SegmentedOption.tsx +7 -0
  63. package/src/Slider/Slider.tsx +23 -29
  64. package/src/TimecodeInput/TimecodeInput.mdx +155 -0
  65. package/src/TimecodeInput/TimecodeInput.stories.tsx +304 -0
  66. package/src/TimecodeInput/TimecodeInput.test.tsx +279 -0
  67. package/src/TimecodeInput/TimecodeInput.tsx +493 -0
  68. package/src/TimecodeInput/timecode.test.ts +236 -0
  69. package/src/TimecodeInput/timecode.ts +391 -0
  70. package/src/VirtualizedGrid/VirtualizedGrid.mdx +21 -0
  71. package/src/VirtualizedGrid/VirtualizedGrid.stories.tsx +64 -0
  72. package/src/VirtualizedGrid/VirtualizedGrid.test.tsx +122 -3
  73. package/src/VirtualizedGrid/VirtualizedGrid.tsx +37 -1
  74. package/src/index.ts +18 -0
  75. package/src/sliderStyles.ts +69 -0
  76. package/src/sourceRules.test.ts +25 -1
@@ -0,0 +1,595 @@
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 {
16
+ fromFraction,
17
+ toPercent,
18
+ } from "../Slider/sliderValue.ts"
19
+ import type { SliderSize } from "../sliderStyles.ts"
20
+ import {
21
+ SLIDER_ROW_CLASS,
22
+ SLIDER_THUMB_CLASS,
23
+ SLIDER_THUMB_OFFSET,
24
+ SLIDER_THUMB_SIZE_CLASS,
25
+ SLIDER_TRACK_CLASS,
26
+ SLIDER_TRACK_SIZE_CLASS,
27
+ } from "../sliderStyles.ts"
28
+ import { toClassName } from "../toClassName.ts"
29
+ import { VisuallyHidden } from "../VisuallyHidden/VisuallyHidden.tsx"
30
+ import type {
31
+ RangeSliderThumb,
32
+ RangeSliderValue,
33
+ } from "./rangeSliderValue.ts"
34
+ import {
35
+ getNearerThumb,
36
+ getThumbBounds,
37
+ moveThumb,
38
+ snapRange,
39
+ } from "./rangeSliderValue.ts"
40
+
41
+ /**
42
+ * A position along the range, drawn as a mark on the bar.
43
+ *
44
+ * Prop-driven and nothing more: a chapter list, a scene index, a set
45
+ * of preset prices. The component holds no model of what a tick
46
+ * means, and `label` is optional because a mark that needs no words
47
+ * is the common case.
48
+ */
49
+ export type RangeSliderTick = {
50
+ label?: string
51
+ value: number
52
+ }
53
+
54
+ export type RangeSliderProps = Omit<
55
+ ComponentPropsWithRef<"div">,
56
+ "children" | "defaultValue" | "onChange"
57
+ > & {
58
+ intent?: IntentName
59
+ isDisabled?: boolean
60
+ isLabelVisible?: boolean
61
+ /**
62
+ * Read-only in the `aria-readonly` sense: focusable, full contrast,
63
+ * announced as unchangeable — the same distinction `Slider` draws.
64
+ * A clip already sent to a renderer is this, not `isDisabled`: the
65
+ * span is still worth reading.
66
+ */
67
+ isReadOnly?: boolean
68
+ isValueShown?: boolean
69
+ /** Required. Each thumb's own name is built from it. */
70
+ label: string
71
+ /** PageUp / PageDown. Defaults to a tenth of the range, as `Slider`'s does. */
72
+ largeStep?: number
73
+ max?: number
74
+ min?: number
75
+ /**
76
+ * Fires on every movement — each arrow key, and each pointer sample
77
+ * while dragging. Use it to paint. See `onChangeEnd` for committing.
78
+ */
79
+ onChange?: (value: RangeSliderValue) => void
80
+ /**
81
+ * Fires once, when the interaction ends: pointer release, or key up.
82
+ *
83
+ * The same split `Slider` settled, for the same reason. A clip
84
+ * preview that re-seeks a player from `onChange` issues one seek per
85
+ * pointer sample and arrives at the right frame **last**, behind a
86
+ * queue of stale ones.
87
+ */
88
+ onChangeEnd?: (value: RangeSliderValue) => void
89
+ size?: SliderSize
90
+ step?: number
91
+ /**
92
+ * Marks along the bar. Positions in the same units as `value`, so a
93
+ * film's chapters are the offsets the app already has.
94
+ *
95
+ * A `label` is centred on its own mark and clamped inside the bar at
96
+ * both ends. Two marks closer together than a label box will overlap:
97
+ * the component draws what it is given, and a caller with dense marks
98
+ * passes the words only where there is room for them.
99
+ */
100
+ ticks?: readonly RangeSliderTick[]
101
+ /**
102
+ * Seeds the range; it does not control it. A drag paints from the
103
+ * component's own value, so a caller that commits on `onChangeEnd`
104
+ * alone still sees the thumbs follow the pointer.
105
+ */
106
+ value?: RangeSliderValue
107
+ /**
108
+ * How each end reads aloud and, when `isValueShown`, on screen.
109
+ * This is what keeps timecodes out of the library: the component
110
+ * knows a number, the app knows it is 21:14.
111
+ */
112
+ valueFormat?: (value: number) => string
113
+ }
114
+
115
+ /** Half the tick-label box, which is what centres it on its mark. */
116
+ const TICK_LABEL_INLINE_SIZE = "4.5rem"
117
+
118
+ const TICK_LABEL_HALF = "2.25rem"
119
+
120
+ /**
121
+ * A two-thumb range control: a span picked out along a bar, with an
122
+ * independently focusable handle at each end.
123
+ *
124
+ * It exists because QueuePilot lets a user play **only a section** of
125
+ * a video, and a clip's start and end are a shape `Slider` states it
126
+ * is not: *"a two-thumb range is a different widget with its own
127
+ * focus model"*
128
+ * ([decision](../../../../docs/decisions/2026-09-01-a-two-thumb-range-is-its-own-component-and-shares-the-bar.md)).
129
+ *
130
+ * ## Why this is a component and not `Slider isRange`
131
+ *
132
+ * `Combobox`'s `isMultiple` and `DatePicker`'s `isRange` are modes
133
+ * because neither changes the widget's role and both share nearly
134
+ * everything. Both halves of that test fail here.
135
+ *
136
+ * `Slider` puts `role="slider"`, the tab stop and the pointer target
137
+ * **on the track**, because one track reports one value. Two values
138
+ * cannot live on one accessible object, so here the role moves to the
139
+ * thumbs, the track becomes a `role="group"` carrying the label, and
140
+ * the tab stops go from one to two. A mode that rewrote its own role
141
+ * would break `getByRole("slider", { name })` in every consumer that
142
+ * had ever used the single-value one.
143
+ *
144
+ * What the two genuinely share is the arithmetic
145
+ * (`Slider/sliderValue.ts`, called rather than copied) and the bar
146
+ * (`../sliderStyles.ts`) — so a range slider snaps on the same grid
147
+ * and paints as the same control.
148
+ *
149
+ * ## The thumbs clamp, they do not swap
150
+ *
151
+ * A thumb dragged past its partner stops on it and the range collapses
152
+ * to zero width. The thumbs never trade identities mid-drag: the
153
+ * handle under the pointer is the one the user grabbed, and the
154
+ * focused handle is the one an arrow key moves. Each thumb reports the
155
+ * other as its own `aria-valuemin` / `aria-valuemax`, which is how a
156
+ * screen reader is told where this handle stops.
157
+ *
158
+ * A press on the bar moves the **nearer** thumb. When the two sit on
159
+ * the same value, the press's side decides — which is what lets a
160
+ * collapsed range be pulled open again in either direction.
161
+ */
162
+ export const RangeSlider = ({
163
+ className,
164
+ intent = "accent",
165
+ isDisabled = false,
166
+ isLabelVisible = false,
167
+ isReadOnly = false,
168
+ isValueShown = false,
169
+ label,
170
+ largeStep,
171
+ max = 100,
172
+ min = 0,
173
+ onChange,
174
+ onChangeEnd,
175
+ size = "md",
176
+ step = 1,
177
+ ticks,
178
+ value = { end: max, start: min },
179
+ valueFormat,
180
+ ...divProps
181
+ }: RangeSliderProps): ReactNode => {
182
+ const labelId = useUniqueId()
183
+
184
+ const trackRef = useRef<HTMLDivElement>(null)
185
+
186
+ const startThumbRef = useRef<HTMLSpanElement>(null)
187
+
188
+ const endThumbRef = useRef<HTMLSpanElement>(null)
189
+
190
+ // Null means "not dragging". The thumb rides along with the value
191
+ // because a drag has to keep moving the handle it picked up, even
192
+ // once the pointer has travelled past the other one and the "nearer"
193
+ // answer has changed.
194
+ const [drag, setDrag] = useState<{
195
+ range: RangeSliderValue
196
+ thumb: RangeSliderThumb
197
+ } | null>(null)
198
+
199
+ const isInert = isDisabled || isReadOnly
200
+
201
+ const bounds = { max, min, step }
202
+
203
+ const committed = snapRange(value, bounds)
204
+
205
+ const shown = drag?.range ?? committed
206
+
207
+ const bigStep =
208
+ largeStep == null
209
+ ? (Math.max(min, max) - Math.min(min, max)) / 10
210
+ : largeStep
211
+
212
+ const startPercent = toPercent(shown.start, min, max)
213
+
214
+ const endPercent = toPercent(shown.end, min, max)
215
+
216
+ const emit = (
217
+ next: RangeSliderValue,
218
+ thumb: RangeSliderThumb,
219
+ isEnd: boolean,
220
+ ): void => {
221
+ if (
222
+ next.start !== shown.start ||
223
+ next.end !== shown.end
224
+ ) {
225
+ onChange?.(next)
226
+ }
227
+
228
+ if (isEnd) {
229
+ setDrag(null)
230
+
231
+ onChangeEnd?.(next)
232
+
233
+ return
234
+ }
235
+
236
+ setDrag({ range: next, thumb })
237
+ }
238
+
239
+ /** The value the pointer at `clientX` is pointing at. */
240
+ const valueAt = (clientX: number): number => {
241
+ const track = trackRef.current
242
+
243
+ if (!track) return shown.start
244
+
245
+ const box = track.getBoundingClientRect()
246
+
247
+ if (box.width <= 0) return shown.start
248
+
249
+ // `left`/`width` are physical, from the layout box, and stay
250
+ // physical on purpose — `isRtl` is what turns them into a value.
251
+ // The logical-properties rule is about `className`, not geometry.
252
+ const fraction = (clientX - box.left) / box.width
253
+
254
+ const isRtl =
255
+ getComputedStyle(track).direction === "rtl"
256
+
257
+ return fromFraction(fraction, min, max, step, isRtl)
258
+ }
259
+
260
+ const onPointerDown = (
261
+ event: PointerEvent<HTMLDivElement>,
262
+ ): void => {
263
+ if (isInert || event.button !== 0) return
264
+
265
+ // The press is ours, and the default is what would undo it: a
266
+ // mousedown on a non-focusable element moves focus to the body,
267
+ // so the handle this handler just focused would lose it again a
268
+ // tick later. `Slider` never needed this because its pointer
269
+ // target IS its focusable element; here the bar is the target and
270
+ // the thumb is the tab stop.
271
+ event.preventDefault()
272
+
273
+ const at = valueAt(event.clientX)
274
+
275
+ const thumb = getNearerThumb({ at, range: shown })
276
+
277
+ // Capture on the GROUP rather than on the thumb, so a drag that
278
+ // leaves the bar vertically keeps reporting — and so a fast drag
279
+ // that outruns the thumb it is moving does not hand the pointer to
280
+ // the element underneath.
281
+ event.currentTarget.setPointerCapture(event.pointerId)
282
+
283
+ // Focus follows the grab. A pointer user who then reaches for the
284
+ // arrow keys is moving the handle they were just dragging, which
285
+ // is the only answer that does not need looking at.
286
+ //
287
+ // The handle wears its focus ring for the length of the drag,
288
+ // which is a script `focus()` matching `:focus-visible`. On a
289
+ // one-thumb control that would be the ring-after-a-click the house
290
+ // style rejects; here it is the answer to "which of the two am I
291
+ // holding", and it is the same mark the keyboard path leaves.
292
+ const thumbElement =
293
+ thumb === "start"
294
+ ? startThumbRef.current
295
+ : endThumbRef.current
296
+
297
+ thumbElement?.focus()
298
+
299
+ emit(
300
+ moveThumb({ bounds, next: at, range: shown, thumb }),
301
+ thumb,
302
+ false,
303
+ )
304
+ }
305
+
306
+ const onPointerMove = (
307
+ event: PointerEvent<HTMLDivElement>,
308
+ ): void => {
309
+ if (isInert || drag == null) return
310
+
311
+ emit(
312
+ moveThumb({
313
+ bounds,
314
+ next: valueAt(event.clientX),
315
+ range: shown,
316
+ thumb: drag.thumb,
317
+ }),
318
+ drag.thumb,
319
+ false,
320
+ )
321
+ }
322
+
323
+ const onPointerUp = (
324
+ event: PointerEvent<HTMLDivElement>,
325
+ ): void => {
326
+ if (isInert || drag == null) return
327
+
328
+ event.currentTarget.releasePointerCapture(
329
+ event.pointerId,
330
+ )
331
+
332
+ emit(
333
+ moveThumb({
334
+ bounds,
335
+ next: valueAt(event.clientX),
336
+ range: shown,
337
+ thumb: drag.thumb,
338
+ }),
339
+ drag.thumb,
340
+ true,
341
+ )
342
+ }
343
+
344
+ /**
345
+ * `Slider`'s key handling, per thumb. Home and End are absolute and
346
+ * are clamped at the other thumb like any other movement, so End on
347
+ * the start handle means "as far right as this handle goes".
348
+ */
349
+ const getKeyedValue = (
350
+ event: KeyboardEvent<HTMLSpanElement>,
351
+ current: number,
352
+ ): number | null => {
353
+ switch (event.key) {
354
+ case "ArrowDown":
355
+ case "ArrowLeft":
356
+ return current - step
357
+ case "ArrowRight":
358
+ case "ArrowUp":
359
+ return current + step
360
+ case "End":
361
+ return Math.max(min, max)
362
+ case "Home":
363
+ return Math.min(min, max)
364
+ case "PageDown":
365
+ return current - bigStep
366
+ case "PageUp":
367
+ return current + bigStep
368
+ default:
369
+ return null
370
+ }
371
+ }
372
+
373
+ const onThumbKeyDown =
374
+ (thumb: RangeSliderThumb) =>
375
+ (event: KeyboardEvent<HTMLSpanElement>): void => {
376
+ if (isInert) return
377
+
378
+ const next = getKeyedValue(event, shown[thumb])
379
+
380
+ if (next == null) return
381
+
382
+ event.preventDefault()
383
+
384
+ // Keys commit immediately, as `Slider`'s do: a key press is a
385
+ // discrete interaction with no release to wait for.
386
+ emit(
387
+ moveThumb({ bounds, next, range: shown, thumb }),
388
+ thumb,
389
+ true,
390
+ )
391
+ }
392
+
393
+ const renderThumb = (
394
+ thumb: RangeSliderThumb,
395
+ ): ReactNode => {
396
+ const thumbBounds = getThumbBounds({
397
+ bounds,
398
+ range: shown,
399
+ thumb,
400
+ })
401
+
402
+ const percent =
403
+ thumb === "start" ? startPercent : endPercent
404
+
405
+ return (
406
+ <span
407
+ aria-disabled={isDisabled || undefined}
408
+ // The group carries `label`; each thumb says which end of it
409
+ // this is, so `getByRole("slider", { name: "Clip start" })`
410
+ // resolves to exactly one handle.
411
+ aria-label={`${label} ${thumb}`}
412
+ aria-orientation="horizontal"
413
+ aria-readonly={isReadOnly || undefined}
414
+ // The other thumb is this one's bound. That is the clamping
415
+ // rule stated in the only vocabulary a slider has.
416
+ aria-valuemax={thumbBounds.max}
417
+ aria-valuemin={thumbBounds.min}
418
+ aria-valuenow={shown[thumb]}
419
+ aria-valuetext={valueFormat?.(shown[thumb])}
420
+ className={toClassName(
421
+ SLIDER_THUMB_CLASS,
422
+ SLIDER_THUMB_SIZE_CLASS[size],
423
+ INTENT_SOLID_FILL_CLASS[intent],
424
+ FOCUS_RING_CLASS,
425
+ isReadOnly ? "cursor-default" : "",
426
+ )}
427
+ key={thumb}
428
+ onKeyDown={onThumbKeyDown(thumb)}
429
+ ref={
430
+ thumb === "start" ? startThumbRef : endThumbRef
431
+ }
432
+ role="slider"
433
+ // Pulled back by half its own width so it sits ON the value
434
+ // rather than starting at it. `margin-inline-start`, not
435
+ // `translate: -50%`: `translate` is physical, so in RTL it
436
+ // would shift the thumb the wrong way and 0% would hang off
437
+ // the start edge.
438
+ style={{
439
+ insetInlineStart: `${percent}%`,
440
+ marginInlineStart: SLIDER_THUMB_OFFSET[size],
441
+ }}
442
+ tabIndex={isDisabled ? -1 : 0}
443
+ />
444
+ )
445
+ }
446
+
447
+ const shownText =
448
+ valueFormat == null
449
+ ? `${shown.start} to ${shown.end}`
450
+ : `${valueFormat(shown.start)} to ${valueFormat(shown.end)}`
451
+
452
+ return (
453
+ <div
454
+ {...divProps}
455
+ className={toClassName(
456
+ "flex flex-col gap-1",
457
+ // A definite floor, for `Slider`'s reason: the root has no
458
+ // intrinsic width of its own, so in a shrink-to-fit parent it
459
+ // would collapse to the width of a thumb with every gate
460
+ // still green.
461
+ "min-w-40",
462
+ className,
463
+ )}
464
+ >
465
+ {isLabelVisible || isValueShown ? (
466
+ <div className="flex items-baseline justify-between gap-2 text-sm">
467
+ {isLabelVisible ? (
468
+ <span
469
+ className="text-content-secondary"
470
+ id={labelId}
471
+ >
472
+ {label}
473
+ </span>
474
+ ) : (
475
+ <VisuallyHidden id={labelId}>
476
+ {label}
477
+ </VisuallyHidden>
478
+ )}
479
+
480
+ {isValueShown ? (
481
+ // "to", not an en dash. A default is words — the
482
+ // no-symbol-glyph rule — and "0:30 to 2:15" is also what
483
+ // the span reads as out loud.
484
+ <span className="text-content-muted tabular-nums">
485
+ {shownText}
486
+ </span>
487
+ ) : null}
488
+ </div>
489
+ ) : (
490
+ <VisuallyHidden id={labelId}>
491
+ {label}
492
+ </VisuallyHidden>
493
+ )}
494
+
495
+ {/* A `group`, not a `slider`: the two values live on the thumbs,
496
+ and this is what holds the name they are each part of. The
497
+ pointer target is still the whole row, which is why a press
498
+ anywhere on the bar moves the nearer handle. */}
499
+ {/* biome-ignore lint/a11y/useSemanticElements: the semantic element for `group` is `<fieldset>`, which drags `<legend>` semantics, a border and form-reset behaviour onto a bar that already carries its name through `aria-labelledby` — and a `FieldGroup` around this control is where a real `<fieldset>` belongs. Same call `Menu`'s group, `AccordionSection`, `BoardLaneList` and `ActionTiles` already made. */}
500
+ <div
501
+ aria-labelledby={labelId}
502
+ className={toClassName(
503
+ SLIDER_ROW_CLASS,
504
+ // NOT `ARIA_DISABLED_CLASS`, for the reason `Slider` gives:
505
+ // that constant paints a box-shaped control, and this
506
+ // control's body is a 2px bar inside a 40px row.
507
+ //
508
+ // `pointer-events-none` is load-bearing rather than
509
+ // cosmetic — without a real `:disabled` the hover rules
510
+ // still match.
511
+ isDisabled
512
+ ? "pointer-events-none opacity-55"
513
+ : "",
514
+ isReadOnly ? "cursor-default" : "",
515
+ )}
516
+ onPointerCancel={onPointerUp}
517
+ onPointerDown={onPointerDown}
518
+ onPointerMove={onPointerMove}
519
+ onPointerUp={onPointerUp}
520
+ ref={trackRef}
521
+ role="group"
522
+ >
523
+ <span
524
+ className={toClassName(
525
+ SLIDER_TRACK_CLASS,
526
+ SLIDER_TRACK_SIZE_CLASS[size],
527
+ )}
528
+ >
529
+ {/* The selected span, offset from the start of the track
530
+ rather than sized from it — a percentage margin resolves
531
+ against the same inline size the width does, and
532
+ `margin-inline-start` flips with the writing direction
533
+ where a `left` would not. */}
534
+ <span
535
+ className={toClassName(
536
+ "block h-full rounded-full",
537
+ INTENT_SOLID_FILL_CLASS[intent],
538
+ )}
539
+ style={{
540
+ inlineSize: `${endPercent - startPercent}%`,
541
+ marginInlineStart: `${startPercent}%`,
542
+ }}
543
+ />
544
+ </span>
545
+
546
+ {ticks?.map((tick) => (
547
+ <span
548
+ aria-hidden="true"
549
+ className="absolute h-2 w-px bg-border-strong"
550
+ key={tick.value}
551
+ style={{
552
+ insetInlineStart: `${toPercent(tick.value, min, max)}%`,
553
+ }}
554
+ />
555
+ ))}
556
+
557
+ {/* Start first, so Tab reaches the two handles in the order
558
+ they read. */}
559
+ {renderThumb("start")}
560
+
561
+ {renderThumb("end")}
562
+ </div>
563
+
564
+ {ticks?.some((tick) => tick.label != null) ? (
565
+ // Decoration. Each thumb already announces its own value
566
+ // through `aria-valuetext`, and a screen reader reading a row
567
+ // of chapter names between them would be reading the picture.
568
+ <div
569
+ aria-hidden="true"
570
+ className="relative h-4 w-full text-content-muted text-xs"
571
+ >
572
+ {ticks.map((tick) =>
573
+ tick.label == null ? null : (
574
+ <span
575
+ className="absolute text-center"
576
+ key={tick.value}
577
+ // Centred on its mark, and `clamp`ed so the first and
578
+ // last labels stay inside the bar instead of hanging
579
+ // off its ends. A fixed box is what makes the centring
580
+ // possible at all: the label's own width is not known
581
+ // here, and `translate: -50%` is physical.
582
+ style={{
583
+ inlineSize: TICK_LABEL_INLINE_SIZE,
584
+ insetInlineStart: `clamp(0rem, calc(${toPercent(tick.value, min, max)}% - ${TICK_LABEL_HALF}), calc(100% - ${TICK_LABEL_INLINE_SIZE}))`,
585
+ }}
586
+ >
587
+ {tick.label}
588
+ </span>
589
+ ),
590
+ )}
591
+ </div>
592
+ ) : null}
593
+ </div>
594
+ )
595
+ }