@tremolo-ui/react 0.7.0 → 0.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.
Files changed (55) hide show
  1. package/dist/index.cjs +666 -1171
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +316 -315
  4. package/dist/index.d.cts.map +1 -1
  5. package/dist/index.d.ts +316 -315
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +643 -1123
  8. package/dist/index.js.map +1 -1
  9. package/package.json +9 -9
  10. package/src/components/AnimationCanvas/index.tsx +4 -18
  11. package/src/components/DropZone/index.tsx +2 -1
  12. package/src/components/FileInput/index.tsx +11 -13
  13. package/src/components/Knob/ActiveLine.tsx +5 -3
  14. package/src/components/Knob/InactiveLine.tsx +6 -4
  15. package/src/components/Knob/SVGRoot.tsx +3 -3
  16. package/src/components/Knob/Thumb.tsx +21 -20
  17. package/src/components/Knob/context.tsx +3 -74
  18. package/src/components/Knob/index.tsx +99 -52
  19. package/src/components/NumberInput/InputField.tsx +20 -33
  20. package/src/components/NumberInput/Stepper.tsx +76 -100
  21. package/src/components/NumberInput/StepperButton.tsx +15 -8
  22. package/src/components/NumberInput/context.tsx +26 -3
  23. package/src/components/NumberInput/index.tsx +175 -91
  24. package/src/components/Piano/index.tsx +54 -144
  25. package/src/components/PointsEditor/Container.tsx +15 -26
  26. package/src/components/PointsEditor/Point.tsx +94 -110
  27. package/src/components/PointsEditor/context.tsx +8 -60
  28. package/src/components/PointsEditor/index.tsx +41 -324
  29. package/src/components/Slider/Marks.tsx +5 -6
  30. package/src/components/Slider/MarksOption.tsx +9 -19
  31. package/src/components/Slider/Thumb.tsx +20 -11
  32. package/src/components/Slider/Track.tsx +16 -13
  33. package/src/components/Slider/context.tsx +3 -3
  34. package/src/components/Slider/index.tsx +110 -72
  35. package/src/components/XYPad/Area.tsx +27 -49
  36. package/src/components/XYPad/Thumb.tsx +20 -8
  37. package/src/components/XYPad/context.tsx +2 -4
  38. package/src/components/XYPad/index.tsx +102 -83
  39. package/src/components/_util/VisuallyHiddenRangeInput.tsx +1 -1
  40. package/src/hooks/_internal/useChangeGesture.ts +93 -0
  41. package/src/hooks/_internal/useCheckSteps.ts +9 -146
  42. package/src/hooks/useAnimationFrame.ts +37 -15
  43. package/src/hooks/useEventListener.ts +93 -22
  44. package/src/hooks/useLongPress.ts +35 -51
  45. package/src/hooks/useMIDIAccess.ts +0 -8
  46. package/src/hooks/useMIDIInput.ts +0 -3
  47. package/src/hooks/useWheel.ts +4 -4
  48. package/src/index.ts +11 -32
  49. package/src/components/Piano/keyboard-shortcuts.ts +0 -61
  50. package/src/components/Slider/type.ts +0 -46
  51. package/src/components/_util/css-length.ts +0 -13
  52. package/src/components/_util/decimal-digits.ts +0 -23
  53. package/src/components/_util/visually-hidden.ts +0 -25
  54. package/src/hooks/useInterval.ts +0 -27
  55. package/src/input-event.ts +0 -32
