@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/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
+ }