@weft-js/scroll 0.0.0-stage → 0.1.5-nightly.20261111.3
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.
- package/LICENSE +16 -0
- package/README.md +579 -2
- package/package.json +39 -3
- package/src/autoscroll.ts +91 -0
- package/src/axis.ts +59 -0
- package/src/bar-input.ts +104 -0
- package/src/bar.ts +29 -0
- package/src/canvas.ts +111 -0
- package/src/engine.ts +374 -0
- package/src/index.ts +21 -0
- package/src/keys.ts +87 -0
- package/src/options.ts +88 -0
- package/src/physics.ts +65 -0
- package/src/render.ts +205 -0
- package/src/scroller-api.ts +75 -0
- package/src/scroller.ts +240 -0
- package/src/snap.ts +35 -0
- package/src/touch.ts +169 -0
- package/src/types.ts +178 -0
- package/src/wheel-input.ts +112 -0
- package/src/wheel.ts +32 -0
- package/src/wrap.ts +27 -0
- package/styles/scroll.css +199 -0
package/src/engine.ts
ADDED
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
// The scroller's state and its one frame loop. The viewport stays a real scroll container with `overflow: hidden`:
|
|
2
|
+
// the engine writes scrollTop and scrollLeft, so focus, find-in-page, anchors, sticky positioning, screen readers and
|
|
3
|
+
// any library listening for `scroll` (a virtualizer) keep working. Input modules only move numbers here.
|
|
4
|
+
import { coast, rubber, spring, unrubber } from './physics'
|
|
5
|
+
import { snapStep, snapTo } from './snap'
|
|
6
|
+
import { axisState, type AxisState } from './axis'
|
|
7
|
+
import type { Resolved } from './options'
|
|
8
|
+
import type { Axis, ScrollSource, ScrollState } from './types'
|
|
9
|
+
|
|
10
|
+
export type { AxisState } from './axis'
|
|
11
|
+
|
|
12
|
+
let query: MediaQueryList | null | undefined
|
|
13
|
+
/** One shared query for every scroller: making a MediaQueryList costs more than the rest of a scroller's setup. */
|
|
14
|
+
export const reduceQuery = () =>
|
|
15
|
+
(query ??=
|
|
16
|
+
typeof matchMedia === 'function' ? matchMedia('(prefers-reduced-motion: reduce)') : null)
|
|
17
|
+
|
|
18
|
+
/** Every live scroller by its viewport, so nested ones can find the one outside them. */
|
|
19
|
+
export const registry = new WeakMap<Element, Engine>()
|
|
20
|
+
|
|
21
|
+
export class Engine {
|
|
22
|
+
readonly ax = { x: axisState('x'), y: axisState('y') }
|
|
23
|
+
rtl = false
|
|
24
|
+
source: ScrollSource = 'api'
|
|
25
|
+
frame = 0
|
|
26
|
+
last = 0
|
|
27
|
+
dispatching = false
|
|
28
|
+
measured = false
|
|
29
|
+
/** DOM thumbs follow a CSS scroll timeline, on the compositor: frames leave their position alone. */
|
|
30
|
+
timeline = false
|
|
31
|
+
private dirty = false
|
|
32
|
+
private seen = 0
|
|
33
|
+
/** Runs after each frame's writes: the bars draw here. */
|
|
34
|
+
onFrame: (now: number) => boolean = () => false
|
|
35
|
+
/** Runs after an autoscroll step, so a held selection can follow the content. */
|
|
36
|
+
afterAuto: (() => void) | null = null
|
|
37
|
+
/** Runs when the position moves for any reason: the bars show. */
|
|
38
|
+
onActivity: () => void = () => {}
|
|
39
|
+
/** What `destroy` runs. */
|
|
40
|
+
destroyHooks: (() => void)[] = []
|
|
41
|
+
|
|
42
|
+
constructor(
|
|
43
|
+
readonly root: HTMLElement,
|
|
44
|
+
readonly vp: HTMLElement,
|
|
45
|
+
public content: HTMLElement | null,
|
|
46
|
+
public o: Resolved,
|
|
47
|
+
) {}
|
|
48
|
+
|
|
49
|
+
get axes(): AxisState[] {
|
|
50
|
+
return [this.ax.x, this.ax.y]
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
reduced() {
|
|
54
|
+
return this.o.motion === 'reduce' || (this.o.motion === 'auto' && !!reduceQuery()?.matches)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
parent(): Engine | null {
|
|
58
|
+
const up = this.root.parentElement?.closest('[data-scroll-viewport]')
|
|
59
|
+
return (up && registry.get(up)) || null
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Read sizes and the position from the DOM. Only call where layout is already clean or a read is wanted. */
|
|
63
|
+
measure() {
|
|
64
|
+
this.measured = true
|
|
65
|
+
const { vp } = this
|
|
66
|
+
this.rtl = getComputedStyle(vp).direction === 'rtl'
|
|
67
|
+
const { x, y } = this.ax
|
|
68
|
+
y.view = vp.clientHeight
|
|
69
|
+
y.total = vp.scrollHeight
|
|
70
|
+
y.max = Math.max(0, y.total - y.view)
|
|
71
|
+
x.view = vp.clientWidth
|
|
72
|
+
x.total = vp.scrollWidth
|
|
73
|
+
const wide = Math.max(0, x.total - x.view)
|
|
74
|
+
x.min = this.rtl ? -wide : 0
|
|
75
|
+
x.max = this.rtl ? 0 : wide
|
|
76
|
+
for (const a of this.axes) {
|
|
77
|
+
if (!a.anim && a.pos === a.written)
|
|
78
|
+
a.pos = a.written = a.axis === 'y' ? vp.scrollTop : vp.scrollLeft
|
|
79
|
+
a.pos = clamp(a.pos, a.min, a.max)
|
|
80
|
+
if (a.track) {
|
|
81
|
+
const inset = this.o.bar.inset
|
|
82
|
+
const long = a.axis === 'y' ? a.track.clientHeight : a.track.clientWidth
|
|
83
|
+
a.trackLen = Math.max(0, long - 2 * inset)
|
|
84
|
+
a.trackCross = a.axis === 'y' ? a.track.clientWidth : a.track.clientHeight
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
this.measureSnap()
|
|
88
|
+
this.dirty = true
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private measureSnap() {
|
|
92
|
+
const s = this.o.snap
|
|
93
|
+
for (const a of this.axes) a.points = []
|
|
94
|
+
if (!s) return
|
|
95
|
+
const a = this.ax[s.axis ?? 'y']
|
|
96
|
+
const given = typeof s.points === 'function' ? s.points() : (s.points ?? [])
|
|
97
|
+
a.points = [...given]
|
|
98
|
+
if (!s.selector) return
|
|
99
|
+
const box = this.vp.getBoundingClientRect()
|
|
100
|
+
const y = a.axis === 'y'
|
|
101
|
+
for (const el of this.vp.querySelectorAll(s.selector)) {
|
|
102
|
+
const r = el.getBoundingClientRect()
|
|
103
|
+
const at = (y ? r.top - box.top : r.left - box.left) + a.pos
|
|
104
|
+
const size = y ? r.height : r.width
|
|
105
|
+
const align = s.align ?? 'start'
|
|
106
|
+
a.points.push(
|
|
107
|
+
align === 'start' ? at : align === 'end' ? at + size - a.view : at + size / 2 - a.view / 2,
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Measure now if no ResizeObserver has yet: input or an API call before the first layout batch. */
|
|
113
|
+
ready() {
|
|
114
|
+
if (!this.measured) this.measure()
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
canMove(axis: Axis, dir: number) {
|
|
118
|
+
this.ready()
|
|
119
|
+
const a = this.ax[axis]
|
|
120
|
+
if (!a.on || a.max - a.min < 1) return false
|
|
121
|
+
return dir > 0 ? a.pos < a.max - 0.5 : dir < 0 ? a.pos > a.min + 0.5 : false
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
wake() {
|
|
125
|
+
if (!this.frame && typeof requestAnimationFrame === 'function') {
|
|
126
|
+
this.frame = requestAnimationFrame(this.tick)
|
|
127
|
+
this.last = 0
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
stop(axis?: Axis) {
|
|
132
|
+
for (const a of this.axes) {
|
|
133
|
+
if (axis && a.axis !== axis) continue
|
|
134
|
+
if (a.anim?.kind === 'spring') continue
|
|
135
|
+
a.anim = null
|
|
136
|
+
a.v = 0
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Move straight there: a trackpad delta, a finger, a thumb drag. */
|
|
141
|
+
moveTo(axis: Axis, to: number, source: ScrollSource) {
|
|
142
|
+
this.ready()
|
|
143
|
+
const a = this.ax[axis]
|
|
144
|
+
if (a.anim?.kind !== 'spring') a.anim = null
|
|
145
|
+
a.pos = clamp(to, a.min, a.max)
|
|
146
|
+
this.source = source
|
|
147
|
+
this.wake()
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Animate there over `duration`, or jump when motion is reduced. */
|
|
151
|
+
tweenTo(
|
|
152
|
+
axis: Axis,
|
|
153
|
+
to: number,
|
|
154
|
+
source: ScrollSource,
|
|
155
|
+
dur = this.o.duration,
|
|
156
|
+
ease = this.o.easing,
|
|
157
|
+
) {
|
|
158
|
+
this.ready()
|
|
159
|
+
const a = this.ax[axis]
|
|
160
|
+
const end = clamp(to, a.min, a.max)
|
|
161
|
+
this.source = source
|
|
162
|
+
if (dur <= 0 || this.reduced()) return this.moveTo(axis, end, source)
|
|
163
|
+
a.anim = { kind: 'tween', from: a.pos, to: end, start: -1, dur, ease }
|
|
164
|
+
this.wake()
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Add to where any running animation is heading, as successive wheel notches and key repeats do. */
|
|
168
|
+
tweenBy(axis: Axis, d: number, source: ScrollSource) {
|
|
169
|
+
const a = this.ax[axis]
|
|
170
|
+
const from = a.anim?.kind === 'tween' ? a.anim.to : a.pos
|
|
171
|
+
const snapped = this.o.snap?.type === 'mandatory' && a.points.length
|
|
172
|
+
this.tweenTo(axis, snapped ? snapStep(a.points, from, Math.sign(d), a.max) : from + d, source)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** A finger or grab drag: `raw` is where the finger puts the content, maybe past an edge. */
|
|
176
|
+
drag(axis: Axis, raw: number) {
|
|
177
|
+
const a = this.ax[axis]
|
|
178
|
+
a.anim = null
|
|
179
|
+
const pos = clamp(raw, a.min, a.max)
|
|
180
|
+
a.pos = pos
|
|
181
|
+
a.over = this.o.overscroll === 'bounce' && !this.reduced() ? rubber(raw - pos, a.view) : 0
|
|
182
|
+
this.source = 'touch'
|
|
183
|
+
this.wake()
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Where a drag would start from now, overscroll included, so catching a bounce doesn't jump. */
|
|
187
|
+
rawPos(axis: Axis) {
|
|
188
|
+
this.ready()
|
|
189
|
+
const a = this.ax[axis]
|
|
190
|
+
return a.pos + unrubber(a.over, a.view)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** A finger lifted with velocity `v` (px/ms, content direction): bounce back, coast, or glide to a snap point. */
|
|
194
|
+
release(axis: Axis, v: number) {
|
|
195
|
+
const a = this.ax[axis]
|
|
196
|
+
if (a.over) {
|
|
197
|
+
a.anim = { kind: 'spring', x0: a.over, v0: 0, start: -1 }
|
|
198
|
+
return this.wake()
|
|
199
|
+
}
|
|
200
|
+
const glide = this.o.momentum ? v : 0
|
|
201
|
+
if (a.points.length) {
|
|
202
|
+
const end = a.pos + (Math.abs(glide) > 0.05 ? glide / -Math.log(this.o.friction) : 0)
|
|
203
|
+
const to = snapTo(a.points, end, { ...this.o.snap, dir: Math.sign(glide), max: a.max })
|
|
204
|
+
if (to !== end || this.o.snap?.type === 'mandatory') {
|
|
205
|
+
const dur =
|
|
206
|
+
Math.abs(glide) > 0.05
|
|
207
|
+
? clamp((3 * Math.abs(to - a.pos)) / Math.abs(glide), 180, 900)
|
|
208
|
+
: 260
|
|
209
|
+
return this.tweenTo(axis, to, 'snap', dur)
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
if (Math.abs(glide) > 0.05) {
|
|
213
|
+
a.anim = { kind: 'coast', v: glide }
|
|
214
|
+
this.wake()
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Scrolling stopped: rest on a snap point if one applies. */
|
|
219
|
+
settle(axis: Axis) {
|
|
220
|
+
const a = this.ax[axis]
|
|
221
|
+
if (!a.points.length || a.anim) return
|
|
222
|
+
const to = snapTo(a.points, a.pos, { ...this.o.snap, max: a.max })
|
|
223
|
+
if (Math.abs(to - a.pos) > 0.5) this.tweenTo(axis, to, 'snap')
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Keep scrolling at `v` px/ms until called with 0: selection and drag autoscroll. */
|
|
227
|
+
auto(axis: Axis, v: number) {
|
|
228
|
+
const a = this.ax[axis]
|
|
229
|
+
if (!v) {
|
|
230
|
+
if (a.anim?.kind === 'auto') a.anim = null
|
|
231
|
+
return
|
|
232
|
+
}
|
|
233
|
+
a.anim = { kind: 'auto', v }
|
|
234
|
+
this.source = 'select'
|
|
235
|
+
this.wake()
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** The browser moved the viewport (focus, find, an anchor, someone else's scrollTop): adopt it. */
|
|
239
|
+
adopt() {
|
|
240
|
+
const t = performance.now()
|
|
241
|
+
const dt = t - this.seen
|
|
242
|
+
this.seen = t
|
|
243
|
+
for (const a of this.axes) {
|
|
244
|
+
const now = a.axis === 'y' ? this.vp.scrollTop : this.vp.scrollLeft
|
|
245
|
+
if (Math.abs(now - a.written) <= 1) continue
|
|
246
|
+
a.v = dt > 0 && dt < 100 ? (now - a.pos) / dt : 0
|
|
247
|
+
a.anim = null
|
|
248
|
+
a.pos = a.written = now
|
|
249
|
+
this.source = 'native'
|
|
250
|
+
this.dirty = true
|
|
251
|
+
}
|
|
252
|
+
if (!this.dirty) return
|
|
253
|
+
this.onActivity()
|
|
254
|
+
this.wake()
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
private step(a: AxisState, now: number, dt: number) {
|
|
258
|
+
const m = a.anim
|
|
259
|
+
if (!m) return false
|
|
260
|
+
if (m.kind === 'tween') {
|
|
261
|
+
if (m.start < 0) m.start = now - 16
|
|
262
|
+
const t = Math.min(1, (now - m.start) / m.dur)
|
|
263
|
+
const before = a.pos
|
|
264
|
+
a.pos = clamp(m.from + (m.to - m.from) * m.ease(t), a.min, a.max)
|
|
265
|
+
a.v = dt ? (a.pos - before) / dt : 0
|
|
266
|
+
if (t < 1) return true
|
|
267
|
+
a.anim = null
|
|
268
|
+
a.v = 0
|
|
269
|
+
return false
|
|
270
|
+
}
|
|
271
|
+
if (m.kind === 'coast') return this.coastStep(a, m, dt)
|
|
272
|
+
if (m.kind === 'spring') {
|
|
273
|
+
if (m.start < 0) m.start = now - 16
|
|
274
|
+
const t = now - m.start
|
|
275
|
+
a.over = spring(m.x0, m.v0, t)
|
|
276
|
+
if (t > 60 && Math.abs(a.over) < 0.4) {
|
|
277
|
+
a.over = 0
|
|
278
|
+
a.anim = null
|
|
279
|
+
return false
|
|
280
|
+
}
|
|
281
|
+
return true
|
|
282
|
+
}
|
|
283
|
+
a.pos = clamp(a.pos + m.v * dt, a.min, a.max)
|
|
284
|
+
this.afterAuto?.()
|
|
285
|
+
return true
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
private coastStep(a: AxisState, m: { v: number }, dt: number) {
|
|
289
|
+
const { moved, v } = coast(m.v, this.o.friction, dt)
|
|
290
|
+
const next = a.pos + moved
|
|
291
|
+
m.v = a.v = v
|
|
292
|
+
if (next < a.min || next > a.max) {
|
|
293
|
+
a.pos = clamp(next, a.min, a.max)
|
|
294
|
+
const bounce = this.o.overscroll === 'bounce' && !this.reduced() && Math.abs(v) > 0.2
|
|
295
|
+
a.anim = bounce ? { kind: 'spring', x0: 0, v0: v, start: -1 } : null
|
|
296
|
+
return bounce
|
|
297
|
+
}
|
|
298
|
+
a.pos = next
|
|
299
|
+
if (Math.abs(v) >= 0.01) return true
|
|
300
|
+
a.anim = null
|
|
301
|
+
a.v = 0
|
|
302
|
+
this.settle(a.axis)
|
|
303
|
+
return !!a.anim
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
tick = (now: number) => {
|
|
307
|
+
this.frame = 0
|
|
308
|
+
const dt = this.last ? Math.min(64, now - this.last) : 16
|
|
309
|
+
this.last = now
|
|
310
|
+
let busy = false
|
|
311
|
+
for (const a of this.axes) if (this.step(a, now, dt)) busy = true
|
|
312
|
+
const moved = this.write()
|
|
313
|
+
if (moved) this.onActivity()
|
|
314
|
+
if (this.onFrame(now)) busy = true
|
|
315
|
+
if (moved || this.dirty) this.emit()
|
|
316
|
+
this.dirty = false
|
|
317
|
+
if (busy) {
|
|
318
|
+
this.frame = requestAnimationFrame(this.tick)
|
|
319
|
+
} else this.last = 0
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Write positions and overscroll to the DOM. Returns whether anything moved. */
|
|
323
|
+
write() {
|
|
324
|
+
const { x, y } = this.ax
|
|
325
|
+
let moved = false
|
|
326
|
+
if (y.pos !== y.written) {
|
|
327
|
+
this.vp.scrollTop = y.written = y.pos
|
|
328
|
+
moved = true
|
|
329
|
+
}
|
|
330
|
+
if (x.pos !== x.written) {
|
|
331
|
+
this.vp.scrollLeft = x.written = x.pos
|
|
332
|
+
moved = true
|
|
333
|
+
}
|
|
334
|
+
const over = `${x.over}|${y.over}`
|
|
335
|
+
if (this.content && this.content.dataset.over !== over) {
|
|
336
|
+
this.content.dataset.over = over
|
|
337
|
+
this.content.style.transform =
|
|
338
|
+
x.over || y.over ? `translate3d(${-x.over}px, ${-y.over}px, 0)` : ''
|
|
339
|
+
moved = true
|
|
340
|
+
}
|
|
341
|
+
// The browser fires its own in native mode, in the frame it draws.
|
|
342
|
+
if (moved && this.o.syncEvents && this.o.mode === 'engine') {
|
|
343
|
+
this.dispatching = true
|
|
344
|
+
this.vp.dispatchEvent(new Event('scroll'))
|
|
345
|
+
this.dispatching = false
|
|
346
|
+
}
|
|
347
|
+
return moved
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
state(): ScrollState {
|
|
351
|
+
this.ready()
|
|
352
|
+
const { x, y } = this.ax
|
|
353
|
+
const px = x.max - x.min > 0 ? (x.pos - x.min) / (x.max - x.min) : 0
|
|
354
|
+
return {
|
|
355
|
+
x: x.pos,
|
|
356
|
+
y: y.pos,
|
|
357
|
+
maxX: this.rtl ? x.min : x.max,
|
|
358
|
+
maxY: y.max,
|
|
359
|
+
progressX: this.rtl ? 1 - px : px,
|
|
360
|
+
progressY: y.max > 0 ? y.pos / y.max : 0,
|
|
361
|
+
overscrollX: x.over,
|
|
362
|
+
overscrollY: y.over,
|
|
363
|
+
velocityX: x.v,
|
|
364
|
+
velocityY: y.v,
|
|
365
|
+
source: this.source,
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
emit() {
|
|
370
|
+
this.o.onScroll?.(this.state())
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v))
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// @weft-js/scroll: scrolling run in JavaScript, with bars drawn in the DOM or (from `./canvas`) on a canvas.
|
|
2
|
+
export { scroller } from './scroller'
|
|
3
|
+
export { wrap } from './wrap'
|
|
4
|
+
export { domRenderer } from './render'
|
|
5
|
+
export { easings, easeOut, coastDistance, rubber } from './physics'
|
|
6
|
+
export { thumbLength, thumbOffset, dragRatio, progressAt } from './bar'
|
|
7
|
+
export { snapTo, snapStep } from './snap'
|
|
8
|
+
export { normalizeWheel, pageStep } from './wheel'
|
|
9
|
+
export type {
|
|
10
|
+
Axis,
|
|
11
|
+
BarFrame,
|
|
12
|
+
BarOptions,
|
|
13
|
+
Easing,
|
|
14
|
+
Renderer,
|
|
15
|
+
ScrollOptions,
|
|
16
|
+
ScrollSource,
|
|
17
|
+
ScrollState,
|
|
18
|
+
Scroller,
|
|
19
|
+
SnapOptions,
|
|
20
|
+
ThumbOptions,
|
|
21
|
+
} from './types'
|
package/src/keys.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Keyboard scrolling, with the keys and step sizes browsers use. A key the area can't act on (already at that edge)
|
|
2
|
+
// is left alone, so it reaches the scroller outside or the page.
|
|
3
|
+
import type { Engine } from './engine'
|
|
4
|
+
import { pageStep } from './wheel'
|
|
5
|
+
import type { Axis } from './types'
|
|
6
|
+
|
|
7
|
+
/** Roles whose widgets use the arrow keys themselves. */
|
|
8
|
+
const WIDGETS =
|
|
9
|
+
/^(?:slider|listbox|menu|menubar|grid|treegrid|tree|tablist|radiogroup|spinbutton|combobox|textbox|toolbar|option|menuitem|tab|radio)$/
|
|
10
|
+
|
|
11
|
+
/** Whether keys typed at `el` belong to it rather than to scrolling. `space` asks about the space bar. */
|
|
12
|
+
export function ownsKeys(el: Element | null, space: boolean) {
|
|
13
|
+
if (!el || !(el instanceof HTMLElement)) return false
|
|
14
|
+
if (el.isContentEditable) return true
|
|
15
|
+
const tag = el.tagName
|
|
16
|
+
if (tag === 'TEXTAREA' || tag === 'SELECT') return true
|
|
17
|
+
if (tag === 'INPUT') {
|
|
18
|
+
const t = (el as HTMLInputElement).type
|
|
19
|
+
return space || !/^(?:checkbox|radio|button|submit|reset|image|file|color)$/.test(t)
|
|
20
|
+
}
|
|
21
|
+
const role = el.getAttribute('role') ?? ''
|
|
22
|
+
if (WIDGETS.test(role)) return true
|
|
23
|
+
if (space)
|
|
24
|
+
return (
|
|
25
|
+
tag === 'BUTTON' ||
|
|
26
|
+
tag === 'SUMMARY' ||
|
|
27
|
+
tag === 'A' ||
|
|
28
|
+
role === 'button' ||
|
|
29
|
+
role === 'checkbox' ||
|
|
30
|
+
role === 'switch'
|
|
31
|
+
)
|
|
32
|
+
return false
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The move a key asks for, or null. */
|
|
36
|
+
export function keyMove(
|
|
37
|
+
e: Engine,
|
|
38
|
+
ev: KeyboardEvent,
|
|
39
|
+
): { axis: Axis; by?: number; to?: number } | null {
|
|
40
|
+
const line = e.o.lineHeight
|
|
41
|
+
const { x, y } = e.ax
|
|
42
|
+
switch (ev.key) {
|
|
43
|
+
case 'ArrowDown':
|
|
44
|
+
return { axis: 'y', by: line }
|
|
45
|
+
case 'ArrowUp':
|
|
46
|
+
return { axis: 'y', by: -line }
|
|
47
|
+
case 'ArrowRight':
|
|
48
|
+
return { axis: 'x', by: line }
|
|
49
|
+
case 'ArrowLeft':
|
|
50
|
+
return { axis: 'x', by: -line }
|
|
51
|
+
case 'PageDown':
|
|
52
|
+
return { axis: 'y', by: pageStep(y.view) }
|
|
53
|
+
case 'PageUp':
|
|
54
|
+
return { axis: 'y', by: -pageStep(y.view) }
|
|
55
|
+
case ' ':
|
|
56
|
+
return { axis: 'y', by: (ev.shiftKey ? -1 : 1) * pageStep(y.view) }
|
|
57
|
+
case 'Home':
|
|
58
|
+
return y.max > 0 || !x.on
|
|
59
|
+
? { axis: 'y', to: y.min }
|
|
60
|
+
: { axis: 'x', to: e.rtl ? x.max : x.min }
|
|
61
|
+
case 'End':
|
|
62
|
+
return y.max > 0 || !x.on
|
|
63
|
+
? { axis: 'y', to: y.max }
|
|
64
|
+
: { axis: 'x', to: e.rtl ? x.min : x.max }
|
|
65
|
+
default:
|
|
66
|
+
return null
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Listen for keys on the viewport and anything focused inside it. Returns the cleanup. */
|
|
71
|
+
export function keyInput(e: Engine) {
|
|
72
|
+
const onKey = (ev: KeyboardEvent) => {
|
|
73
|
+
if (!e.o.keyboard || ev.defaultPrevented || ev.altKey || ev.metaKey) return
|
|
74
|
+
if (ev.ctrlKey && ev.key !== 'Home' && ev.key !== 'End') return
|
|
75
|
+
const move = keyMove(e, ev)
|
|
76
|
+
if (!move || ownsKeys(ev.target as Element, ev.key === ' ')) return
|
|
77
|
+
const a = e.ax[move.axis]
|
|
78
|
+
const heading = a.anim?.kind === 'tween' ? a.anim.to : a.pos
|
|
79
|
+
const dir = move.to !== undefined ? Math.sign(move.to - heading) : Math.sign(move.by ?? 0)
|
|
80
|
+
if (!a.on || !e.canMove(move.axis, dir)) return
|
|
81
|
+
ev.preventDefault()
|
|
82
|
+
if (move.to !== undefined) e.tweenTo(move.axis, move.to, 'key')
|
|
83
|
+
else e.tweenBy(move.axis, move.by ?? 0, 'key')
|
|
84
|
+
}
|
|
85
|
+
e.vp.addEventListener('keydown', onKey)
|
|
86
|
+
return () => e.vp.removeEventListener('keydown', onKey)
|
|
87
|
+
}
|
package/src/options.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Defaults, and options resolved into the numbers the engine reads every frame.
|
|
2
|
+
import { easeOut } from './physics'
|
|
3
|
+
import type { BarFrame, BarOptions, ScrollOptions } from './types'
|
|
4
|
+
|
|
5
|
+
export type Resolved = Required<
|
|
6
|
+
Omit<ScrollOptions, 'renderer' | 'snap' | 'label' | 'onScroll' | 'thumb' | 'bar'>
|
|
7
|
+
> & { [K in 'renderer' | 'snap' | 'label' | 'onScroll']: ScrollOptions[K] | undefined } & {
|
|
8
|
+
thumb: NonNullable<ScrollOptions['thumb']>
|
|
9
|
+
bar: BarFrame['bar']
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** The look a bar has when no option or CSS says otherwise. Colours follow the text colour, so themes come free. */
|
|
13
|
+
export const BAR_DEFAULTS: BarFrame['bar'] = {
|
|
14
|
+
width: 6,
|
|
15
|
+
hoverWidth: 10,
|
|
16
|
+
inset: 2,
|
|
17
|
+
radius: 3,
|
|
18
|
+
color: 'color-mix(in srgb, currentColor 32%, transparent)',
|
|
19
|
+
hoverColor: 'color-mix(in srgb, currentColor 48%, transparent)',
|
|
20
|
+
activeColor: 'color-mix(in srgb, currentColor 60%, transparent)',
|
|
21
|
+
track: 'transparent',
|
|
22
|
+
hoverTrack: 'color-mix(in srgb, currentColor 8%, transparent)',
|
|
23
|
+
layout: 'overlay',
|
|
24
|
+
show: 'scroll',
|
|
25
|
+
hideDelay: 900,
|
|
26
|
+
fade: 200,
|
|
27
|
+
trackClick: 'page',
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function resolve(o: ScrollOptions): Resolved {
|
|
31
|
+
const bar = { ...BAR_DEFAULTS, ...defined(o.bar ?? {}) }
|
|
32
|
+
if (o.bar?.radius === undefined) bar.radius = bar.width / 2
|
|
33
|
+
return {
|
|
34
|
+
mode: o.mode ?? 'native',
|
|
35
|
+
axes: o.axes ?? 'both',
|
|
36
|
+
thumb: o.thumb ?? {},
|
|
37
|
+
bar,
|
|
38
|
+
renderer: o.renderer,
|
|
39
|
+
duration: o.duration ?? 220,
|
|
40
|
+
easing: o.easing ?? easeOut,
|
|
41
|
+
friction: o.friction ?? 0.998,
|
|
42
|
+
momentum: o.momentum ?? true,
|
|
43
|
+
wheelSpeed: o.wheelSpeed ?? 1,
|
|
44
|
+
lineHeight: o.lineHeight ?? 40,
|
|
45
|
+
overscroll: o.overscroll ?? 'bounce',
|
|
46
|
+
chain: o.chain ?? 'auto',
|
|
47
|
+
snap: o.snap,
|
|
48
|
+
keyboard: o.keyboard ?? true,
|
|
49
|
+
autoscroll: o.autoscroll ?? true,
|
|
50
|
+
touch: o.touch ?? 'js',
|
|
51
|
+
grab: o.grab ?? false,
|
|
52
|
+
motion: o.motion ?? 'auto',
|
|
53
|
+
label: o.label,
|
|
54
|
+
syncEvents: o.syncEvents ?? true,
|
|
55
|
+
onScroll: o.onScroll,
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const defined = (o: BarOptions) =>
|
|
60
|
+
Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined)) as BarOptions
|
|
61
|
+
|
|
62
|
+
/** The CSS custom properties a bar option sets, so DOM bars and stylesheets read the same values. */
|
|
63
|
+
export const BAR_VARS: Partial<Record<keyof BarOptions, string>> = {
|
|
64
|
+
width: '--scroll-width',
|
|
65
|
+
hoverWidth: '--scroll-hover-width',
|
|
66
|
+
inset: '--scroll-inset',
|
|
67
|
+
radius: '--scroll-radius',
|
|
68
|
+
color: '--scroll-color',
|
|
69
|
+
hoverColor: '--scroll-hover-color',
|
|
70
|
+
activeColor: '--scroll-active-color',
|
|
71
|
+
track: '--scroll-track',
|
|
72
|
+
hoverTrack: '--scroll-hover-track',
|
|
73
|
+
fade: '--scroll-fade',
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Write the options the caller gave as custom properties on the root; the rest come from the stylesheet. */
|
|
77
|
+
export function writeVars(root: HTMLElement, bar: BarOptions | undefined, reduced: boolean) {
|
|
78
|
+
for (const [key, name] of Object.entries(BAR_VARS)) {
|
|
79
|
+
const v = bar?.[key as keyof BarOptions]
|
|
80
|
+
if (v === undefined) root.style.removeProperty(name)
|
|
81
|
+
else
|
|
82
|
+
root.style.setProperty(
|
|
83
|
+
name,
|
|
84
|
+
typeof v === 'number' ? `${v}${key === 'fade' ? 'ms' : 'px'}` : v,
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
if (reduced) root.style.setProperty('--scroll-fade', '0ms')
|
|
88
|
+
}
|
package/src/physics.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// The numbers behind the feel: easing curves, inertia, the rubber band and the spring back. Pure, so tests pin them.
|
|
2
|
+
import type { Easing } from './types'
|
|
3
|
+
|
|
4
|
+
/** Ease-out with a soft landing; the curve browsers use for smooth scrolling is close to this. */
|
|
5
|
+
export const easeOut: Easing = (t) => 1 - (1 - t) ** 3
|
|
6
|
+
/** Named curves to pass as `easing`. */
|
|
7
|
+
export const easings = {
|
|
8
|
+
linear: (t: number) => t,
|
|
9
|
+
easeOut,
|
|
10
|
+
easeOutQuint: (t: number) => 1 - (1 - t) ** 5,
|
|
11
|
+
easeInOut: (t: number) => (t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2),
|
|
12
|
+
/** Exponential ease-out: quick start, long tail, like a coasting fling. */
|
|
13
|
+
expo: (t: number) => (t >= 1 ? 1 : 1 - 2 ** (-10 * t)),
|
|
14
|
+
} satisfies Record<string, Easing>
|
|
15
|
+
|
|
16
|
+
/** Where a coast from velocity `v` (px/ms) ends, with `friction` kept per ms. */
|
|
17
|
+
export function coastDistance(v: number, friction: number) {
|
|
18
|
+
return friction >= 1 || friction <= 0 ? 0 : v / -Math.log(friction)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Advance a coast by `dt` ms: the distance moved and the velocity left. */
|
|
22
|
+
export function coast(v: number, friction: number, dt: number) {
|
|
23
|
+
const k = friction ** dt
|
|
24
|
+
return { moved: coastDistance(v, friction) * (1 - k), v: v * k }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** How far content follows a finger dragged `excess` px past an edge of a `size` px viewport. */
|
|
28
|
+
export function rubber(excess: number, size: number) {
|
|
29
|
+
if (!excess || size <= 0) return 0
|
|
30
|
+
const s = Math.sign(excess)
|
|
31
|
+
return s * (1 - 1 / ((Math.abs(excess) * 0.55) / size + 1)) * size
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The finger distance that gives `offset` of rubber band: `rubber`'s inverse. */
|
|
35
|
+
export function unrubber(offset: number, size: number) {
|
|
36
|
+
if (!offset || size <= 0) return 0
|
|
37
|
+
const a = Math.min(Math.abs(offset), size * 0.999)
|
|
38
|
+
return (Math.sign(offset) * (size / (1 - a / size) - size)) / 0.55
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A critically damped spring toward 0, from `x0` with velocity `v0`: where it is after `t` ms. */
|
|
42
|
+
export function spring(x0: number, v0: number, t: number, omega = 0.014) {
|
|
43
|
+
return (x0 + (v0 + omega * x0) * t) * Math.exp(-omega * t)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Tracks pointer velocity over the last ~100 ms, as touch scrollers do, so a pause before lifting stops a fling. */
|
|
47
|
+
export class Velocity {
|
|
48
|
+
private samples: { t: number; x: number; y: number }[] = []
|
|
49
|
+
reset() {
|
|
50
|
+
this.samples.length = 0
|
|
51
|
+
}
|
|
52
|
+
add(t: number, x: number, y: number) {
|
|
53
|
+
this.samples.push({ t, x, y })
|
|
54
|
+
while (this.samples.length > 2 && t - this.samples[0]!.t > 100) this.samples.shift()
|
|
55
|
+
}
|
|
56
|
+
/** px per ms, pointing the way the content should move. Zero when the last move is stale. */
|
|
57
|
+
get(now: number) {
|
|
58
|
+
const s = this.samples
|
|
59
|
+
const last = s.at(-1)
|
|
60
|
+
const first = s[0]
|
|
61
|
+
if (!last || !first || last === first || now - last.t > 50) return { x: 0, y: 0 }
|
|
62
|
+
const dt = Math.max(1, last.t - first.t)
|
|
63
|
+
return { x: (last.x - first.x) / dt, y: (last.y - first.y) / dt }
|
|
64
|
+
}
|
|
65
|
+
}
|