@@ -3,8 +3,6 @@ import {
3
3
  CSSProperties,
4
4
  forwardRef,
5
5
  ReactNode,
6
- RefObject,
7
- useCallback,
8
6
  useEffect,
9
7
  useMemo,
10
8
  useRef,
@@ -12,89 +10,27 @@ import {
12
10
  } from 'react'
13
11
 
14
12
  import {
15
- applyDelta,
16
- createSelectionBox,
13
+ createPointsEditor,
14
+ DEFAULT_DRAG_SENSITIVITY,
17
15
  type InputEventOption,
18
- type ModifierState,
19
16
  type ModifierValue,
20
- type SelectionBoxInstance,
21
17
  type SelectionBoxRect,
22
- type XY,
23
18
  } from '@tremolo-ui/dom'
24
- import { clamp, toPrecision } from '@tremolo-ui/functions'
25
-
26
- import { DEFAULT_DRAG_SENSITIVITY } from '../../input-event'
27
- import { cssLength } from '../_util/css-length'
19
+ import {
20
+ POINTS_EDITOR_DEFAULT_KEYBOARD,
21
+ POINTS_EDITOR_DEFAULT_WHEEL,
22
+ } from '@tremolo-ui/dom/internal'
28
23
 
29
24
  import { Background } from './Background'
30
25
  import { Container } from './Container'
31
- import { type PointRegistration, PointsEditorProvider } from './context'
32
- import { AXIS, Point, type PointBaseType } from './Point'
26
+ import { PointsEditorProvider } from './context'
27
+ import { Point } from './Point'
33
28
  import { SelectionBox } from './SelectionBox'
34
29
 
35
- import type { CSSVariables } from '../../css-variables'
36
-
37
30
  /** One array for every editor with selection turned off, so memos hold still. */
38
31
  const EMPTY: readonly string[] = []
39
32
 
40
- /**
41
- * How far a point may move before something in the selection leaves its range.
42
- *
43
- * Clamping each point on its own would break the shape of the selection: the
44
- * one that reached the edge would stop while the rest carried on. One amount
45
- * for all of them means the whole selection stops together.
46
- */
47
- function allowedDelta(
48
- delta: PointBaseType,
49
- entries: { start: PointBaseType; registration: PointRegistration }[],
50
- ): PointBaseType {
51
- let loX = -Infinity
52
- let hiX = Infinity
53
- let loY = -Infinity
54
- let hiY = Infinity
55
-
56
- for (const { start, registration } of entries) {
57
- loX = Math.max(loX, (registration.min?.x ?? 0) - start.x)
58
- hiX = Math.min(hiX, (registration.max?.x ?? 1) - start.x)
59
- loY = Math.max(loY, (registration.min?.y ?? 0) - start.y)
60
- hiY = Math.min(hiY, (registration.max?.y ?? 1) - start.y)
61
- }
62
-
63
- // A point that started outside its own range leaves nothing to move within.
64
- return {
65
- x: hiX < loX ? 0 : clamp(delta.x, loX, hiX),
66
- y: hiY < loY ? 0 : clamp(delta.y, loY, hiY),
67
- }
68
- }
69
-
70
- /*
71
- TODO:
72
-
73
- - grid
74
- */
75
-
76
- /**
77
- * A point moves over 0..1 in both axes, so a nudge of 0.01 crosses the editor
78
- * in a hundred steps whatever its pixel size.
79
- */
80
- const DEFAULT_WHEEL: ModifierValue<InputEventOption> = ['normalized', 0.01]
81
-
82
- /**
83
- * Shift is the fine-adjustment key everywhere else, so it is bound here too
84
- * — but only on the keyboard. On the wheel it already means the x axis, and
85
- * browsers hand shift+wheel over as horizontal scrolling anyway.
86
- */
87
- const DEFAULT_KEYBOARD: ModifierValue<InputEventOption> = {
88
- default: ['normalized', 0.01],
89
- shift: ['normalized', 0.001],
90
- }
91
-
92
33
  export interface PointsEditorProps {
93
- /** Sets `--width`; the size the theme gives it stands when omitted. */
94
- width?: number | string
95
- /** Sets `--height`; the size the theme gives it stands when omitted. */
96
- height?: number | string
97
-
98
34
  /**
99
35
  * Make the points unchangeable and remove them from the tab order.
100
36
  * The parts carry `data-disabled` while it is set.
@@ -104,17 +40,15 @@ export interface PointsEditorProps {
104
40
  * Make the points unmovable.
105
41
  * The parts carry `data-readonly` while it is set.
106
42
  */
107
- readonly?: boolean
43
+ readOnly?: boolean
108
44
 
109
45
  /**
110
46
  * The cursor to show while dragging a point. It is set on the dragged
111
47
  * point, so it stays while the pointer is outside it.
112
48
  *
113
- * @default { cursor: 'grabbing' }
49
+ * @default 'grabbing'
114
50
  */
115
- externalStyles?: {
116
- cursor?: CSSProperties['cursor']
117
- }
51
+ dragCursor?: CSSProperties['cursor']
118
52
 
119
53
  /**
120
54
  * How much one notch of the wheel moves the focused `Point`. Scrolling
@@ -212,8 +146,6 @@ export interface PointsEditorProps {
212
146
  * </PointsEditor.Root>
213
147
  */
214
148
  children: ReactNode
215
-
216
- style?: CSSProperties & CSSVariables<'width' | 'height'>
217
149
  }
218
150
 
219
151
  type Props = PointsEditorProps &
@@ -222,18 +154,16 @@ type Props = PointsEditorProps &
222
154
  export const Root = /* @__PURE__ */ forwardRef<HTMLDivElement, Props>(
223
155
  (
224
156
  {
225
- width,
226
- height,
227
157
  disabled = false,
228
- readonly = false,
229
- wheel = DEFAULT_WHEEL,
230
- keyboard = DEFAULT_KEYBOARD,
158
+ readOnly = false,
159
+ wheel = POINTS_EDITOR_DEFAULT_WHEEL,
160
+ keyboard = POINTS_EDITOR_DEFAULT_KEYBOARD,
231
161
  dragSensitivity = DEFAULT_DRAG_SENSITIVITY,
232
162
  selectable = false,
233
163
  selection: selectionProp,
234
164
  defaultSelection,
235
165
  onSelectionChange,
236
- externalStyles,
166
+ dragCursor = 'grabbing',
237
167
  style,
238
168
  className,
239
169
  children,
@@ -242,12 +172,12 @@ export const Root = /* @__PURE__ */ forwardRef<HTMLDivElement, Props>(
242
172
  forwardedRef,
243
173
  ) => {
244
174
  const containerRef = useRef<HTMLDivElement>(null)
245
-
246
175
  // Picked apart so that the memo below depends on values rather than on the
247
176
  // object literal a caller writes inline, which is new on every render.
248
- const { cursor = 'grabbing' } = externalStyles ?? {}
249
177
 
250
178
  // --- selection ---
179
+ // The selection lives here, where React state can hold it; which points a
180
+ // press or a box selects, and how a selection moves, is the core's.
251
181
  const controlled = selectionProp !== undefined
252
182
  const [ownSelection, setOwnSelection] = useState<string[]>(
253
183
  defaultSelection ?? [],
@@ -256,262 +186,55 @@ export const Root = /* @__PURE__ */ forwardRef<HTMLDivElement, Props>(
256
186
  // point it started on and `data-selected` never turns on.
257
187
  const selection = selectable ? (selectionProp ?? ownSelection) : EMPTY
258
188
 
259
- // Drags read the selection from a native event handler, which runs after
260
- // the commit, so a ref is current by the time it matters.
261
- const selectionRef = useRef(selection)
262
- const changeHandlerRef = useRef(onSelectionChange)
263
- useEffect(() => {
264
- selectionRef.current = selection
265
- changeHandlerRef.current = onSelectionChange
266
- })
267
-
268
- const changeSelection = useCallback(
269
- (next: string[]) => {
270
- selectionRef.current = next
271
- if (!controlled) setOwnSelection(next)
272
- changeHandlerRef.current?.(next)
273
- },
274
- [controlled],
275
- )
276
-
277
- /**
278
- * Every mounted point, by id. A registration is a ref rather than a value:
279
- * the value inside changes on every frame of a drag, and a registry keyed
280
- * on it would be rebuilt just as often.
281
- */
282
- const points = useRef(new Map<string, RefObject<PointRegistration>>())
283
-
284
- const registerPoint = useCallback(
285
- (id: string, entry: RefObject<PointRegistration>) => {
286
- points.current.set(id, entry)
287
- return () => {
288
- points.current.delete(id)
289
- }
290
- },
291
- [],
292
- )
293
-
294
- /** What the current drag picked up, and where those points started. */
295
- const dragRef = useRef<{ id: string; start: PointBaseType }[]>([])
296
-
297
- const snapshot = useCallback((ids: readonly string[]) => {
298
- return ids.flatMap((id) => {
299
- const registration = points.current.get(id)?.current
300
- return registration ? [{ id, start: { ...registration.value } }] : []
301
- })
302
- }, [])
303
-
304
- const applyDeltaTo = useCallback(
305
- (
306
- entries: { id: string; start: PointBaseType }[],
307
- delta: PointBaseType,
308
- ) => {
309
- const withRegistration = entries.flatMap((entry) => {
310
- const registration = points.current.get(entry.id)?.current
311
- return registration ? [{ ...entry, registration }] : []
312
- })
313
- const allowed = allowedDelta(delta, withRegistration)
314
- for (const { start, registration } of withRegistration) {
315
- if (registration.readonly) continue
316
- // Rounded here as well as in the pipeline: a move is a subtraction
317
- // and an addition of its own, and that is enough to put the binary
318
- // artefact back (0.2 + 0.1 lands on 0.30000000000000004).
319
- registration.onChange?.({
320
- x: toPrecision(start.x + allowed.x),
321
- y: toPrecision(start.y + allowed.y),
322
- })
323
- }
324
- },
325
- [],
326
- )
327
-
328
- const beginPointDrag = useCallback(
329
- (id: string, modifiers: ModifierState) => {
330
- if (!selectable) {
331
- dragRef.current = snapshot([id])
332
- return
333
- }
334
- const current = selectionRef.current
335
- // Ctrl / meta rather than shift: shift is the fine-adjustment key on
336
- // every control here, and it cannot be both.
337
- const additive = modifiers.ctrlKey || modifiers.metaKey
338
- let next: readonly string[]
339
- if (additive) {
340
- next = current.includes(id)
341
- ? current.filter((x) => x !== id)
342
- : [...current, id]
343
- } else if (current.includes(id)) {
344
- // Already part of a group: keep it, so the group can be dragged.
345
- next = current
346
- } else {
347
- next = [id]
348
- }
349
- changeSelection([...next])
350
- // A press that took the point out of the selection was a deselect, not
351
- // the start of a move, so there is nothing to drag.
352
- dragRef.current = next.includes(id) ? snapshot(next) : []
353
- },
354
- [selectable, changeSelection, snapshot],
355
- )
356
-
357
- const movePointDrag = useCallback(
358
- (delta: PointBaseType) => applyDeltaTo(dragRef.current, delta),
359
- [applyDeltaTo],
360
- )
361
-
362
- const nudgeSelection = useCallback(
363
- (id: string, delta: PointBaseType) => {
364
- // From wherever the points are now: a key press is not a drag, so
365
- // there is no earlier position to measure against.
366
- const ids = selectionRef.current.includes(id)
367
- ? selectionRef.current
368
- : [id]
369
- applyDeltaTo(snapshot(ids), delta)
370
- },
371
- [applyDeltaTo, snapshot],
372
- )
373
-
374
- const isPointElement = useCallback(
375
- (element: Element | null | undefined) => {
376
- if (!element) return false
377
- for (const [, entry] of points.current) {
378
- if (entry.current.element?.contains(element)) return true
379
- }
380
- return false
381
- },
382
- [],
383
- )
384
-
385
- const nudgeFocusedPoint = useCallback(
386
- (axis: 'x' | 'y', direction: number, modifiers: ModifierState) => {
387
- const container = containerRef.current
388
- const active = container?.ownerDocument.activeElement
389
- if (!container || !active || !container.contains(active)) return false
390
- for (const [id, entry] of points.current) {
391
- const { element, wheel, readonly, onChange, value } = entry.current
392
- // A point answers only for the focus inside its own inputs, so both
393
- // axes stay part of the same interaction.
394
- if (!element?.contains(active)) continue
395
- if (!wheel || readonly || !onChange) return false
396
- const next = applyDelta(
397
- value[axis],
398
- direction,
399
- wheel,
400
- AXIS,
401
- modifiers,
402
- )
403
- // As a move, so that the rest of the selection comes along and the
404
- // whole group stops together at the edge.
405
- nudgeSelection(id, {
406
- x: axis === 'x' ? next - value.x : 0,
407
- y: axis === 'y' ? next - value.y : 0,
408
- })
409
- return true
410
- }
411
- return false
412
- },
413
- [nudgeSelection],
414
- )
415
-
416
- // --- selection box ---
417
- // The box itself lives in the core: which items a rectangle covers, and
418
- // what a press adds to or replaces, are not React's to decide. What is
419
- // left here is the registry it reads and the state the box is drawn from.
420
189
  const [selectionBox, setSelectionBox] = useState<SelectionBoxRect | null>(
421
190
  null,
422
191
  )
423
- const selectionBoxRef = useRef<SelectionBoxInstance<string> | null>(null)
424
- selectionBoxRef.current ??= createSelectionBox<string>({
425
- *items(): Generator<readonly [string, XY<number>]> {
426
- for (const [id, entry] of points.current) {
427
- const { x, y } = entry.current.value
428
- yield [id, [x, y]]
429
- }
430
- },
431
- onBoxChange: setSelectionBox,
432
- })
433
-
434
- // The selection is reported to whoever owns it, and that handler is a new
435
- // function whenever the caller's is, so it is handed over rather than
436
- // closed over.
437
- useEffect(() => {
438
- selectionBoxRef.current?.update({ onSelectionChange: changeSelection })
439
- }, [changeSelection])
440
-
441
- useEffect(() => {
442
- const box = selectionBoxRef.current
443
- return () => box?.destroy()
444
- }, [])
445
192
 
446
- const beginSelectionBox = useCallback(
447
- (at: PointBaseType, modifiers: ModifierState) => {
448
- if (!selectable) return
449
- selectionBoxRef.current?.begin([at.x, at.y], {
450
- // Ctrl / meta rather than shift: shift is the fine-adjustment key on
451
- // every control here, and it cannot be both.
452
- additive: modifiers.ctrlKey || modifiers.metaKey,
453
- selection: selectionRef.current,
454
- })
455
- },
456
- [selectable],
193
+ const [editor] = useState(() =>
194
+ createPointsEditor({ onSelectionBoxChange: setSelectionBox }),
457
195
  )
458
196
 
459
- const moveSelectionBox = useCallback((to: PointBaseType) => {
460
- selectionBoxRef.current?.move([to.x, to.y])
461
- }, [])
197
+ // Drags read the selection from a native event handler, which runs after
198
+ // the commit, so pushing it from an effect is current by the time it
199
+ // matters. The handler goes the same way: it closes over this render.
200
+ useEffect(() => {
201
+ editor.update({
202
+ selectable,
203
+ selection,
204
+ onSelectionChange: (next) => {
205
+ if (!controlled) setOwnSelection(next)
206
+ onSelectionChange?.(next)
207
+ },
208
+ })
209
+ })
462
210
 
463
- const endSelectionBox = useCallback(() => {
464
- if (!selectionBoxRef.current?.end()) return
465
- // A selection box is drawn on the container, which is not a control and
466
- // cannot hold focus, so the press that started it left the focus on
467
- // nothing. The arrow keys and the wheel reach a point only through the
468
- // focus, so it is handed to one of the points the box selected —
469
- // whichever point takes it moves the whole selection.
470
- const [first] = selectionRef.current
471
- if (first) points.current.get(first)?.current.element?.focus()
472
- }, [])
211
+ useEffect(() => () => editor.destroy(), [editor])
473
212
 
474
213
  const context = useMemo(
475
214
  () => ({
476
215
  disabled,
477
- readonly,
216
+ readOnly,
478
217
  wheel,
479
218
  keyboard,
480
219
  dragSensitivity,
481
- externalStyles: { cursor },
220
+ dragCursor,
482
221
  containerRef,
483
222
  selectable,
484
223
  selection,
485
- registerPoint,
486
- isPointElement,
487
- beginPointDrag,
488
- movePointDrag,
489
- nudgeSelection,
490
- nudgeFocusedPoint,
224
+ editor,
491
225
  selectionBox,
492
- beginSelectionBox,
493
- moveSelectionBox,
494
- endSelectionBox,
495
226
  }),
496
227
  [
497
228
  disabled,
498
- readonly,
229
+ readOnly,
499
230
  wheel,
500
231
  keyboard,
501
232
  dragSensitivity,
502
- cursor,
233
+ dragCursor,
503
234
  selectable,
504
235
  selection,
505
- registerPoint,
506
- isPointElement,
507
- beginPointDrag,
508
- movePointDrag,
509
- nudgeSelection,
510
- nudgeFocusedPoint,
236
+ editor,
511
237
  selectionBox,
512
- beginSelectionBox,
513
- moveSelectionBox,
514
- endSelectionBox,
515
238
  ],
516
239
  )
517
240
 
@@ -521,11 +244,9 @@ export const Root = /* @__PURE__ */ forwardRef<HTMLDivElement, Props>(
521
244
  ref={forwardedRef}
522
245
  className={className}
523
246
  data-disabled={disabled ? '' : undefined}
524
- data-readonly={readonly ? '' : undefined}
247
+ data-readonly={readOnly ? '' : undefined}
525
248
  style={
526
249
  {
527
- '--width': cssLength(width),
528
- '--height': cssLength(height),
529
250
  // The layers inside are placed against this box.
530
251
  position: 'relative',
531
252
  ...style,
@@ -555,8 +276,4 @@ export {
555
276
  usePointsEditorContext,
556
277
  type PointsEditorContextValue,
557
278
  } from './context'
558
- export {
559
- clampPoint,
560
- type PointBaseType,
561
- type PointsEditorPointProps,
562
- } from './Point'
279
+ export { type PointsEditorPointProps } from './Point'
@@ -1,11 +1,12 @@
1
1
  import { ComponentPropsWithoutRef, CSSProperties } from 'react'
2
2
 
3
- import { cssLength } from '../_util/css-length'
3
+ import { type MarksOptions } from '@tremolo-ui/dom'
4
+ import { cssLength, sliderMarks } from '@tremolo-ui/dom/internal'
5
+
4
6
  import { Placement } from '../_util/Placement'
5
7
 
6
8
  import { useSliderContext } from './context'
7
9
  import { MarksOption } from './MarksOption'
8
- import { generateOptionsList, MarksOptions } from './type'
9
10
 
10
11
  import type { CSSVariables } from '../../css-variables'
11
12
 
@@ -40,12 +41,10 @@ export function Marks({
40
41
  const min = useSliderContext((s) => s.min)
41
42
  const max = useSliderContext((s) => s.max)
42
43
  const step = useSliderContext((s) => s.step)
43
- const vertical = useSliderContext((s) => s.vertical)
44
+ const vertical = useSliderContext((s) => s.orientation === 'vertical')
44
45
  const reverse = useSliderContext((s) => s.reverse)
45
46
 
46
- const optionsList = options
47
- ? generateOptionsList(options, min, max, step)
48
- : []
47
+ const optionsList = options ? sliderMarks(options, min, max, step) : []
49
48
  if (vertical !== reverse) optionsList.reverse()
50
49
 
51
50
  return (
@@ -1,8 +1,8 @@
1
- import { ComponentPropsWithoutRef, CSSProperties, useCallback } from 'react'
1
+ import { ComponentPropsWithoutRef, CSSProperties } from 'react'
2
2
 
3
- import { toFixed } from '@tremolo-ui/functions'
3
+ import { valuePercent } from '@tremolo-ui/dom'
4
+ import { cssLength } from '@tremolo-ui/dom/internal'
4
5
 
5
- import { cssLength } from '../_util/css-length'
6
6
  import { useCheckPlacement } from '../_util/Placement'
7
7
 
8
8
  import { useSliderContext } from './context'
@@ -29,8 +29,6 @@ export interface SliderMarksOptionProps {
29
29
  length?: number | string
30
30
  /** Space between the mark and the label. Sets `--gap`. */
31
31
  gap?: number | string
32
- /** Width of the label. Sets `--label-width`. */
33
- labelWidth?: number | string
34
32
  /** Classes for the mark and the label inside the option. */
35
33
  classes?: {
36
34
  mark?: string
@@ -43,7 +41,7 @@ export interface SliderMarksOptionProps {
43
41
  }
44
42
 
45
43
  style?: CSSProperties &
46
- CSSVariables<'thickness' | 'length' | 'gap' | 'label-width' | 'translate'>
44
+ CSSVariables<'thickness' | 'length' | 'gap' | 'translate'>
47
45
  }
48
46
 
49
47
  export function MarksOption({
@@ -53,7 +51,6 @@ export function MarksOption({
53
51
  thickness,
54
52
  length,
55
53
  gap,
56
- labelWidth,
57
54
  classes,
58
55
  styles,
59
56
  className,
@@ -66,17 +63,11 @@ export function MarksOption({
66
63
  const min = useSliderContext((s) => s.min)
67
64
  const max = useSliderContext((s) => s.max)
68
65
  const scale = useSliderContext((s) => s.scale)
69
- const vertical = useSliderContext((s) => s.vertical)
66
+ const vertical = useSliderContext((s) => s.orientation === 'vertical')
70
67
  const reverse = useSliderContext((s) => s.reverse)
71
68
 
72
- const calcPercent = useCallback(
73
- (value: number) => {
74
- // The marks have to sit on the same curve the thumb runs along.
75
- const percent = scale.normalize(value, min, max) * 100
76
- return toFixed(vertical !== reverse ? 100 - percent : percent)
77
- },
78
- [vertical, reverse, max, min, scale],
79
- )
69
+ // The marks have to sit on the same curve the thumb runs along.
70
+ const percent = valuePercent(value, { min, max, scale }, vertical !== reverse)
80
71
 
81
72
  return (
82
73
  <div
@@ -87,7 +78,6 @@ export function MarksOption({
87
78
  '--thickness': cssLength(thickness),
88
79
  '--length': cssLength(length),
89
80
  '--gap': cssLength(gap),
90
- '--label-width': cssLength(labelWidth),
91
81
  // The mechanics of the position below, as on the thumb.
92
82
  position: 'absolute',
93
83
  translate: vertical
@@ -96,8 +86,8 @@ export function MarksOption({
96
86
  zIndex: 10,
97
87
  ...style,
98
88
  // Where the mark belongs on the track: the value, not a style.
99
- left: !vertical ? `${calcPercent(value)}%` : undefined,
100
- top: vertical ? `${calcPercent(value)}%` : undefined,
89
+ left: !vertical ? `${percent}%` : undefined,
90
+ top: vertical ? `${percent}%` : undefined,
101
91
  } as CSSProperties
102
92
  }
103
93
  data-orientation={vertical ? 'vertical' : 'horizontal'}
@@ -1,9 +1,10 @@
1
1
  import {
2
2
  ComponentPropsWithoutRef,
3
+ CSSProperties,
3
4
  forwardRef,
5
+ Ref,
4
6
  useImperativeHandle,
5
7
  useRef,
6
- CSSProperties,
7
8
  } from 'react'
8
9
 
9
10
  import { useCheckPlacement } from '../_util/Placement'
@@ -22,6 +23,12 @@ export interface SliderThumbProps {
22
23
  color?: string
23
24
 
24
25
  style?: CSSProperties & CSSVariables<'color' | 'translate'>
26
+
27
+ /**
28
+ * Receives `focus` and `blur` for the range input inside, which do nothing
29
+ * while the slider is disabled. `ref` reaches the thumb element itself.
30
+ */
31
+ actionsRef?: Ref<SliderThumbMethods>
25
32
  }
26
33
 
27
34
  export interface SliderThumbMethods {
@@ -32,7 +39,7 @@ export interface SliderThumbMethods {
32
39
  type Props = SliderThumbProps &
33
40
  Omit<ComponentPropsWithoutRef<'div'>, keyof SliderThumbProps>
34
41
 
35
- export const Thumb = /* @__PURE__ */ forwardRef<SliderThumbMethods, Props>(
42
+ export const Thumb = /* @__PURE__ */ forwardRef<HTMLDivElement, Props>(
36
43
  function Thumb(
37
44
  {
38
45
  color,
@@ -48,6 +55,7 @@ export const Thumb = /* @__PURE__ */ forwardRef<SliderThumbMethods, Props>(
48
55
  'aria-labelledby': ariaLabelledby,
49
56
  'aria-describedby': ariaDescribedby,
50
57
  'aria-valuetext': ariaValuetext,
58
+ actionsRef,
51
59
  ...props
52
60
  },
53
61
  forwardedRef,
@@ -58,9 +66,9 @@ export const Thumb = /* @__PURE__ */ forwardRef<SliderThumbMethods, Props>(
58
66
  min,
59
67
  max,
60
68
  step,
61
- vertical,
69
+ orientation,
62
70
  disabled,
63
- readonly,
71
+ readOnly,
64
72
  onChange,
65
73
  percent,
66
74
  thumbRef,
@@ -78,15 +86,16 @@ export const Thumb = /* @__PURE__ */ forwardRef<SliderThumbMethods, Props>(
78
86
  },
79
87
  })
80
88
 
81
- useImperativeHandle(forwardedRef, methods, [disabled])
89
+ useImperativeHandle(actionsRef, methods, [disabled])
82
90
  // Root focuses the thumb when a drag starts, wherever the user placed it.
83
91
  useImperativeHandle(thumbRef, methods, [disabled])
84
92
 
85
93
  return (
86
94
  <div
95
+ ref={forwardedRef}
87
96
  className={className}
88
97
  data-disabled={disabled ? '' : undefined}
89
- data-readonly={readonly ? '' : undefined}
98
+ data-readonly={readOnly ? '' : undefined}
90
99
  {...props}
91
100
  style={{
92
101
  ...{ '--color': color },
@@ -101,8 +110,8 @@ export const Thumb = /* @__PURE__ */ forwardRef<SliderThumbMethods, Props>(
101
110
  // Where the thumb sits is the component's decision, not a style: a
102
111
  // `left` from the caller would take it off the track, so it is
103
112
  // written after theirs.
104
- top: vertical ? `${percent}%` : '50%',
105
- left: !vertical ? `${percent}%` : '50%',
113
+ top: orientation === 'vertical' ? `${percent}%` : '50%',
114
+ left: orientation === 'vertical' ? '50%' : `${percent}%`,
106
115
  }}
107
116
  >
108
117
  <VisuallyHiddenRangeInput
@@ -112,14 +121,14 @@ export const Thumb = /* @__PURE__ */ forwardRef<SliderThumbMethods, Props>(
112
121
  max={max}
113
122
  step={step}
114
123
  disabled={disabled}
115
- aria-readonly={readonly}
116
- aria-orientation={vertical ? 'vertical' : 'horizontal'}
124
+ aria-readonly={readOnly}
125
+ aria-orientation={orientation}
117
126
  aria-label={ariaLabel}
118
127
  aria-labelledby={ariaLabelledby}
119
128
  aria-describedby={ariaDescribedby}
120
129
  aria-valuetext={ariaValuetext}
121
130
  onChange={(event) => {
122
- if (readonly) {
131
+ if (readOnly) {
123
132
  event.currentTarget.value = String(value)
124
133
  return
125
134
  }