@tremolo-ui/react 0.7.0 → 0.8.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 (47) hide show
  1. package/dist/index.cjs +346 -1015
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +76 -221
  4. package/dist/index.d.cts.map +1 -1
  5. package/dist/index.d.ts +76 -221
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +333 -978
  8. package/dist/index.js.map +1 -1
  9. package/package.json +3 -3
  10. package/src/components/DropZone/index.tsx +2 -1
  11. package/src/components/FileInput/index.tsx +8 -13
  12. package/src/components/Knob/ActiveLine.tsx +5 -3
  13. package/src/components/Knob/InactiveLine.tsx +6 -4
  14. package/src/components/Knob/SVGRoot.tsx +3 -3
  15. package/src/components/Knob/Thumb.tsx +3 -2
  16. package/src/components/Knob/context.tsx +3 -74
  17. package/src/components/Knob/index.tsx +19 -16
  18. package/src/components/NumberInput/InputField.tsx +12 -27
  19. package/src/components/NumberInput/Stepper.tsx +49 -97
  20. package/src/components/NumberInput/StepperButton.tsx +3 -4
  21. package/src/components/NumberInput/context.tsx +2 -1
  22. package/src/components/NumberInput/index.tsx +40 -71
  23. package/src/components/Piano/index.tsx +35 -142
  24. package/src/components/PointsEditor/Container.tsx +15 -26
  25. package/src/components/PointsEditor/Point.tsx +38 -85
  26. package/src/components/PointsEditor/context.tsx +5 -56
  27. package/src/components/PointsEditor/index.tsx +29 -311
  28. package/src/components/Slider/Marks.tsx +3 -5
  29. package/src/components/Slider/MarksOption.tsx +7 -18
  30. package/src/components/Slider/Track.tsx +2 -1
  31. package/src/components/Slider/index.tsx +29 -28
  32. package/src/components/XYPad/Area.tsx +27 -49
  33. package/src/components/XYPad/Thumb.tsx +3 -1
  34. package/src/components/XYPad/context.tsx +1 -3
  35. package/src/components/XYPad/index.tsx +33 -46
  36. package/src/components/_util/VisuallyHiddenRangeInput.tsx +1 -1
  37. package/src/hooks/_internal/useCheckSteps.ts +9 -146
  38. package/src/hooks/useLongPress.ts +35 -51
  39. package/src/hooks/useMIDIAccess.ts +0 -8
  40. package/src/hooks/useMIDIInput.ts +0 -3
  41. package/src/index.ts +2 -27
  42. package/src/components/Piano/keyboard-shortcuts.ts +0 -61
  43. package/src/components/Slider/type.ts +0 -46
  44. package/src/components/_util/css-length.ts +0 -13
  45. package/src/components/_util/decimal-digits.ts +0 -23
  46. package/src/components/_util/visually-hidden.ts +0 -25
  47. 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,25 @@ 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,
17
+ POINTS_EDITOR_DEFAULT_KEYBOARD,
18
+ POINTS_EDITOR_DEFAULT_WHEEL,
21
19
  type SelectionBoxRect,
22
- type XY,
23
20
  } 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'
28
21
 
29
22
  import { Background } from './Background'
30
23
  import { Container } from './Container'
31
- import { type PointRegistration, PointsEditorProvider } from './context'
32
- import { AXIS, Point, type PointBaseType } from './Point'
24
+ import { PointsEditorProvider } from './context'
25
+ import { Point } from './Point'
33
26
  import { SelectionBox } from './SelectionBox'
34
27
 
35
- import type { CSSVariables } from '../../css-variables'
36
-
37
28
  /** One array for every editor with selection turned off, so memos hold still. */
38
29
  const EMPTY: readonly string[] = []
39
30
 
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
31
  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
