@tremolo-ui/dom 0.4.0 → 0.5.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,229 @@
1
+ import {
2
+ clamp,
3
+ linearScale,
4
+ normalizeValue,
5
+ stepValue,
6
+ type ValueRange,
7
+ } from '@tremolo-ui/functions'
8
+
9
+ import { toXY, type XY, type XYInput } from '../xy'
10
+
11
+ import { createDrag, type DragState } from './drag'
12
+
13
+ /**
14
+ * How the 0-1 travel of one axis maps onto a value.
15
+ *
16
+ * Extends `ValueRange` so that a drag and an `applyDelta` nudge from a wheel
17
+ * or an arrow key share one description of the scaling.
18
+ */
19
+ export interface AxisOptions extends ValueRange {
20
+ /**
21
+ * Flip the axis so that its far end is `min`.
22
+ *
23
+ * Positions follow the screen: x grows to the right, y downwards. A vertical
24
+ * slider whose maximum is at the top therefore reverses its y axis.
25
+ *
26
+ * @default false
27
+ */
28
+ reverse?: boolean
29
+ }
30
+
31
+ /** Where the value of each axis currently sits, as a position (see {@link DragValueMapping}). */
32
+ export interface MappingContext {
33
+ position: () => XY<number>
34
+ }
35
+
36
+ /**
37
+ * Turns pointer movement into a position on each axis: 0 is the `min` end of
38
+ * the travel and 1 the `max` end, before `reverse` and the scaling of
39
+ * {@link AxisOptions} are applied.
40
+ *
41
+ * A mapping holds the state of the drag in progress, so an instance belongs to
42
+ * a single {@link createDragValue} instance.
43
+ */
44
+ export interface DragValueMapping {
45
+ /**
46
+ * @returns the position, or null when it cannot be determined and the event
47
+ * should be ignored.
48
+ */
49
+ start: (state: DragState, context: MappingContext) => XY<number> | null
50
+ move: (state: DragState, context: MappingContext) => XY<number> | null
51
+ }
52
+
53
+ /**
54
+ * Map the pointer onto the bounding rect of an element: the value *is* the
55
+ * position pointed at, so the middle of the element is 0.5.
56
+ *
57
+ * The element is read on every event, so it may be mounted after the drag is
58
+ * set up and may change size while a drag is in progress.
59
+ */
60
+ export function elementMapping(
61
+ getElement: () => Element | null | undefined,
62
+ ): DragValueMapping {
63
+ function positionIn(state: DragState): XY<number> | null {
64
+ const element = getElement()
65
+ if (!element) return null
66
+ const { left, top, right, bottom } = element.getBoundingClientRect()
67
+ // A collapsed element has no travel to normalize against, and
68
+ // normalizeValue rejects an empty range.
69
+ return [
70
+ right > left ? normalizeValue(state.clientX, left, right) : 0,
71
+ bottom > top ? normalizeValue(state.clientY, top, bottom) : 0,
72
+ ]
73
+ }
74
+
75
+ return { start: positionIn, move: positionIn }
76
+ }
77
+
78
+ /**
79
+ * Move the value away from where it stood when the drag started, by the
80
+ * distance dragged. The pointer position itself carries no meaning, so the
81
+ * value can be adjusted from anywhere on the screen.
82
+ */
83
+ export function relativeMapping({
84
+ pixelRange = 100,
85
+ }: {
86
+ /**
87
+ * Pixels of movement that span the whole range.
88
+ * @default 100
89
+ */
90
+ pixelRange?: XYInput<number>
91
+ } = {}): DragValueMapping {
92
+ const [rangeX, rangeY] = toXY(pixelRange)
93
+ let origin: XY<number> = [0, 0]
94
+
95
+ return {
96
+ start: (_state, context) => {
97
+ origin = context.position()
98
+ return origin
99
+ },
100
+ // `x` and `y` are measured from the start of the drag, so the position
101
+ // never accumulates a rounding error of its own.
102
+ move: (state) => [
103
+ origin[0] + state.x / rangeX,
104
+ origin[1] + state.y / rangeY,
105
+ ],
106
+ }
107
+ }
108
+
109
+ export interface DragValueOptions {
110
+ /** Scaling of each axis; a single value applies to both. */
111
+ axis: XYInput<AxisOptions>
112
+
113
+ /** How pointer movement becomes a position. */
114
+ mapping: DragValueMapping
115
+
116
+ /**
117
+ * The current value of each axis. Read when a drag starts, by mappings that
118
+ * move the value relative to it, such as {@link relativeMapping}.
119
+ */
120
+ getValue?: () => XY<number>
121
+
122
+ /**
123
+ * Report the value on pointer down, before any movement.
124
+ *
125
+ * Enable it where the pointer position *is* the value, so that a plain click
126
+ * jumps to it. Leave it off where the element being dragged is an object in
127
+ * its own right, so that grabbing its edge does not shift it under the
128
+ * pointer.
129
+ *
130
+ * @default false
131
+ */
132
+ updateOnPointerDown?: boolean
133
+
134
+ /** @see DragOptions.threshold */
135
+ threshold?: number
136
+ /** @see DragOptions.cursor */
137
+ cursor?: string
138
+
139
+ onChange?: (value: XY<number>, state: DragState) => void
140
+ onDragStart?: (value: XY<number>, state: DragState) => void
141
+ onDragEnd?: (value: XY<number>, state: DragState) => void
142
+ }
143
+
144
+ export interface DragValueInstance {
145
+ /**
146
+ * Replace the given options. Lets a wrapper feed fresh values in without
147
+ * tearing down the listeners, which would abort a drag in progress.
148
+ *
149
+ * `mapping` is fixed for the lifetime of the instance and is ignored here.
150
+ */
151
+ update: (options: Partial<DragValueOptions>) => void
152
+ destroy: () => void
153
+ }
154
+
155
+ /**
156
+ * Drive a value with a pointer drag.
157
+ *
158
+ * Combines {@link createDrag} with the scaling of `@tremolo-ui/functions`: the
159
+ * mapping decides where the pointer sits on the 0-1 travel of each axis, and
160
+ * the axis options turn that into a value.
161
+ */
162
+ export function createDragValue(
163
+ element: Element,
164
+ options: DragValueOptions,
165
+ ): DragValueInstance {
166
+ let opts = options
167
+ let lastValue: XY<number> = [0, 0]
168
+
169
+ const axes = () => toXY(opts.axis)
170
+
171
+ function valueOf(position: XY<number>): XY<number> {
172
+ return axes().map((axis, i) => {
173
+ const p = axis.reverse ? 1 - position[i] : position[i]
174
+ // A scale clamps the position, so a mapping may report outside 0-1.
175
+ const scale = axis.scale ?? linearScale
176
+ const value = scale.denormalize(p, axis.min, axis.max)
177
+ const stepped = axis.step ? stepValue(value, axis.step) : value
178
+ // Rounding to the step can leave the range.
179
+ return clamp(stepped, axis.min, axis.max)
180
+ }) as XY<number>
181
+ }
182
+
183
+ const context: MappingContext = {
184
+ position: () => {
185
+ const getValue = opts.getValue
186
+ if (!getValue) {
187
+ throw new Error(
188
+ 'createDragValue: getValue is required by the given mapping',
189
+ )
190
+ }
191
+ const value = getValue()
192
+ return axes().map((axis, i) => {
193
+ const scale = axis.scale ?? linearScale
194
+ const n = scale.normalize(value[i], axis.min, axis.max)
195
+ return axis.reverse ? 1 - n : n
196
+ }) as XY<number>
197
+ },
198
+ }
199
+
200
+ const drag = createDrag(element, {
201
+ threshold: opts.threshold,
202
+ cursor: opts.cursor,
203
+ onDragStart: (state) => {
204
+ const position = opts.mapping.start(state, context)
205
+ if (position) {
206
+ lastValue = valueOf(position)
207
+ if (opts.updateOnPointerDown) opts.onChange?.(lastValue, state)
208
+ }
209
+ opts.onDragStart?.(lastValue, state)
210
+ },
211
+ onDrag: (state) => {
212
+ const position = opts.mapping.move(state, context)
213
+ if (!position) return
214
+ lastValue = valueOf(position)
215
+ opts.onChange?.(lastValue, state)
216
+ },
217
+ // The pointer has not moved since the last reported value, so `lastValue`
218
+ // is where the drag ended.
219
+ onDragEnd: (state) => opts.onDragEnd?.(lastValue, state),
220
+ })
221
+
222
+ return {
223
+ update: (next) => {
224
+ opts = { ...opts, ...next, mapping: opts.mapping }
225
+ drag.update({ threshold: opts.threshold, cursor: opts.cursor })
226
+ },
227
+ destroy: () => drag.destroy(),
228
+ }
229
+ }
@@ -1,4 +1,23 @@
1
+ export interface WheelOptions {
2
+ /**
3
+ * Only report events while the focus is inside the element.
4
+ *
5
+ * A control that reacts to the wheel on hover alone takes the scroll away
6
+ * from the page, so passing over one in a long form silently changes its
7
+ * value. Requiring focus makes that an explicit act.
8
+ *
9
+ * The check is `contains`, not an identity test: the element that actually
10
+ * takes focus is usually a descendant, such as a thumb or an `<input>`, and
11
+ * a caller may have replaced it with markup of their own.
12
+ *
13
+ * @default false
14
+ */
15
+ requireFocus?: boolean
16
+ }
17
+
1
18
  export interface WheelInstance {
19
+ /** Replace the given options, keeping the listener in place. */
20
+ update: (options: WheelOptions) => void
2
21
  destroy: () => void
3
22
  }
