@tremolo-ui/dom 0.6.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.
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Which way an input moves a value, before any amount is applied.
3
+ *
4
+ * `applyDelta` takes the direction as given, since which key or which sign of
5
+ * `deltaY` counts as "up" differs per control. The answers are collected here
6
+ * so that every wrapper gives the same one: a knob that turns the other way
7
+ * in one framework would be a bug nobody could see from the code.
8
+ *
9
+ * Two families, by what the control looks like:
10
+ *
11
+ * - **one value** (`Knob`, `Slider`, `NumberInput`): right and up raise it
12
+ * - **a position on screen** (`XYPad`, `PointsEditor`): in screen coordinates,
13
+ * x growing rightwards and y growing downwards, plus which axis moves
14
+ *
15
+ * A control that runs the other way (`reverse`) flips the result itself.
16
+ */
17
+
18
+ /** One of the four arrow keys, as `KeyboardEvent.key` names it. */
19
+ export type ArrowKey = 'ArrowRight' | 'ArrowLeft' | 'ArrowUp' | 'ArrowDown'
20
+
21
+ /** Is `key` one of the four arrow keys? */
22
+ export function isArrowKey(key: string): key is ArrowKey {
23
+ return (
24
+ key === 'ArrowRight' ||
25
+ key === 'ArrowLeft' ||
26
+ key === 'ArrowUp' ||
27
+ key === 'ArrowDown'
28
+ )
29
+ }
30
+
31
+ /** A move along one of the two axes of a position on screen. */
32
+ export interface AxisMove {
33
+ /** 0 = x, 1 = y. */
34
+ axis: 0 | 1
35
+ /** `1` towards the right or the bottom, `-1` towards the left or the top. */
36
+ direction: 1 | -1
37
+ }
38
+
39
+ /**
40
+ * The direction an arrow key moves a single value: right and up raise it.
41
+ * `null` for any other key.
42
+ */
43
+ export function arrowKeyDirection(key: string): 1 | -1 | null {
44
+ if (!isArrowKey(key)) return null
45
+ return key === 'ArrowRight' || key === 'ArrowUp' ? 1 : -1
46
+ }
47
+
48
+ /**
49
+ * The axis and direction an arrow key moves a position on screen. `null` for
50
+ * any other key.
51
+ *
52
+ * The key picks the axis, whichever element inside the control holds the
53
+ * focus: a two-dimensional control is one control to the person moving it.
54
+ */
55
+ export function arrowKeyMove(key: string): AxisMove | null {
56
+ if (!isArrowKey(key)) return null
57
+ return {
58
+ axis: key === 'ArrowRight' || key === 'ArrowLeft' ? 0 : 1,
59
+ direction: key === 'ArrowLeft' || key === 'ArrowUp' ? -1 : 1,
60
+ }
61
+ }
62
+
63
+ export interface WheelDirectionOptions {
64
+ /**
65
+ * Read horizontal scrolling as well, for a control laid out horizontally:
66
+ * scrolling right raises the value. Vertical scrolling still counts when
67
+ * there is no horizontal movement.
68
+ *
69
+ * @default false
70
+ */
71
+ horizontal?: boolean
72
+ }
73
+
74
+ /**
75
+ * The direction one wheel event moves a single value: scrolling up raises it.
76
+ * `null` when the event carries no movement the control reads.
77
+ */
78
+ export function wheelDirection(
79
+ event: Pick<WheelEvent, 'deltaX' | 'deltaY'>,
80
+ { horizontal = false }: WheelDirectionOptions = {},
81
+ ): 1 | -1 | null {
82
+ if (horizontal && event.deltaX !== 0) return event.deltaX > 0 ? 1 : -1
83
+ if (event.deltaY === 0) return null
84
+ return event.deltaY > 0 ? -1 : 1
85
+ }
86
+
87
+ /**
88
+ * The axis and direction one wheel event moves a position on screen. `null`
89
+ * when the event carries no movement.
90
+ *
91
+ * Scrolling moves y, and shift switches to x. Browsers turn shift+wheel into
92
+ * horizontal scrolling: `deltaY` comes out empty and `deltaX` carries the
93
+ * movement. Reading whichever axis moved keeps shift working as the x-axis
94
+ * modifier — and picks up a trackpad's own horizontal gesture, which never
95
+ * had a modifier.
96
+ */
97
+ export function wheelMove(
98
+ event: Pick<WheelEvent, 'deltaX' | 'deltaY' | 'shiftKey'>,
99
+ ): AxisMove | null {
100
+ const horizontal = event.deltaX !== 0
101
+ const delta = horizontal ? event.deltaX : event.deltaY
102
+ if (delta === 0) return null
103
+ return {
104
+ axis: horizontal || event.shiftKey ? 0 : 1,
105
+ direction: delta < 0 ? -1 : 1,
106
+ }
107
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Options for setting the amount of keyboard and mouse wheel changes.
3
+ */
4
+ export type InputEventOption = readonly ['normalized' | 'raw', number]
5
+
6
+ /**
7
+ * A modifier key that can carry an amount of its own.
8
+ *
9
+ * `ctrl` and `meta` are kept apart rather than folded into one "command" key:
10
+ * a plugin UI that mirrors a desktop host usually wants the same physical key
11
+ * on every platform, not the platform's own convention.
12
+ */
13
+ export type Modifier = 'shift' | 'alt' | 'ctrl' | 'meta'
14
+
15
+ /** The modifier flags of a `WheelEvent` or a `KeyboardEvent`. */
16
+ export interface ModifierState {
17
+ shiftKey: boolean
18
+ altKey: boolean
19
+ ctrlKey: boolean
20
+ metaKey: boolean
21
+ }
22
+
23
+ /** One setting per modifier key, with `default` for none of them. */
24
+ type ModifierSetting = number | InputEventOption
25
+
26
+ export type ModifierMap<T extends ModifierSetting> = { default: T } & Partial<
27
+ Record<Modifier, T>
28
+ >
29
+
30
+ /**
31
+ * A single setting, or one per modifier key.
32
+ *
33
+ * @example
34
+ * ['raw', 1]
35
+ * { default: ['raw', 1], shift: ['raw', 0.1] }
36
+ */
37
+ export type ModifierValue<T extends ModifierSetting> = T | ModifierMap<T>
38
+
39
+ /**
40
+ * Checked in this order, and the first one that is both held and configured
41
+ * wins. Fixing an order is what keeps two modifiers held at once from
42
+ * behaving differently between browsers.
43
+ */
44
+ const MODIFIER_ORDER = ['meta', 'ctrl', 'alt', 'shift'] as const
45
+
46
+ const MODIFIER_FLAG = {
47
+ meta: 'metaKey',
48
+ ctrl: 'ctrlKey',
49
+ alt: 'altKey',
50
+ shift: 'shiftKey',
51
+ } as const satisfies Record<Modifier, keyof ModifierState>
52
+
53
+ /**
54
+ * A map is the only form with a `default` key, which is what tells it apart
55
+ * from a bare setting. Tuples are arrays, so they never match.
56
+ */
57
+ function isModifierMap<T extends ModifierSetting>(
58
+ value: ModifierValue<T>,
59
+ ): value is ModifierMap<T> {
60
+ return (
61
+ typeof value === 'object' &&
62
+ value !== null &&
63
+ !Array.isArray(value) &&
64
+ 'default' in value
65
+ )
66
+ }
67
+
68
+ /**
69
+ * Pick the setting that applies, given the modifier keys being held.
70
+ *
71
+ * @example
72
+ * selectModifier({ default: 1, shift: 0.1 }, event)
73
+ */
74
+ export function selectModifier<T extends ModifierSetting>(
75
+ options: ModifierValue<T>,
76
+ modifiers?: ModifierState,
77
+ ): { value: T; modifier: Modifier | null } {
78
+ if (!isModifierMap(options)) {
79
+ // TypeScript cannot subtract the map from `ModifierValue<T>` while `T` is
80
+ // still a type parameter, so the other half has to be spelled out.
81
+ return { value: options as T, modifier: null }
82
+ }
83
+ if (modifiers) {
84
+ for (const modifier of MODIFIER_ORDER) {
85
+ const value = options[modifier]
86
+ // Compared against undefined rather than checked for truthiness: 0 is a
87
+ // legitimate setting.
88
+ if (value !== undefined && modifiers[MODIFIER_FLAG[modifier]]) {
89
+ return { value, modifier }
90
+ }
91
+ }
92
+ }
93
+ return { value: options.default, modifier: null }
94
+ }
95
+
96
+ /**
97
+ * Turn every entry of a setting into another kind of setting, keeping which
98
+ * modifier each belongs to.
99
+ *
100
+ * A drag sensitivity is a number and a keyboard amount is a tuple, but the two
101
+ * describe the same thing from the caller's side. This carries one over to the
102
+ * other so that a component can hand a sensitivity to {@link applyDelta}
103
+ * without unpicking the modifier map itself — which matters, since naming a
104
+ * modifier is also what takes `step` out of the pipeline.
105
+ *
106
+ * @example
107
+ * mapModifier({ default: 1, shift: 0.1 }, (f) => ['raw', step * f])
108
+ * // { default: ['raw', 1], shift: ['raw', 0.1] }
109
+ */
110
+ export function mapModifier<
111
+ T extends ModifierSetting,
112
+ U extends ModifierSetting,
113
+ >(options: ModifierValue<T>, fn: (value: T) => U): ModifierValue<U> {
114
+ if (!isModifierMap(options)) return fn(options as T)
115
+ const mapped = { default: fn(options.default) } as ModifierMap<U>
116
+ for (const modifier of MODIFIER_ORDER) {
117
+ const value = options[modifier]
118
+ if (value !== undefined) mapped[modifier] = fn(value)
119
+ }
120
+ return mapped
121
+ }
@@ -0,0 +1,100 @@
1
+ import { radian, type Scale } from '@tremolo-ui/functions'
2
+
3
+ /**
4
+ * Width and height of the viewBox a knob is drawn in. The arcs and the thumb
5
+ * are laid out in these units, and the SVG scales them to the knob's size.
6
+ */
7
+ export const KNOB_VIEWBOX_SIZE = 100
8
+
9
+ const center = KNOB_VIEWBOX_SIZE / 2
10
+
11
+ export interface KnobAngleOptions {
12
+ value: number
13
+ min: number
14
+ max: number
15
+ scale: Scale
16
+ /** Where the active arc starts from, so that it can grow from the middle. */
17
+ startValue: number
18
+ /** How far the knob turns from `min` to `max`, in degrees. */
19
+ angleRange: number
20
+ }
21
+
22
+ /**
23
+ * The angles a knob is drawn with, in degrees clockwise from the top.
24
+ *
25
+ * The travel is centred on the top, so `angleRange` of 270 runs from -135 to
26
+ * 135. Derived from the value alone, so a wrapper can call it while rendering.
27
+ */
28
+ export interface KnobAngles {
29
+ /** The value, normalized to 0..1 along the scale. */
30
+ p: number
31
+ /** Where the travel starts. */
32
+ r1: number
33
+ /** Where the active arc starts: the lower of the value and `startValue`. */
34
+ r2: number
35
+ /** Where the active arc ends: the higher of the value and `startValue`. */
36
+ r3: number
37
+ /** Where the travel ends. */
38
+ r4: number
39
+ }
40
+
41
+ export function knobAngles({
42
+ value,
43
+ min,
44
+ max,
45
+ scale,
46
+ startValue,
47
+ angleRange,
48
+ }: KnobAngleOptions): KnobAngles {
49
+ const p = scale.normalize(value, min, max)
50
+ const s = scale.normalize(startValue, min, max)
51
+ const r1 = -angleRange / 2
52
+ const r2 = r1 + Math.min(p, s) * angleRange
53
+ const r3 = r1 + Math.max(p, s) * angleRange
54
+ const r4 = angleRange / 2
55
+ return { p, r1, r2, r3, r4 }
56
+ }
57
+
58
+ /**
59
+ * The point at `angle` on a circle of `radius` around the centre of the
60
+ * viewBox.
61
+ *
62
+ * The radius depends on the stroke of the line being drawn, which each arc
63
+ * has its own of, so the point is found per arc rather than once for the knob.
64
+ */
65
+ export function knobArcPoint(angle: number, radius: number) {
66
+ return {
67
+ x: center + radius * Math.cos(radian(angle - 90)),
68
+ y: center + radius * Math.sin(radian(angle - 90)),
69
+ }
70
+ }
71
+
72
+ /**
73
+ * The radius that keeps a stroke of `strokeWidth` inside the viewBox: half of
74
+ * the stroke falls outside the path it is drawn along.
75
+ */
76
+ export function knobArcRadius(strokeWidth: number | string | undefined) {
77
+ const width =
78
+ typeof strokeWidth === 'number'
79
+ ? strokeWidth
80
+ : Number.parseFloat(String(strokeWidth))
81
+ return Number.isFinite(width) ? center - width / 2 : center
82
+ }
83
+
84
+ /** Build an SVG path for an arc, splitting full turns into drawable segments. */
85
+ export function knobArcPath(
86
+ startAngle: number,
87
+ endAngle: number,
88
+ radius: number,
89
+ ) {
90
+ const start = knobArcPoint(startAngle, radius)
91
+ const sweep = endAngle - startAngle
92
+ const segmentCount = Math.max(1, Math.ceil(Math.abs(sweep) / 180))
93
+ const segmentSweep = sweep / segmentCount
94
+ let path = `M ${start.x} ${start.y}`
95
+ for (let i = 1; i <= segmentCount; i += 1) {
96
+ const end = knobArcPoint(startAngle + segmentSweep * i, radius)
97
+ path += ` A ${radius} ${radius} 0 0 ${segmentSweep >= 0 ? 1 : 0} ${end.x} ${end.y}`
98
+ }
99
+ return path
100
+ }
@@ -0,0 +1,139 @@
1
+ import { type ValueRange } from '@tremolo-ui/functions'
2
+
3
+ import { applyDelta } from '../input/apply-delta'
4
+ import { DEFAULT_DRAG_SENSITIVITY } from '../input/defaults'
5
+ import {
6
+ mapModifier,
7
+ selectModifier,
8
+ type InputEventOption,
9
+ type ModifierValue,
10
+ } from '../input/modifiers'
11
+ import { createDrag } from '../pointer/drag'
12
+
13
+ export interface StepperDragOptions {
14
+ /** The value now, read when the drag starts moving it. */
15
+ getValue: () => number
16
+ /**
17
+ * The range the value moves across. Its `step` is what one step of the drag
18
+ * is worth; see `numberInputRanges` for the one a number input uses.
19
+ */
20
+ range: ValueRange
21
+ /**
22
+ * How many pixels of vertical movement make one step.
23
+ *
24
+ * @default 1
25
+ */
26
+ pixels?: number
27
+ /**
28
+ * How much a step is worth, per modifier key: `0.1` makes the same movement
29
+ * count a tenth as much. Pressing or releasing the key mid-drag does not
30
+ * move the value.
31
+ *
32
+ * @default { default: 1, shift: 0.1 }
33
+ */
34
+ sensitivity?: ModifierValue<number>
35
+ /** Hide the pointer and keep it from hitting the edge of the screen. */
36
+ pointerLock?: boolean
37
+ /** Called with the new value whenever the drag moves it. */
38
+ onChange: (value: number) => void
39
+ }
40
+
41
+ export interface StepperDragInstance {
42
+ /** Replace the given options. `pointerLock` reaches the next drag. */
43
+ update: (options: Partial<StepperDragOptions>) => void
44
+ /**
45
+ * Whether the drag in progress has moved the value. A stepper button's
46
+ * press-and-hold repeat stands down once it has, so the value is not moved
47
+ * twice.
48
+ */
49
+ moved: () => boolean
50
+ destroy: () => void
51
+ }
52
+
53
+ /**
54
+ * Drag up and down on the steppers of a number input to move its value, one
55
+ * `step` every `pixels` — up raises it, as on a knob.
56
+ *
57
+ * Counted from where the drag started moving rather than added up per
58
+ * event, so rounding cannot accumulate. The start is taken on the first
59
+ * move, not on pointerdown: a stepper button acts on pointerdown, so by then
60
+ * the value may already have been nudged once, and the drag carries on from
61
+ * there.
62
+ */
63
+ export function createStepperDrag(
64
+ element: Element,
65
+ options: StepperDragOptions,
66
+ ): StepperDragInstance {
67
+ let opts = options
68
+ let origin: { y: number; value: number } | null = null
69
+ let moved = false
70
+ /**
71
+ * Which sensitivity the drag is counting at, and where the previous event
72
+ * was — a key produces no pointer event of its own, so a change is only
73
+ * seen on the next move and has to be dated back to the one before it.
74
+ */
75
+ let factor = 1
76
+ let previousY = 0
77
+
78
+ const drag = createDrag(element, {
79
+ threshold: 1,
80
+ cursor: 'ns-resize',
81
+ pointerLock: opts.pointerLock,
82
+ onDragStart: (state) => {
83
+ origin = null
84
+ moved = false
85
+ factor = selectModifier(
86
+ opts.sensitivity ?? DEFAULT_DRAG_SENSITIVITY,
87
+ state.event,
88
+ ).value
89
+ },
90
+ onDrag: (state) => {
91
+ const { y } = state
92
+ if (!origin) {
93
+ origin = { y, value: opts.getValue() }
94
+ previousY = y
95
+ return
96
+ }
97
+
98
+ const sensitivity = opts.sensitivity ?? DEFAULT_DRAG_SENSITIVITY
99
+ // Pressing or releasing the key mid-drag must not move the value, so the
100
+ // travel so far is folded into the origin and measuring starts again
101
+ // from the previous event: that event's own distance belongs to the new
102
+ // sensitivity.
103
+ const next = selectModifier(sensitivity, state.event).value
104
+ if (next !== factor) {
105
+ origin = { y: previousY, value: opts.getValue() }
106
+ factor = next
107
+ }
108
+ previousY = y
109
+
110
+ const steps = Math.round(-(y - origin.y) / (opts.pixels ?? 1))
111
+ if (steps === 0) return
112
+ moved = true
113
+
114
+ // The sensitivity as an amount per step, carried over as a modifier map
115
+ // rather than resolved here: that keeps `step` out of the pipeline for
116
+ // a modifier entry, since naming one is a request to move off the grid.
117
+ const step = opts.range.step ?? 1
118
+ const amounts = mapModifier(sensitivity, (f): InputEventOption => [
119
+ 'raw',
120
+ step * f,
121
+ ])
122
+ opts.onChange(
123
+ applyDelta(origin.value, steps, amounts, opts.range, state.event),
124
+ )
125
+ },
126
+ onDragEnd: () => {
127
+ moved = false
128
+ },
129
+ })
130
+
131
+ return {
132
+ update: (next) => {
133
+ opts = { ...opts, ...next }
134
+ if ('pointerLock' in next) drag.update({ pointerLock: opts.pointerLock })
135
+ },
136
+ moved: () => moved,
137
+ destroy: () => drag.destroy(),
138
+ }
139
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Reading a number out of the text of a number input, and keeping the caret
3
+ * in place while the number under it changes.
4
+ *
5
+ * The text is whatever `format` made of the value — `"440 Hz"`, `"-6.0 dB"` —
6
+ * or a half-typed entry, so none of this assumes the text is a number alone.
7
+ */
8
+
9
+ /** The signs a number can carry. U+2212 is what some `Intl.NumberFormat` locales write. */
10
+ const SIGNS = '+-\u2212'
11
+
12
+ /** Where the number is in the text; the rest, on either side, is the unit. */
13
+ export interface NumberSpan {
14
+ start: number
15
+ end: number
16
+ }
17
+
18
+ /**
19
+ * Where the number is in the text: from its first digit to its last, with the
20
+ * sign and the decimal point in front of it. Whatever is left on either side
21
+ * is taken for the unit, so it does not matter whether a space separates them,
22
+ * or what the number looks like — `+6.0`, `1e+21`, `1,000` and `1:30` are each
23
+ * one number. `null` when the text has no digit at all.
24
+ */
25
+ export function numberSpan(text: string): NumberSpan | null {
26
+ const first = text.search(/\d/)
27
+ if (first === -1) return null
28
+ let start = first
29
+ if (text[start - 1] === '.') start -= 1
30
+ if (start > 0 && SIGNS.includes(text[start - 1])) start -= 1
31
+ return { start, end: text.search(/\d\D*$/) + 1 }
32
+ }
33
+
34
+ /** A plain number, and nothing else: no grouping, no other separators. */
35
+ const PLAIN_NUMBER =
36
+ /^[+\-\u2212]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+\-\u2212]?\d+)?$/
37
+
38
+ /**
39
+ * The number in the text, with the unit after it ignored: the default `parse`
40
+ * of a number input.
41
+ *
42
+ * `NaN`, which leaves the value alone, whenever the number cannot be read
43
+ * safely, rather than a part of it: text with no number, a number that is not
44
+ * a plain one (`1,000` would otherwise read as 1, and `1:30` as 1), and text
45
+ * with something in front of the number, which can change what it means (the
46
+ * `L` of a pan reading `L 30`). A `format` that writes any of those needs its
47
+ * own `parse`.
48
+ */
49
+ export function parseNumberText(text: string): number {
50
+ const span = numberSpan(text)
51
+ if (!span || text.slice(0, span.start).trim() !== '') return NaN
52
+ const number = text.slice(span.start, span.end)
53
+ return PLAIN_NUMBER.test(number)
54
+ ? Number(number.replace(/\u2212/g, '-'))
55
+ : NaN
56
+ }
57
+
58
+ /**
59
+ * The index the caret is measured against: the decimal point, or where one
60
+ * would go if the number has none — in front of an exponent, if there is one.
61
+ *
62
+ * Measuring from an end instead would slide the caret across a digit whenever
63
+ * the number changed length — `9.9` to `10.0` gains a character in front, `10`
64
+ * to `9` loses one, and so does `9e+9` to `1e+10` behind — which is exactly
65
+ * what stepping does.
66
+ */
67
+ function decimalAnchor(text: string, span: NumberSpan) {
68
+ const number = text.slice(span.start, span.end)
69
+ const exponent = number.search(/[eE][+\-\u2212]?\d/)
70
+ const mantissa = exponent === -1 ? number : number.slice(0, exponent)
71
+ const dot = mantissa.indexOf('.')
72
+ return span.start + (dot === -1 ? mantissa.length : dot)
73
+ }
74
+
75
+ const NO_NUMBER: NumberSpan = { start: 0, end: 0 }
76
+
77
+ /**
78
+ * Where the caret is, relative to the decimal point, so that it can be put
79
+ * back at the same digit once the value has changed. See
80
+ * {@link caretAtDecimalOffset}.
81
+ */
82
+ export function caretDecimalOffset(text: string, caret: number): number {
83
+ return caret - decimalAnchor(text, numberSpan(text) ?? NO_NUMBER)
84
+ }
85
+
86
+ /**
87
+ * The caret position `offset` characters from the decimal point of the new
88
+ * text, kept within the number.
89
+ *
90
+ * @example
91
+ * // The caret sits in front of the point of "9.9" when ArrowUp turns it
92
+ * // into "10.0"
93
+ * const offset = caretDecimalOffset('9.9', 1) // 0
94
+ * caretAtDecimalOffset('10.0', offset) // 2: still in front of the point
95
+ */
96
+ export function caretAtDecimalOffset(text: string, offset: number): number {
97
+ const span = numberSpan(text) ?? NO_NUMBER
98
+ const place = decimalAnchor(text, span) + offset
99
+ return Math.max(span.start, Math.min(place, span.end))
100
+ }
@@ -0,0 +1,129 @@
1
+ import { type Scale, type ValueRange } from '@tremolo-ui/functions'
2
+
3
+ import { applyDelta } from '../input/apply-delta'
4
+ import {
5
+ selectModifier,
6
+ type InputEventOption,
7
+ type ModifierState,
8
+ type ModifierValue,
9
+ } from '../input/modifiers'
10
+
11
+ /**
12
+ * The value a number input edits, and how far it may go.
13
+ *
14
+ * Unlike a slider, either end may be left open, and `clampValue: false` lets
15
+ * the value past the ends that are set.
16
+ */
17
+ export interface NumberInputValueOptions {
18
+ min?: number
19
+ max?: number
20
+ step?: number
21
+ scale?: Scale
22
+ /**
23
+ * Keep the value between `min` and `max`.
24
+ *
25
+ * @default true
26
+ */
27
+ clampValue?: boolean
28
+ }
29
+
30
+ /** The ranges {@link nudgeNumberInput} moves a value across. */
31
+ export interface NumberInputRanges {
32
+ /** For a `normalized` amount, which needs a finite span to take a share of. */
33
+ normalized: ValueRange
34
+ /** For a `raw` amount, which does not. */
35
+ raw: ValueRange
36
+ }
37
+
38
+ /**
39
+ * The ranges a number input moves its value across, with the open ends
40
+ * filled in.
41
+ *
42
+ * A normalized amount needs a finite span even when an end is unbounded or
43
+ * clamping is off. Safe integers provide one without overflowing the span a
44
+ * scale calculates. A raw amount needs no span, so its open ends can cover
45
+ * every finite number instead of stopping at the safe-integer range.
46
+ */
47
+ export function numberInputRanges({
48
+ min,
49
+ max,
50
+ step,
51
+ scale,
52
+ clampValue = true,
53
+ }: NumberInputValueOptions): NumberInputRanges {
54
+ const lo = clampValue ? min : undefined
55
+ const hi = clampValue ? max : undefined
56
+ return {
57
+ normalized: {
58
+ min: lo ?? Number.MIN_SAFE_INTEGER,
59
+ max: hi ?? Number.MAX_SAFE_INTEGER,
60
+ step,
61
+ scale,
62
+ },
63
+ raw: {
64
+ min: lo ?? -Number.MAX_VALUE,
65
+ max: hi ?? Number.MAX_VALUE,
66
+ step,
67
+ scale,
68
+ },
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Move a number input's value by one press of a key, a wheel notch or a
74
+ * stepper, picking the range that suits the kind of amount. See `applyDelta`.
75
+ */
76
+ export function nudgeNumberInput(
77
+ value: number,
78
+ direction: number,
79
+ options: ModifierValue<InputEventOption>,
80
+ ranges: NumberInputRanges,
81
+ modifiers?: ModifierState,
82
+ ): number {
83
+ const [mode] = selectModifier(options, modifiers).value
84
+ return applyDelta(
85
+ value,
86
+ direction,
87
+ options,
88
+ mode === 'raw' ? ranges.raw : ranges.normalized,
89
+ modifiers,
90
+ )
91
+ }
92
+
93
+ /**
94
+ * Where the value stands against the ends: whether it can go no further
95
+ * down or up, and whether it lies outside them — which only an unclamped
96
+ * input, or a value set from outside, can do.
97
+ */
98
+ export function numberInputBounds(
99
+ value: number,
100
+ { min, max, clampValue = true }: NumberInputValueOptions,
101
+ ) {
102
+ return {
103
+ atMin: clampValue && min !== undefined && value <= min,
104
+ atMax: clampValue && max !== undefined && value >= max,
105
+ outOfRange:
106
+ (min !== undefined && value < min) || (max !== undefined && value > max),
107
+ }
108
+ }
109
+
110
+ /**
111
+ * The value typed text commits to, or `null` when there is no number in it.
112
+ *
113
+ * Text with no number is not a value: the input should go back to what it
114
+ * was showing rather than commit a zero the user never typed. What is read
115
+ * is clamped here and not while typing, since clamping as the user types
116
+ * would make "1500" impossible to enter into an input whose max is 100.
117
+ */
118
+ export function commitNumberInputText(
119
+ text: string,
120
+ parse: (text: string) => number,
121
+ { min, max, clampValue = true }: NumberInputValueOptions,
122
+ ): number | null {
123
+ const parsed = parse(text)
124
+ if (!Number.isFinite(parsed)) return null
125
+ let committed = parsed
126
+ if (clampValue && min !== undefined) committed = Math.max(committed, min)
127
+ if (clampValue && max !== undefined) committed = Math.min(committed, max)
128
+ return committed
129
+ }