32
  /**
99
33
  * Make the points unchangeable and remove them from the tab order.
100
34
  * The parts carry `data-disabled` while it is set.
@@ -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,12 +154,10 @@ 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
158
  readonly = false,
229
- wheel = DEFAULT_WHEEL,
230
- keyboard = DEFAULT_KEYBOARD,
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,
@@ -242,12 +172,13 @@ 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
177
  const { cursor = 'grabbing' } = externalStyles ?? {}
249
178
 
250
179
  // --- selection ---
180
+ // The selection lives here, where React state can hold it; which points a
181
+ // press or a box selects, and how a selection moves, is the core's.
251
182
  const controlled = selectionProp !== undefined
252
183
  const [ownSelection, setOwnSelection] = useState<string[]>(
253
184
  defaultSelection ?? [],
@@ -256,220 +187,29 @@ export const Root = /* @__PURE__ */ forwardRef<HTMLDivElement, Props>(
256
187
  // point it started on and `data-selected` never turns on.
257
188
  const selection = selectable ? (selectionProp ?? ownSelection) : EMPTY
258
189
 
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
190
  const [selectionBox, setSelectionBox] = useState<SelectionBoxRect | null>(
421
191
  null,
422
192
  )
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
193
 
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],
194
+ const [editor] = useState(() =>
195
+ createPointsEditor({ onSelectionBoxChange: setSelectionBox }),
457
196
  )
458
197
 
459
- const moveSelectionBox = useCallback((to: PointBaseType) => {
460
- selectionBoxRef.current?.move([to.x, to.y])
461
- }, [])
198
+ // Drags read the selection from a native event handler, which runs after
199
+ // the commit, so pushing it from an effect is current by the time it
200
+ // matters. The handler goes the same way: it closes over this render.
201
+ useEffect(() => {
202
+ editor.update({
203
+ selectable,
204
+ selection,
205
+ onSelectionChange: (next) => {
206
+ if (!controlled) setOwnSelection(next)
207
+ onSelectionChange?.(next)
208
+ },
209
+ })
210
+ })
462
211
 
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
- }, [])
212
+ useEffect(() => () => editor.destroy(), [editor])
473
213
 