4
23
 
@@ -11,12 +30,26 @@ export interface WheelInstance {
11
30
  export function createWheel(
12
31
  element: Element,
13
32
  onWheel: (event: WheelEvent) => void,
33
+ options: WheelOptions = {},
14
34
  ): WheelInstance {
15
- const handler = (event: Event) => onWheel(event as WheelEvent)
35
+ let opts = options
36
+
37
+ function hasFocus() {
38
+ const active = element.ownerDocument?.activeElement
39
+ return !!active && element.contains(active)
40
+ }
41
+
42
+ const handler = (event: Event) => {
43
+ if (opts.requireFocus && !hasFocus()) return
44
+ onWheel(event as WheelEvent)
45
+ }
16
46
 
17
47
  element.addEventListener('wheel', handler, { passive: false })
18
48
 
19
49
  return {
50
+ update: (next) => {
51
+ opts = { ...opts, ...next }
52
+ },
20
53
  destroy: () => {
21
54
  // Only `capture` matters when removing, and it is false here.
22
55
  element.removeEventListener('wheel', handler)
package/src/xy.ts ADDED
@@ -0,0 +1,38 @@
1
+ /**
2
+ * A pair of per-axis values. The tuple elements are labelled, so editors show
3
+ * `[x: number, y: number]` rather than a bare pair.
4
+ */
5
+ export type XY<T> = [x: T, y: T]
6
+
7
+ /**
8
+ * A setting that may be given once for both axes, or per axis.
9
+ *
10
+ * A single value is told from a pair with `Array.isArray`, so the single form
11
+ * is only offered while `T` cannot itself be an array. Where it can, the pair
12
+ * is the only way to write it, since a lone array would be read as a pair.
13
+ */
14
+ export type XYInput<T> = [T] extends [readonly unknown[]]
15
+ ? readonly [x: T, y: T]
16
+ : T | readonly [x: T, y: T]
17
+
18
+ // `Array.isArray` narrows a mutable tuple on its own, but not a readonly one,
19
+ // hence the explicit predicate.
20
+ function isPair<T>(
21
+ value: T | readonly [x: T, y: T],
22
+ ): value is readonly [x: T, y: T] {
23
+ return Array.isArray(value)
24
+ }
25
+
26
+ /**
27
+ * Spread a setting that may have been given as a single value.
28
+ *
29
+ * The parameter is written out rather than taken as `XYInput<T>`: a
30
+ * conditional type cannot be narrowed, so the constraint stays where it is
31
+ * declared and this takes both forms.
32
+ *
33
+ * The pair is copied rather than passed along, so that the result is a tuple
34
+ * the caller owns even when a readonly one was given.
35
+ */
36
+ export function toXY<T>(value: T | readonly [x: T, y: T]): XY<T> {
37
+ return isPair(value) ? [value[0], value[1]] : [value, value]
38
+ }