@tremolo-ui/dom 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.
@@ -0,0 +1,103 @@
1
+ export interface LongPressOptions {
2
+ /** Called once on the press, then again every `interval` after `delay`. */
3
+ onPress: () => void
4
+ /**
5
+ * How long the press has to be held before it starts repeating, in
6
+ * milliseconds.
7
+ *
8
+ * @default 500
9
+ */
10
+ delay?: number
11
+ /**
12
+ * How often it repeats once it has started, in milliseconds.
13
+ *
14
+ * @default 40
15
+ */
16
+ interval?: number
17
+ }
18
+
19
+ export interface LongPressInstance {
20
+ /**
21
+ * Start a press, from a `pointerdown` or with no event at all. Only the
22
+ * primary button starts one, and a press already in progress is left alone.
23
+ */
24
+ start: (event?: Pick<PointerEvent, 'button' | 'pointerId'>) => void
25
+ /** End the press, as releasing the pointer would. */
26
+ stop: () => void
27
+ /** Replace the given options. The press in progress picks them up. */
28
+ update: (options: Partial<LongPressOptions>) => void
29
+ /** Whether a press is in progress. */
30
+ pressed: () => boolean
31
+ destroy: () => void
32
+ }
33
+
34
+ /**
35
+ * Repeat an action while a pointer is held down: once on the press, then
36
+ * again every `interval` after `delay` — the way a stepper button or a
37
+ * key held on a keyboard behaves.
38
+ *
39
+ * The release is listened for on the window, not on the element: the
40
+ * pointer may be let go anywhere. Only the pointer that started the press
41
+ * ends it, so a second finger lifting elsewhere does not. The window losing
42
+ * focus ends it too, since the release would never arrive.
43
+ */
44
+ export function createLongPress(options: LongPressOptions): LongPressInstance {
45
+ let opts = options
46
+ let pointerId: number | null = null
47
+ let active = false
48
+ let timer: ReturnType<typeof setTimeout> | null = null
49
+
50
+ function clearTimer() {
51
+ if (timer !== null) clearTimeout(timer)
52
+ timer = null
53
+ }
54
+
55
+ /** Wait `wait`, fire, and keep going at `interval` from then on. */
56
+ function schedule(wait: number) {
57
+ timer = setTimeout(() => {
58
+ opts.onPress()
59
+ if (active) schedule(opts.interval ?? 40)
60
+ }, wait)
61
+ }
62
+
63
+ function onPointerEnd(event: PointerEvent) {
64
+ // Pointer events carry an id; a press started without one ends on any.
65
+ if (pointerId !== null && event.pointerId !== pointerId) return
66
+ stop()
67
+ }
68
+
69
+ function listen(add: boolean) {
70
+ const target = globalThis.window
71
+ if (!target) return
72
+ const method = add ? 'addEventListener' : 'removeEventListener'
73
+ target[method]('pointerup', onPointerEnd as EventListener)
74
+ target[method]('pointercancel', onPointerEnd as EventListener)
75
+ target[method]('blur', stop)
76
+ }
77
+
78
+ function stop() {
79
+ if (!active) return
80
+ active = false
81
+ pointerId = null
82
+ clearTimer()
83
+ listen(false)
84
+ }
85
+
86
+ return {
87
+ start: (event) => {
88
+ if (active || (event && event.button !== 0)) return
89
+ active = true
90
+ pointerId = event?.pointerId ?? null
91
+ listen(true)
92
+ opts.onPress()
93
+ // The action may have stopped the press itself.
94
+ if (active) schedule(opts.delay ?? 500)
95
+ },
96
+ stop,
97
+ update: (next) => {
98
+ opts = { ...opts, ...next }
99
+ },
100
+ pressed: () => active,
101
+ destroy: stop,
102
+ }
103
+ }
@@ -0,0 +1,361 @@
1
+ import { clamp, toPrecision } from '@tremolo-ui/functions'
2
+
3
+ import { applyDelta } from '../input/apply-delta'
4
+ import {
5
+ type InputEventOption,
6
+ type ModifierState,
7
+ type ModifierValue,
8
+ } from '../input/modifiers'
9
+ import { createSelectionBox, type SelectionBoxRect } from '../selection/box'
10
+ import { type XY } from '../xy'
11
+
12
+ /** Where a point is: 0..1 on each axis, with y growing downwards. */
13
+ export interface PointPosition {
14
+ x: number
15
+ y: number
16
+ }
17
+
18
+ /**
19
+ * A point's value is its position in the editor, so its range is the editor:
20
+ * no scaling, and no rounding to a step.
21
+ */
22
+ export const POINT_AXIS = { min: 0, max: 1 }
23
+
24
+ /**
25
+ * How much one wheel notch moves a point by default: a hundredth of the
26
+ * editor, whatever its pixel size.
27
+ */
28
+ export const POINTS_EDITOR_DEFAULT_WHEEL: ModifierValue<InputEventOption> = [
29
+ 'normalized',
30
+ 0.01,
31
+ ]
32
+
33
+ /**
34
+ * How much one arrow key press moves a point by default. Shift is the
35
+ * fine-adjustment key everywhere else, so it is bound here too — but only on
36
+ * the keyboard. On the wheel it already means the x axis, and browsers hand
37
+ * shift+wheel over as horizontal scrolling anyway.
38
+ */
39
+ export const POINTS_EDITOR_DEFAULT_KEYBOARD: ModifierValue<InputEventOption> = {
40
+ default: ['normalized', 0.01],
41
+ shift: ['normalized', 0.001],
42
+ }
43
+
44
+ /** Keep a point within its range. An axis left out runs from 0 to 1. */
45
+ export function clampPoint(
46
+ point: PointPosition,
47
+ min?: Partial<PointPosition>,
48
+ max?: Partial<PointPosition>,
49
+ ): PointPosition {
50
+ return {
51
+ x: clamp(point.x, min?.x ?? 0, max?.x ?? 1),
52
+ y: clamp(point.y, min?.y ?? 0, max?.y ?? 1),
53
+ }
54
+ }
55
+
56
+ /**
57
+ * What a point tells the editor about itself, so that a selection can be
58
+ * moved without the editor knowing how the points are stored.
59
+ */
60
+ export interface PointsEditorPoint {
61
+ value: PointPosition
62
+ min?: Partial<PointPosition>
63
+ max?: Partial<PointPosition>
64
+ /** A point that cannot move stays put while the rest of a selection moves. */
65
+ readonly?: boolean
66
+ onChange?: (value: PointPosition) => void
67
+ /** The point's own element, to match the focus and a press against. */
68
+ element?: Element | null
69
+ /** The wheel option the point resolved, `null` for no wheel. */
70
+ wheel?: ModifierValue<InputEventOption> | null
71
+ }
72
+
73
+ export interface PointsEditorOptions {
74
+ /**
75
+ * Let points be selected, and a selection be moved as one. While it is off
76
+ * nothing is selected, and a drag moves only the point it started on.
77
+ *
78
+ * @default false
79
+ */
80
+ selectable?: boolean
81
+ /**
82
+ * The ids of the selected points. Whoever holds the selection pushes it
83
+ * back with `update()` after {@link PointsEditorOptions.onSelectionChange}.
84
+ */
85
+ selection?: readonly string[]
86
+ /** Called whenever a press or a selection box changes the selection. */
87
+ onSelectionChange?: (selection: string[]) => void
88
+ /** Called whenever the selection box changes, with `null` once it is gone. */
89
+ onSelectionBoxChange?: (rect: SelectionBoxRect | null) => void
90
+ }
91
+
92
+ export interface PointsEditorInstance {
93
+ /** Replace the given options. */
94
+ update: (options: Partial<PointsEditorOptions>) => void
95
+ /**
96
+ * Register a point under `id`. `read` is called whenever the editor needs
97
+ * the point, so it can return what the point is now rather than what it
98
+ * was when it registered. Returns the function that unregisters it.
99
+ */
100
+ registerPoint: (id: string, read: () => PointsEditorPoint) => () => void
101
+ /** Whether the element is a point, or inside one. */
102
+ isPointElement: (element: Element | null | undefined) => boolean
103
+ /**
104
+ * A pointer went down on a point: work out the new selection and remember
105
+ * where everything the drag picked up started.
106
+ *
107
+ * Ctrl / meta add the point to the selection or take it out: shift is the
108
+ * fine-adjustment key on every control here, and it cannot be both. A
109
+ * press on a point already selected keeps the selection, so the group can
110
+ * be dragged.
111
+ */
112
+ beginPointDrag: (id: string, modifiers: ModifierState) => void
113
+ /**
114
+ * Move everything the drag picked up by `delta` from where it started,
115
+ * stopping the whole group together at the edge.
116
+ */
117
+ movePointDrag: (delta: PointPosition) => void
118
+ /**
119
+ * Move `id` — and the selection, when it is part of one — by `delta` from
120
+ * where the points are now: an input that is not a drag has no earlier
121
+ * position to measure against.
122
+ */
123
+ nudgeSelection: (id: string, delta: PointPosition) => void
124
+ /**
125
+ * Move `id` by one press of `option` along one axis, taking the selection
126
+ * along as {@link PointsEditorInstance.nudgeSelection} does.
127
+ */
128
+ nudgePoint: (
129
+ id: string,
130
+ axis: 'x' | 'y',
131
+ direction: number,
132
+ option: ModifierValue<InputEventOption>,
133
+ modifiers?: ModifierState,
134
+ ) => void
135
+ /**
136
+ * Move the point holding the focus by one wheel notch, with its own wheel
137
+ * option. The wheel is listened to once for the whole editor, since a wheel
138
+ * event only reaches what the cursor is over. Returns whether a point took
139
+ * it, so the caller knows whether to consume the event.
140
+ */
141
+ nudgeFocusedPoint: (
142
+ axis: 'x' | 'y',
143
+ direction: number,
144
+ modifiers: ModifierState,
145
+ ) => boolean
146
+ /** A drag on empty space started at `at`: start a selection box there. */
147
+ beginSelectionBox: (at: PointPosition, modifiers: ModifierState) => void
148
+ moveSelectionBox: (to: PointPosition) => void
149
+ /**
150
+ * End the selection box. The press that started it left the focus on
151
+ * nothing, and the arrow keys and the wheel reach a point only through the
152
+ * focus, so it is handed to one of the points the box selected.
153
+ */
154
+ endSelectionBox: () => void
155
+ destroy: () => void
156
+ }
157
+
158
+ const EMPTY: readonly string[] = []
159
+
160
+ /**
161
+ * How far a group may move before something in it leaves its range.
162
+ *
163
+ * Clamping each point on its own would break the shape of the selection: the
164
+ * one that reached the edge would stop while the rest carried on. One amount
165
+ * for all of them means the whole selection stops together.
166
+ */
167
+ function allowedDelta(
168
+ delta: PointPosition,
169
+ entries: { start: PointPosition; point: PointsEditorPoint }[],
170
+ ): PointPosition {
171
+ let loX = -Infinity
172
+ let hiX = Infinity
173
+ let loY = -Infinity
174
+ let hiY = Infinity
175
+ for (const { start, point } of entries) {
176
+ // A point that cannot move stays where it is, so its range says nothing
177
+ // about how far the rest may go.
178
+ if (point.readonly) continue
179
+ loX = Math.max(loX, (point.min?.x ?? 0) - start.x)
180
+ hiX = Math.min(hiX, (point.max?.x ?? 1) - start.x)
181
+ loY = Math.max(loY, (point.min?.y ?? 0) - start.y)
182
+ hiY = Math.min(hiY, (point.max?.y ?? 1) - start.y)
183
+ }
184
+ // A point that started outside its own range leaves nothing to move within.
185
+ return {
186
+ x: hiX < loX ? 0 : clamp(delta.x, loX, hiX),
187
+ y: hiY < loY ? 0 : clamp(delta.y, loY, hiY),
188
+ }
189
+ }
190
+
191
+ /**
192
+ * The selection and the moves of a points editor: which points a press or a
193
+ * box selects, and how a selection moves as one.
194
+ *
195
+ * The points stay with the wrapper, which registers each one; the editor only
196
+ * reads them when it needs to, so a point's value can change on every frame
197
+ * of a drag without anything being re-registered.
198
+ */
199
+ export function createPointsEditor(
200
+ options: PointsEditorOptions = {},
201
+ ): PointsEditorInstance {
202
+ let opts = options
203
+ const points = new Map<string, () => PointsEditorPoint>()
204
+ /** What the current drag picked up, and where those points started. */
205
+ let dragged: { id: string; start: PointPosition }[] = []
206
+
207
+ const selection = () => (opts.selectable ? (opts.selection ?? EMPTY) : EMPTY)
208
+
209
+ function changeSelection(next: string[]) {
210
+ // Read back at once by the drag that follows, before the owner has had a
211
+ // chance to push it with update().
212
+ opts = { ...opts, selection: next }
213
+ opts.onSelectionChange?.(next)
214
+ }
215
+
216
+ function snapshot(ids: readonly string[]) {
217
+ return ids.flatMap((id) => {
218
+ const point = points.get(id)?.()
219
+ return point ? [{ id, start: { ...point.value } }] : []
220
+ })
221
+ }
222
+
223
+ function moveFrom(
224
+ entries: { id: string; start: PointPosition }[],
225
+ delta: PointPosition,
226
+ ) {
227
+ const withPoint = entries.flatMap((entry) => {
228
+ const point = points.get(entry.id)?.()
229
+ return point ? [{ ...entry, point }] : []
230
+ })
231
+ const allowed = allowedDelta(delta, withPoint)
232
+ for (const { start, point } of withPoint) {
233
+ if (point.readonly) continue
234
+ // Rounded here as well as in the pipeline: a move is a subtraction and
235
+ // an addition of its own, and that is enough to put the binary artefact
236
+ // back (0.2 + 0.1 lands on 0.30000000000000004).
237
+ point.onChange?.({
238
+ x: toPrecision(start.x + allowed.x),
239
+ y: toPrecision(start.y + allowed.y),
240
+ })
241
+ }
242
+ }
243
+
244
+ function nudgeSelection(id: string, delta: PointPosition) {
245
+ const current = selection()
246
+ moveFrom(snapshot(current.includes(id) ? current : [id]), delta)
247
+ }
248
+
249
+ function nudgePoint(
250
+ id: string,
251
+ axis: 'x' | 'y',
252
+ direction: number,
253
+ option: ModifierValue<InputEventOption>,
254
+ modifiers?: ModifierState,
255
+ ) {
256
+ const point = points.get(id)?.()
257
+ if (!point) return
258
+ const { value } = point
259
+ const next = applyDelta(
260
+ value[axis],
261
+ direction,
262
+ option,
263
+ POINT_AXIS,
264
+ modifiers,
265
+ )
266
+ // As a move, so that the rest of the selection comes along and the whole
267
+ // group stops together at the edge.
268
+ nudgeSelection(id, {
269
+ x: axis === 'x' ? next - value.x : 0,
270
+ y: axis === 'y' ? next - value.y : 0,
271
+ })
272
+ }
273
+
274
+ const box = createSelectionBox<string>({
275
+ *items(): Generator<readonly [string, XY<number>]> {
276
+ for (const [id, read] of points) {
277
+ const { x, y } = read().value
278
+ yield [id, [x, y]]
279
+ }
280
+ },
281
+ onBoxChange: (rect) => opts.onSelectionBoxChange?.(rect),
282
+ onSelectionChange: changeSelection,
283
+ })
284
+
285
+ return {
286
+ update: (next) => {
287
+ opts = { ...opts, ...next }
288
+ },
289
+ registerPoint: (id, read) => {
290
+ points.set(id, read)
291
+ return () => {
292
+ if (points.get(id) === read) points.delete(id)
293
+ }
294
+ },
295
+ isPointElement: (element) => {
296
+ if (!element) return false
297
+ for (const read of points.values()) {
298
+ if (read().element?.contains(element)) return true
299
+ }
300
+ return false
301
+ },
302
+ beginPointDrag: (id, modifiers) => {
303
+ if (!opts.selectable) {
304
+ dragged = snapshot([id])
305
+ return
306
+ }
307
+ const current = selection()
308
+ const additive = modifiers.ctrlKey || modifiers.metaKey
309
+ let next: readonly string[]
310
+ if (additive) {
311
+ next = current.includes(id)
312
+ ? current.filter((x) => x !== id)
313
+ : [...current, id]
314
+ } else if (current.includes(id)) {
315
+ next = current
316
+ } else {
317
+ next = [id]
318
+ }
319
+ changeSelection([...next])
320
+ // A press that took the point out of the selection was a deselect, not
321
+ // the start of a move, so there is nothing to drag.
322
+ dragged = next.includes(id) ? snapshot(next) : []
323
+ },
324
+ movePointDrag: (delta) => moveFrom(dragged, delta),
325
+ nudgeSelection,
326
+ nudgePoint,
327
+ nudgeFocusedPoint: (axis, direction, modifiers) => {
328
+ for (const [id, read] of points) {
329
+ const { element, wheel, readonly, onChange } = read()
330
+ // The document the point is in, which need not be the one this script
331
+ // runs in — an editor rendered into an iframe has its own focus.
332
+ const active = element?.ownerDocument.activeElement
333
+ // A point answers only for the focus inside its own inputs, so both
334
+ // axes stay part of the same interaction.
335
+ if (!active || !element?.contains(active)) continue
336
+ if (!wheel || readonly || !onChange) return false
337
+ nudgePoint(id, axis, direction, wheel, modifiers)
338
+ return true
339
+ }
340
+ return false
341
+ },
342
+ beginSelectionBox: (at, modifiers) => {
343
+ if (!opts.selectable) return
344
+ box.begin([at.x, at.y], {
345
+ additive: modifiers.ctrlKey || modifiers.metaKey,
346
+ selection: selection(),
347
+ })
348
+ },
349
+ moveSelectionBox: (to) => box.move([to.x, to.y]),
350
+ endSelectionBox: () => {
351
+ if (!box.end()) return
352
+ const [first] = selection()
353
+ const element = first ? points.get(first)?.().element : null
354
+ if (element && 'focus' in element) (element as HTMLElement).focus()
355
+ },
356
+ destroy: () => {
357
+ box.destroy()
358
+ dragged = []
359
+ },
360
+ }
361
+ }
@@ -0,0 +1,20 @@
1
+ import { linearScale, toFixed, type ValueRange } from '@tremolo-ui/functions'
2
+
3
+ /**
4
+ * Where a value sits along a track, as a whole percentage from its start.
5
+ *
6
+ * Normalized along the scale, so a thumb, the fill behind it and the marks
7
+ * all sit on the curve the drag follows. `reversed` measures from the other
8
+ * end — for a track that grows upwards or leftwards on screen, since CSS
9
+ * places things from the top and the left.
10
+ */
11
+ export function valuePercent(
12
+ value: number,
13
+ { min, max, scale = linearScale }: Pick<ValueRange, 'min' | 'max' | 'scale'>,
14
+ reversed = false,
15
+ ): number {
16
+ // Reversed before rounding, so that a value halfway between two whole
17
+ // percentages lands on the same one whichever end it is measured from.
18
+ const normalized = scale.normalize(value, min, max)
19
+ return toFixed((reversed ? 1 - normalized : normalized) * 100)
20
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * How many digits a number carries after the decimal point.
3
+ *
4
+ * `Slider.Marks` builds its labels by multiplying an interval, which leaves
5
+ * binary debris in the last digits (`0.1 * 3` is `0.30000000000000004`). The
6
+ * count is what tells `toFixed` how far to round that back.
7
+ *
8
+ * The exponent form has to be handled separately: `String(1e-7)` is `'1e-7'`,
9
+ * which has no decimal point at all, so reading the text after the point
10
+ * reports no digits and the interval rounds away to whole numbers.
11
+ */
12
+ export function decimalDigits(x: number): number {
13
+ if (!Number.isFinite(x)) return 0
14
+
15
+ const text = String(x)
16
+ const e = text.indexOf('e')
17
+ if (e === -1) return text.split('.')[1]?.length ?? 0
18
+
19
+ const fraction = text.slice(0, e).split('.')[1]?.length ?? 0
20
+ // A negative exponent pushes the point further right, a positive one pulls
21
+ // it back past the digits that are there.
22
+ return Math.max(0, fraction - Number(text.slice(e + 1)))
23
+ }
@@ -0,0 +1,56 @@
1
+ import { toFixed } from '@tremolo-ui/functions'
2
+
3
+ import { decimalDigits } from './decimal-digits'
4
+
5
+ /**
6
+ * How `Slider.Marks` fills itself in when it is given no children: one option
7
+ * every `per`, or every `step` of the slider. The object form turns off the
8
+ * mark or the label for the whole set; a single option is customized by
9
+ * writing `Slider.MarksOption` out instead.
10
+ */
11
+ export type MarksOptions =
12
+ | 'step'
13
+ | number
14
+ | {
15
+ per: 'step' | number
16
+ mark?: boolean
17
+ label?: boolean
18
+ }
19
+
20
+ /** One mark along a slider, as {@link sliderMarks} lays them out. */
21
+ export interface SliderMark {
22
+ value: number
23
+ mark: boolean
24
+ label: boolean
25
+ }
26
+
27
+ /**
28
+ * The marks `options` asks for between `min` and `max`, in ascending order.
29
+ *
30
+ * Each value is a whole multiple of the interval, rounded to the digits the
31
+ * interval has: stepping by 0.1 would otherwise put binary debris in the
32
+ * labels (`0.1 * 3` is `0.30000000000000004`).
33
+ */
34
+ export function sliderMarks(
35
+ options: MarksOptions,
36
+ min: number,
37
+ max: number,
38
+ step: number,
39
+ ): SliderMark[] {
40
+ const {
41
+ per,
42
+ mark = true,
43
+ label = true,
44
+ } = typeof options === 'object' ? options : { per: options }
45
+ const optionsList: SliderMark[] = []
46
+ const interval = per === 'step' ? step : per
47
+ const count = Math.floor(max / interval) - Math.ceil(min / interval) + 1
48
+ for (let i = 0; i < count; i++) {
49
+ const value = toFixed(
50
+ interval * (Math.ceil(min / interval) + i),
51
+ decimalDigits(interval),
52
+ )
53
+ optionsList.push({ value: value, mark: mark, label: label })
54
+ }
55
+ return optionsList
56
+ }
package/src/style.ts ADDED
@@ -0,0 +1,41 @@
1
+ /**
2
+ * A number as a CSS length, for a custom property.
3
+ *
4
+ * A custom property takes whatever text it is given, so `--size: 50` comes out
5
+ * as the invalid `50` rather than `50px`. React appends `px` to a bare number
6
+ * only for the properties it knows take a length, and a custom property is
7
+ * never one of them; Vue and Svelte append nothing at all. Anything already a
8
+ * string is passed through, so `'3rem'` and `'100%'` still work.
9
+ */
10
+ export function cssLength(
11
+ value: number | string | undefined,
12
+ ): string | undefined {
13
+ return typeof value === 'number' ? `${value}px` : value
14
+ }
15
+
16
+ /**
17
+ * Take an element out of sight while leaving it in the accessibility tree and
18
+ * in the tab order.
19
+ *
20
+ * `display: none` and `visibility: hidden` would remove it from both, and the
21
+ * native control underneath a headless component is what carries the ARIA and
22
+ * the keyboard behaviour. `pointer-events: none` is safe because nothing is
23
+ * ever clicked here directly: a `<label>` forwards its click, and a drag is
24
+ * handled by the part that is visible.
25
+ *
26
+ * Every value is a string with its unit, so the object can be handed to any
27
+ * framework's `style` binding as it is.
28
+ */
29
+ export const visuallyHiddenStyle = {
30
+ position: 'absolute',
31
+ width: '1px',
32
+ height: '1px',
33
+ padding: '0',
34
+ margin: '-1px',
35
+ overflow: 'hidden',
36
+ clip: 'rect(0, 0, 0, 0)',
37
+ clipPath: 'inset(50%)',
38
+ whiteSpace: 'nowrap',
39
+ border: '0',
40
+ pointerEvents: 'none',
41
+ } as const