474
214
  const context = useMemo(
475
215
  () => ({
@@ -482,16 +222,8 @@ export const Root = /* @__PURE__ */ forwardRef<HTMLDivElement, Props>(
482
222
  containerRef,
483
223
  selectable,
484
224
  selection,
485
- registerPoint,
486
- isPointElement,
487
- beginPointDrag,
488
- movePointDrag,
489
- nudgeSelection,
490
- nudgeFocusedPoint,
225
+ editor,
491
226
  selectionBox,
492
- beginSelectionBox,
493
- moveSelectionBox,
494
- endSelectionBox,
495
227
  }),
496
228
  [
497
229
  disabled,
@@ -502,16 +234,8 @@ export const Root = /* @__PURE__ */ forwardRef<HTMLDivElement, Props>(
502
234
  cursor,
503
235
  selectable,
504
236
  selection,
505
- registerPoint,
506
- isPointElement,
507
- beginPointDrag,
508
- movePointDrag,
509
- nudgeSelection,
510
- nudgeFocusedPoint,
237
+ editor,
511
238
  selectionBox,
512
- beginSelectionBox,
513
- moveSelectionBox,
514
- endSelectionBox,
515
239
  ],
516
240
  )
517
241
 
@@ -524,8 +248,6 @@ export const Root = /* @__PURE__ */ forwardRef<HTMLDivElement, Props>(
524
248
  data-readonly={readonly ? '' : undefined}
525
249
  style={
526
250
  {
527
- '--width': cssLength(width),
528
- '--height': cssLength(height),
529
251
  // The layers inside are placed against this box.
530
252
  position: 'relative',
531
253
  ...style,
@@ -555,8 +277,4 @@ export {
555
277
  usePointsEditorContext,
556
278
  type PointsEditorContextValue,
557
279
  } from './context'
558
- export {
559
- clampPoint,
560
- type PointBaseType,
561
- type PointsEditorPointProps,
562
- } from './Point'
280
+ export { type PointsEditorPointProps } from './Point'
@@ -1,11 +1,11 @@
1
1
  import { ComponentPropsWithoutRef, CSSProperties } from 'react'
2
2
 
3
- import { cssLength } from '../_util/css-length'
3
+ import { cssLength, type MarksOptions, sliderMarks } from '@tremolo-ui/dom'
4
+
4
5
  import { Placement } from '../_util/Placement'
5
6
 
6
7
  import { useSliderContext } from './context'
7
8
  import { MarksOption } from './MarksOption'
8
- import { generateOptionsList, MarksOptions } from './type'
9
9
 
10
10
  import type { CSSVariables } from '../../css-variables'
11
11
 
@@ -43,9 +43,7 @@ export function Marks({
43
43
  const vertical = useSliderContext((s) => s.vertical)
44
44
  const reverse = useSliderContext((s) => s.reverse)
45
45
 
46
- const optionsList = options
47
- ? generateOptionsList(options, min, max, step)
48
- : []
46
+ const optionsList = options ? sliderMarks(options, min, max, step) : []
49
47
  if (vertical !== reverse) optionsList.reverse()
50
48
 
51
49
  return (
@@ -1,8 +1,7 @@
1
- import { ComponentPropsWithoutRef, CSSProperties, useCallback } from 'react'
1
+ import { ComponentPropsWithoutRef, CSSProperties } from 'react'
2
2
 
3
- import { toFixed } from '@tremolo-ui/functions'
3
+ import { cssLength, valuePercent } from '@tremolo-ui/dom'
4
4
 
5
- import { cssLength } from '../_util/css-length'
6
5
  import { useCheckPlacement } from '../_util/Placement'
7
6
 
8
7
  import { useSliderContext } from './context'
@@ -29,8 +28,6 @@ export interface SliderMarksOptionProps {
29
28
  length?: number | string
30
29
  /** Space between the mark and the label. Sets `--gap`. */
31
30
  gap?: number | string
32
- /** Width of the label. Sets `--label-width`. */
33
- labelWidth?: number | string
34
31
  /** Classes for the mark and the label inside the option. */
35
32
  classes?: {
36
33
  mark?: string
@@ -43,7 +40,7 @@ export interface SliderMarksOptionProps {
43
40
  }
44
41
 
45
42
  style?: CSSProperties &
46
- CSSVariables<'thickness' | 'length' | 'gap' | 'label-width' | 'translate'>
43
+ CSSVariables<'thickness' | 'length' | 'gap' | 'translate'>
47
44
  }
48
45
 
49
46
  export function MarksOption({
@@ -53,7 +50,6 @@ export function MarksOption({
53
50
  thickness,
54
51
  length,
55
52
  gap,
56
- labelWidth,
57
53
  classes,
58
54
  styles,
59
55
  className,
@@ -69,14 +65,8 @@ export function MarksOption({
69
65
  const vertical = useSliderContext((s) => s.vertical)
70
66
  const reverse = useSliderContext((s) => s.reverse)
71
67
 
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
- )
68
+ // The marks have to sit on the same curve the thumb runs along.
69
+ const percent = valuePercent(value, { min, max, scale }, vertical !== reverse)
80
70
 
81
71
  return (
82
72
  <div
@@ -87,7 +77,6 @@ export function MarksOption({
87
77
  '--thickness': cssLength(thickness),
88
78
  '--length': cssLength(length),
89
79
  '--gap': cssLength(gap),
90
- '--label-width': cssLength(labelWidth),
91
80
  // The mechanics of the position below, as on the thumb.
92
81
  position: 'absolute',
93
82
  translate: vertical
@@ -96,8 +85,8 @@ export function MarksOption({
96
85
  zIndex: 10,
97
86
  ...style,
98
87
  // Where the mark belongs on the track: the value, not a style.
99
- left: !vertical ? `${calcPercent(value)}%` : undefined,
100
- top: vertical ? `${calcPercent(value)}%` : undefined,
88
+ left: !vertical ? `${percent}%` : undefined,
89
+ top: vertical ? `${percent}%` : undefined,
101
90
  } as CSSProperties
102
91
  }
103
92
  data-orientation={vertical ? 'vertical' : 'horizontal'}
@@ -1,7 +1,8 @@
1
1
  import { ComponentPropsWithoutRef, CSSProperties, forwardRef } from 'react'
2
2
 
3
+ import { cssLength } from '@tremolo-ui/dom'
4
+
3
5
  import { useComposedRefs } from '../../compose-refs'
4
- import { cssLength } from '../_util/css-length'
5
6
  import { Placement } from '../_util/Placement'
6
7
 
7
8
  import { useSliderContext } from './context'
@@ -11,23 +11,24 @@ import React, {
11
11
 
12
12
  import {
13
13
  applyDelta,
14
+ arrowKeyDirection,
14
15
  type AxisOptions,
16
+ DEFAULT_DRAG_SENSITIVITY,
17
+ DEFAULT_KEYBOARD_OPTIONS,
18
+ DEFAULT_WHEEL_OPTIONS,
15
19
  type InputEventOption,
16
20
  type ModifierValue,
17
21
  selectModifier,
22
+ valuePercent,
23
+ wheelDirection,
18
24
  type XY,
19
25
  } from '@tremolo-ui/dom'
20
- import { linearScale, type Scale, toFixed } from '@tremolo-ui/functions'
26
+ import { linearScale, type Scale } from '@tremolo-ui/functions'
21
27
 
22
28
  import { useComposedRefs } from '../../compose-refs'
23
29
  import { useCheckSteps } from '../../hooks/_internal/useCheckSteps'
24
30
  import { useDragValue } from '../../hooks/useDragValue'
25
31
  import { useWheel } from '../../hooks/useWheel'
26
- import {
27
- DEFAULT_DRAG_SENSITIVITY,
28
- DEFAULT_KEYBOARD_OPTIONS,
29
- DEFAULT_WHEEL_OPTIONS,
30
- } from '../../input-event'
31
32
 
32
33
  import { SliderProvider } from './context'
33
34
  import { Marks } from './Marks'
@@ -216,15 +217,13 @@ export const Root = /* @__PURE__ */ forwardRef<SliderMethods, Props>(
216
217
  const externalStyles = { ...defaultExternalStyles, ..._externalStyles }
217
218
  const inactive = disabled || readonly
218
219
 
219
- const p = toFixed(scale.normalize(value, min, max) * 100)
220
- const rev = toFixed(100 - p)
221
- // NOTE
220
+ // Measured from the left or the top, as CSS places things:
222
221
  // normal -> normal (right)
223
- // vertical -> rev (up)
224
- // reverse -> rev (left)
222
+ // vertical -> reversed (up)
223
+ // reverse -> reversed (left)
225
224
  // vertical & reverse -> normal (down)
226
225
  const displayReversed = vertical !== reverse
227
- const percent = displayReversed ? rev : p
226
+ const percent = valuePercent(value, { min, max, scale }, displayReversed)
228
227
 
229
228
  // --- internal functions ---
230
229
  // The pointer is normalized against the track on both axes; only the one
@@ -239,14 +238,19 @@ export const Root = /* @__PURE__ */ forwardRef<SliderMethods, Props>(
239
238
 
240
239
  const handleKeyDown = useCallback(
241
240
  (event: React.KeyboardEvent<HTMLDivElement>) => {
242
- const key = event.key
243
- if (!['ArrowRight', 'ArrowLeft', 'ArrowUp', 'ArrowDown'].includes(key))
244
- return
241
+ const direction = arrowKeyDirection(event.key)
242
+ if (direction === null) return
245
243
  event.preventDefault()
246
244
  if (!keyboard || !onChange || inactive) return
247
- let direction = key === 'ArrowRight' || key === 'ArrowUp' ? 1 : -1
248
- if (reverse) direction *= -1
249
- onChange(applyDelta(value, direction, keyboard, axis, event))
245
+ onChange(
246
+ applyDelta(
247
+ value,
248
+ reverse ? -direction : direction,
249
+ keyboard,
250
+ axis,
251
+ event,
252
+ ),
253
+ )
250
254
  },
251
255
  [keyboard, onChange, inactive, reverse, value, axis],
252
256
  )
@@ -279,16 +283,14 @@ export const Root = /* @__PURE__ */ forwardRef<SliderMethods, Props>(
279
283
 
280
284
  const wheelRefCallback = useWheel<HTMLDivElement>((event) => {
281
285
  if (!wheel || !onChange || inactive) return
286
+ // A notch the slider does not read — a sideways scroll on a vertical
287
+ // slider — is left to the page rather than swallowed.
288
+ const direction = wheelDirection(event, { horizontal: !vertical })
289
+ if (direction === null) return
282
290
  event.preventDefault()
283
- let direction
284
- if (!vertical && event.deltaX !== 0) {
285
- direction = event.deltaX > 0 ? 1 : -1
286
- } else {
287
- if (event.deltaY === 0) return
288
- direction = event.deltaY > 0 ? -1 : 1
289
- }
290
- if (reverse) direction *= -1
291
- onChange(applyDelta(value, direction, wheel, axis, event))
291
+ onChange(
292
+ applyDelta(value, reverse ? -direction : direction, wheel, axis, event),
293
+ )
292
294
  }, WHEEL_OPTIONS)
293
295
 
294
296
  // Composed once, so React attaches the refs a single time instead of
@@ -394,4 +396,3 @@ export { type SliderThumbMethods, type SliderThumbProps } from './Thumb'
394
396
  export { type SliderTrackProps } from './Track'
395
397
  export { type SliderMarksProps } from './Marks'
396
398
  export { type SliderMarksOptionProps } from './MarksOption'
397
- export { type MarksOptions } from './type'