@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/render.ts
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// The bars: their elements and ARIA, when they show, and the frame each renderer is asked to paint.
|
|
2
|
+
import { thumbLength, thumbOffset } from './bar'
|
|
3
|
+
import type { AxisState, Engine } from './engine'
|
|
4
|
+
import type { BarFrame, Renderer } from './types'
|
|
5
|
+
|
|
6
|
+
/** The default renderer: a `data-part="thumb"` element per bar, sized and moved here, styled by CSS. */
|
|
7
|
+
export const domRenderer: Renderer = {
|
|
8
|
+
mount(track) {
|
|
9
|
+
if (track.querySelector('[data-part="thumb"]')) return
|
|
10
|
+
const thumb = document.createElement('div')
|
|
11
|
+
thumb.dataset.part = 'thumb'
|
|
12
|
+
thumb.dataset.made = ''
|
|
13
|
+
track.append(thumb)
|
|
14
|
+
},
|
|
15
|
+
draw(track, f) {
|
|
16
|
+
const thumb = track.querySelector<HTMLElement>('[data-part="thumb"]')
|
|
17
|
+
if (!thumb) return
|
|
18
|
+
const y = f.axis === 'y'
|
|
19
|
+
thumb.style.transform = y
|
|
20
|
+
? `translate3d(0,${f.thumb.y}px,0)`
|
|
21
|
+
: `translate3d(${f.thumb.x}px,0,0)`
|
|
22
|
+
thumb.style[y ? 'height' : 'width'] = `${y ? f.thumb.height : f.thumb.width}px`
|
|
23
|
+
// What the stylesheet's scroll-timeline animation runs between, where the browser supports one.
|
|
24
|
+
thumb.style.setProperty('--scroll-thumb-start', `${f.span.start}px`)
|
|
25
|
+
thumb.style.setProperty('--scroll-thumb-end', `${f.span.end}px`)
|
|
26
|
+
},
|
|
27
|
+
unmount(track) {
|
|
28
|
+
const thumb = track.querySelector<HTMLElement>('[data-part="thumb"]')
|
|
29
|
+
if (thumb && thumb.dataset.made !== undefined) thumb.remove()
|
|
30
|
+
else thumb?.style.removeProperty('transform')
|
|
31
|
+
},
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let ids = 0
|
|
35
|
+
const ARIA = [
|
|
36
|
+
'role',
|
|
37
|
+
'aria-controls',
|
|
38
|
+
'aria-orientation',
|
|
39
|
+
'aria-valuemin',
|
|
40
|
+
'aria-valuemax',
|
|
41
|
+
'aria-valuenow',
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
/** Find or make each bar's track, give it scrollbar semantics, and hand it to the renderer. */
|
|
45
|
+
export function mountBars(e: Engine, current: () => Renderer) {
|
|
46
|
+
const renderer = current()
|
|
47
|
+
const made: HTMLElement[] = []
|
|
48
|
+
if (!e.vp.id) e.vp.id = `scroll-${(ids++).toString(36)}-${Math.random().toString(36).slice(2, 6)}`
|
|
49
|
+
for (const a of e.axes) {
|
|
50
|
+
if (!a.on) continue
|
|
51
|
+
const orientation = a.axis === 'y' ? 'vertical' : 'horizontal'
|
|
52
|
+
let track = e.root.querySelector<HTMLElement>(
|
|
53
|
+
`:scope > [data-part="scrollbar"][data-orientation="${orientation}"]`,
|
|
54
|
+
)
|
|
55
|
+
if (!track) {
|
|
56
|
+
track = document.createElement('div')
|
|
57
|
+
track.dataset.part = 'scrollbar'
|
|
58
|
+
track.dataset.orientation = orientation
|
|
59
|
+
e.root.append(track)
|
|
60
|
+
made.push(track)
|
|
61
|
+
}
|
|
62
|
+
track.setAttribute('role', 'scrollbar')
|
|
63
|
+
track.setAttribute('aria-controls', e.vp.id)
|
|
64
|
+
track.setAttribute('aria-orientation', orientation)
|
|
65
|
+
track.setAttribute('aria-valuemin', '0')
|
|
66
|
+
track.setAttribute('aria-valuemax', '100')
|
|
67
|
+
track.setAttribute('aria-valuenow', '0')
|
|
68
|
+
a.track = track
|
|
69
|
+
renderer.mount(track, a.axis)
|
|
70
|
+
}
|
|
71
|
+
return () => {
|
|
72
|
+
for (const a of e.axes) {
|
|
73
|
+
if (!a.track) continue
|
|
74
|
+
current().unmount(a.track)
|
|
75
|
+
for (const name of ARIA) a.track.removeAttribute(name)
|
|
76
|
+
a.track = null
|
|
77
|
+
a.drawn = ''
|
|
78
|
+
a.aria = -1
|
|
79
|
+
}
|
|
80
|
+
for (const t of made) t.remove()
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Paint whatever changed. Returns true while a hover grow is still easing (a renderer that animates it). */
|
|
85
|
+
export function drawBars(e: Engine, renderer: Renderer, dt: number) {
|
|
86
|
+
let busy = false
|
|
87
|
+
for (const a of e.axes) {
|
|
88
|
+
if (!a.track) continue
|
|
89
|
+
const target = a.hover || a.active ? 1 : 0
|
|
90
|
+
if (a.grow !== target) {
|
|
91
|
+
a.grow = renderer.animates && !e.reduced() ? ease(a.grow, target, dt) : target
|
|
92
|
+
busy ||= a.grow !== target
|
|
93
|
+
}
|
|
94
|
+
const f = frame(e, a)
|
|
95
|
+
// On a timeline the compositor moves the thumb, so only its span and size are worth a draw.
|
|
96
|
+
const at = e.timeline ? `${f.span.start}|${f.span.end}` : `${f.thumb.x}|${f.thumb.y}`
|
|
97
|
+
const key = `${at}|${f.thumb.width}|${f.thumb.height}|${a.grow}|${a.hover}|${a.active}|${f.track.width}|${f.track.height}`
|
|
98
|
+
if (key !== a.drawn) {
|
|
99
|
+
a.drawn = key
|
|
100
|
+
renderer.draw(a.track, f)
|
|
101
|
+
}
|
|
102
|
+
const now100 = Math.round(f.progress * 100)
|
|
103
|
+
if (now100 !== a.aria) {
|
|
104
|
+
a.aria = now100
|
|
105
|
+
a.track.setAttribute('aria-valuenow', String(now100))
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return busy
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const ease = (from: number, to: number, dt: number) => {
|
|
112
|
+
const next = from + (to - from) * Math.min(1, dt / 70)
|
|
113
|
+
return Math.abs(to - next) < 0.02 ? to : next
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function frame(e: Engine, a: AxisState): BarFrame {
|
|
117
|
+
const { bar } = e.o
|
|
118
|
+
const range = a.max - a.min
|
|
119
|
+
const progress = range > 0 ? (a.pos - a.min) / range : 0
|
|
120
|
+
// A native thumb shrinks against the edge while the content is pulled past it.
|
|
121
|
+
const len = Math.max(
|
|
122
|
+
Math.min(8, a.trackLen),
|
|
123
|
+
thumbLength(a.trackLen, a.view, a.total, e.o.thumb) - Math.abs(a.over) * 0.6,
|
|
124
|
+
)
|
|
125
|
+
const along = (p: number) => bar.inset + thumbOffset(a.trackLen, len, p)
|
|
126
|
+
const off = a.over > 0 ? bar.inset + a.trackLen - len : a.over < 0 ? bar.inset : along(progress)
|
|
127
|
+
// Right to left, a horizontal scroll starts at the right end.
|
|
128
|
+
const flip = a.axis === 'x' && e.rtl
|
|
129
|
+
a.thumbOff = off
|
|
130
|
+
a.thumbLen = len
|
|
131
|
+
const thick = bar.width + (bar.hoverWidth - bar.width) * a.grow
|
|
132
|
+
const y = a.axis === 'y'
|
|
133
|
+
const crossAt = y && e.rtl ? bar.inset : a.trackCross - bar.inset - thick
|
|
134
|
+
return {
|
|
135
|
+
axis: a.axis,
|
|
136
|
+
track: y
|
|
137
|
+
? { width: a.trackCross, height: a.trackLen + 2 * bar.inset }
|
|
138
|
+
: { width: a.trackLen + 2 * bar.inset, height: a.trackCross },
|
|
139
|
+
thumb: y
|
|
140
|
+
? { x: crossAt, y: off, width: thick, height: len }
|
|
141
|
+
: { x: off, y: crossAt, width: len, height: thick },
|
|
142
|
+
grow: a.grow,
|
|
143
|
+
hover: a.hover,
|
|
144
|
+
active: a.active,
|
|
145
|
+
progress: flip ? 1 - progress : progress,
|
|
146
|
+
span: { start: along(flip ? 1 : 0), end: along(flip ? 0 : 1) },
|
|
147
|
+
rtl: e.rtl,
|
|
148
|
+
bar,
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Which bars show, as data attributes the stylesheet fades on. */
|
|
153
|
+
export class Visibility {
|
|
154
|
+
private timer: ReturnType<typeof setTimeout> | undefined
|
|
155
|
+
private lastMove = 0
|
|
156
|
+
scrolling = false
|
|
157
|
+
inside = false
|
|
158
|
+
|
|
159
|
+
constructor(private e: Engine) {}
|
|
160
|
+
|
|
161
|
+
/** The position moved: show bars that show on scroll, and hide them again after the delay. */
|
|
162
|
+
moved() {
|
|
163
|
+
this.lastMove = performance.now()
|
|
164
|
+
if (!this.scrolling) {
|
|
165
|
+
this.scrolling = true
|
|
166
|
+
this.e.root.toggleAttribute('data-scrolling', true)
|
|
167
|
+
this.apply()
|
|
168
|
+
}
|
|
169
|
+
this.timer ??= setTimeout(this.idle, this.e.o.bar.hideDelay)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private idle = () => {
|
|
173
|
+
this.timer = undefined
|
|
174
|
+
const left = this.e.o.bar.hideDelay - (performance.now() - this.lastMove)
|
|
175
|
+
if (left > 4) {
|
|
176
|
+
this.timer = setTimeout(this.idle, left)
|
|
177
|
+
return
|
|
178
|
+
}
|
|
179
|
+
this.scrolling = false
|
|
180
|
+
this.e.root.toggleAttribute('data-scrolling', false)
|
|
181
|
+
this.apply()
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
apply() {
|
|
185
|
+
const { show } = this.e.o.bar
|
|
186
|
+
for (const a of this.e.axes) {
|
|
187
|
+
if (!a.track) continue
|
|
188
|
+
const none = !a.on || a.max - a.min < 1
|
|
189
|
+
a.track.toggleAttribute('data-none', none)
|
|
190
|
+
this.e.root.toggleAttribute(`data-overflow-${a.axis}`, !none)
|
|
191
|
+
const visible =
|
|
192
|
+
!none &&
|
|
193
|
+
(show === 'always' ||
|
|
194
|
+
(show !== 'never' &&
|
|
195
|
+
(this.scrolling || a.hover || a.active || (show === 'hover' && this.inside))))
|
|
196
|
+
a.track.toggleAttribute('data-visible', visible)
|
|
197
|
+
a.track.toggleAttribute('data-hover', a.hover)
|
|
198
|
+
a.track.toggleAttribute('data-active', a.active)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
destroy() {
|
|
203
|
+
clearTimeout(this.timer)
|
|
204
|
+
}
|
|
205
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// The `Scroller` a caller holds: programmatic scrolling, scrollIntoView, and live option changes.
|
|
2
|
+
import { clamp } from './engine'
|
|
3
|
+
import { resolve } from './options'
|
|
4
|
+
import type { Setup } from './scroller'
|
|
5
|
+
import type { Axis, Scroller } from './types'
|
|
6
|
+
|
|
7
|
+
type Move = { top?: number; left?: number; behavior?: ScrollBehavior }
|
|
8
|
+
|
|
9
|
+
export function api(s: Setup, configure: () => void): Scroller {
|
|
10
|
+
const { e } = s
|
|
11
|
+
const move = (to: Move, by: boolean) => {
|
|
12
|
+
for (const [axis, v] of [
|
|
13
|
+
['y', to.top],
|
|
14
|
+
['x', to.left],
|
|
15
|
+
] as [Axis, number | undefined][]) {
|
|
16
|
+
if (v === undefined || !Number.isFinite(v)) continue
|
|
17
|
+
const a = e.ax[axis]
|
|
18
|
+
const target = by ? (a.anim?.kind === 'tween' ? a.anim.to : a.pos) + v : v
|
|
19
|
+
if (to.behavior === 'smooth') e.tweenTo(axis, target, 'api')
|
|
20
|
+
else {
|
|
21
|
+
e.moveTo(axis, target, 'api')
|
|
22
|
+
e.write()
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
viewport: e.vp,
|
|
28
|
+
state: () => e.state(),
|
|
29
|
+
scrollTo: (to) => move(to, false),
|
|
30
|
+
scrollBy: (by) => move(by, true),
|
|
31
|
+
scrollIntoView(el, o = {}) {
|
|
32
|
+
const box = e.vp.getBoundingClientRect()
|
|
33
|
+
const r = el.getBoundingClientRect()
|
|
34
|
+
const m = o.margin ?? 0
|
|
35
|
+
const plan = (axis: Axis, pos: ScrollLogicalPosition) => {
|
|
36
|
+
const a = e.ax[axis]
|
|
37
|
+
const y = axis === 'y'
|
|
38
|
+
const start = (y ? r.top - box.top : r.left - box.left) + a.pos
|
|
39
|
+
const size = y ? r.height : r.width
|
|
40
|
+
let to = a.pos
|
|
41
|
+
if (pos === 'start') to = start - m
|
|
42
|
+
else if (pos === 'end') to = start + size - a.view + m
|
|
43
|
+
else if (pos === 'center') to = start + size / 2 - a.view / 2
|
|
44
|
+
else if (start - m < a.pos || size + 2 * m > a.view) to = start - m
|
|
45
|
+
else if (start + size + m > a.pos + a.view) to = start + size - a.view + m
|
|
46
|
+
return clamp(to, a.min, a.max)
|
|
47
|
+
}
|
|
48
|
+
const top = plan('y', o.block ?? 'start')
|
|
49
|
+
const left = plan('x', o.inline ?? 'nearest')
|
|
50
|
+
move({ top, left, behavior: o.behavior ?? 'auto' }, false)
|
|
51
|
+
},
|
|
52
|
+
stop: () => e.stop(),
|
|
53
|
+
refresh: s.remeasure,
|
|
54
|
+
update(next) {
|
|
55
|
+
s.options = { ...s.options, ...next }
|
|
56
|
+
const axes = e.o.axes
|
|
57
|
+
e.o = resolve(s.options)
|
|
58
|
+
const wanted = next.renderer
|
|
59
|
+
if (wanted && wanted !== s.renderer) {
|
|
60
|
+
for (const a of e.axes) {
|
|
61
|
+
if (!a.track) continue
|
|
62
|
+
s.renderer.unmount(a.track)
|
|
63
|
+
wanted.mount(a.track, a.axis)
|
|
64
|
+
a.drawn = ''
|
|
65
|
+
}
|
|
66
|
+
s.renderer = wanted
|
|
67
|
+
}
|
|
68
|
+
configure()
|
|
69
|
+
s.reinput()
|
|
70
|
+
if (e.o.axes !== axes) s.rebar()
|
|
71
|
+
else s.remeasure()
|
|
72
|
+
},
|
|
73
|
+
destroy: () => e.destroyHooks.splice(0).forEach((f) => f()),
|
|
74
|
+
}
|
|
75
|
+
}
|
package/src/scroller.ts
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
// `scroller`: puts the engine, its inputs and its bars on a root element. `scroller-api.ts` is what it returns.
|
|
2
|
+
import { autoscroll } from './autoscroll'
|
|
3
|
+
import { barInput } from './bar-input'
|
|
4
|
+
import { Engine, reduceQuery, registry } from './engine'
|
|
5
|
+
import { keyInput } from './keys'
|
|
6
|
+
import { resolve, writeVars } from './options'
|
|
7
|
+
import { domRenderer, drawBars, mountBars, Visibility } from './render'
|
|
8
|
+
import { api } from './scroller-api'
|
|
9
|
+
import { touchInput } from './touch'
|
|
10
|
+
import { wheelInput } from './wheel-input'
|
|
11
|
+
import type { Renderer, ScrollOptions, Scroller } from './types'
|
|
12
|
+
|
|
13
|
+
const FOCUSABLE =
|
|
14
|
+
'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"]),[contenteditable]:not([contenteditable="false"]),summary,iframe,audio[controls],video[controls]'
|
|
15
|
+
|
|
16
|
+
/** What the setup steps share. */
|
|
17
|
+
export interface Setup {
|
|
18
|
+
e: Engine
|
|
19
|
+
vis: Visibility
|
|
20
|
+
options: ScrollOptions
|
|
21
|
+
renderer: Renderer
|
|
22
|
+
remeasure: () => void
|
|
23
|
+
/** Rebuild the bars, for a change of axes. */
|
|
24
|
+
rebar: () => void
|
|
25
|
+
/** Attach the inputs again, for a change of mode. */
|
|
26
|
+
reinput: () => void
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Custom scrolling on `root`, which holds a `[data-part="viewport"]` element (and, inside that, the content). In the
|
|
31
|
+
* default `native` mode the browser scrolls the viewport, with its own bars hidden, and this draws the bars; in
|
|
32
|
+
* `engine` mode the viewport is `overflow: hidden` and every input runs here. Either way it keeps real scroll offsets,
|
|
33
|
+
* so focus, find, anchors and `scroll` listeners still work. Bars are found as `[data-part="scrollbar"]
|
|
34
|
+
* [data-orientation]` children of `root`, or made. Load `@weft-js/scroll/styles.css`.
|
|
35
|
+
*/
|
|
36
|
+
export function scroller(root: HTMLElement, options: ScrollOptions = {}): Scroller {
|
|
37
|
+
const vp = root.querySelector<HTMLElement>(':scope > [data-part="viewport"]') ?? root
|
|
38
|
+
registry
|
|
39
|
+
.get(vp)
|
|
40
|
+
?.destroyHooks.splice(0)
|
|
41
|
+
.forEach((f) => f())
|
|
42
|
+
const content = vp === root ? null : (vp.firstElementChild as HTMLElement | null)
|
|
43
|
+
const e = new Engine(root, vp, content, resolve(options))
|
|
44
|
+
const vis = new Visibility(e)
|
|
45
|
+
const hooks = e.destroyHooks
|
|
46
|
+
const tabStop = focusable(vp, hooks)
|
|
47
|
+
// Reads happen in one place (measure, from the ResizeObserver batch) and writes in the frame, so a page that mounts
|
|
48
|
+
// many scrollers lays out once, not once per scroller.
|
|
49
|
+
let stale = true
|
|
50
|
+
const s: Setup = {
|
|
51
|
+
e,
|
|
52
|
+
vis,
|
|
53
|
+
options,
|
|
54
|
+
renderer: e.o.renderer ?? domRenderer,
|
|
55
|
+
remeasure: () => {
|
|
56
|
+
e.measure()
|
|
57
|
+
stale = true
|
|
58
|
+
e.wake()
|
|
59
|
+
},
|
|
60
|
+
rebar: () => {},
|
|
61
|
+
reinput: () => {},
|
|
62
|
+
}
|
|
63
|
+
vp.setAttribute('data-scroll-viewport', '')
|
|
64
|
+
configure(s)
|
|
65
|
+
tabStop()
|
|
66
|
+
let unbar = mountBars(e, () => s.renderer)
|
|
67
|
+
hooks.push(() => unbar())
|
|
68
|
+
root.toggleAttribute('data-ready', true)
|
|
69
|
+
if (typeof ResizeObserver !== 'function') e.measure()
|
|
70
|
+
e.onActivity = () => vis.moved()
|
|
71
|
+
e.onFrame = (now) => {
|
|
72
|
+
if (stale && e.measured) {
|
|
73
|
+
stale = false
|
|
74
|
+
tabStop()
|
|
75
|
+
vis.apply()
|
|
76
|
+
}
|
|
77
|
+
return drawBars(e, s.renderer, e.last ? now - e.last : 16)
|
|
78
|
+
}
|
|
79
|
+
const watching = watch(s)
|
|
80
|
+
s.rebar = () => {
|
|
81
|
+
unbar()
|
|
82
|
+
unbar = mountBars(e, () => s.renderer)
|
|
83
|
+
barsOff()
|
|
84
|
+
barsOff = barInput(e, vis)
|
|
85
|
+
watching.observe()
|
|
86
|
+
s.remeasure()
|
|
87
|
+
}
|
|
88
|
+
hooks.push(watching.stop, listen(s))
|
|
89
|
+
let barsOff = barInput(e, vis)
|
|
90
|
+
let inputsOff = inputs(e)
|
|
91
|
+
s.reinput = () => {
|
|
92
|
+
inputsOff()
|
|
93
|
+
inputsOff = inputs(e)
|
|
94
|
+
}
|
|
95
|
+
hooks.push(
|
|
96
|
+
() => inputsOff(),
|
|
97
|
+
() => barsOff(),
|
|
98
|
+
)
|
|
99
|
+
hooks.push(() => {
|
|
100
|
+
vis.destroy()
|
|
101
|
+
if (e.frame) cancelAnimationFrame(e.frame)
|
|
102
|
+
if (e.content) e.content.style.transform = ''
|
|
103
|
+
for (const name of ['data-ready', 'data-timeline', 'data-mode', 'data-chain'])
|
|
104
|
+
root.removeAttribute(name)
|
|
105
|
+
vp.removeAttribute('data-scroll-viewport')
|
|
106
|
+
registry.delete(vp)
|
|
107
|
+
})
|
|
108
|
+
registry.set(vp, e)
|
|
109
|
+
e.wake()
|
|
110
|
+
return api(s, () => configure(s))
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The engine's input. In native mode even a wheel or touch listener that does nothing would cost the compositor its
|
|
115
|
+
* scrolling, so only keys (which reach the page first in any case, and which some browsers don't scroll a focused
|
|
116
|
+
* area for) and a mouse `grab` are attached.
|
|
117
|
+
*/
|
|
118
|
+
function inputs(e: Engine) {
|
|
119
|
+
const native = e.o.mode === 'native'
|
|
120
|
+
const offs = native
|
|
121
|
+
? [keyInput(e), e.o.grab ? touchInput(e) : () => {}]
|
|
122
|
+
: [wheelInput(e), keyInput(e), touchInput(e), autoscroll(e)]
|
|
123
|
+
return () => offs.forEach((f) => f())
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
let timelines: boolean | undefined
|
|
127
|
+
/** Whether CSS can drive the thumbs from the viewport's scroll position, as a named, scoped scroll timeline. */
|
|
128
|
+
const canTimeline = () =>
|
|
129
|
+
(timelines ??=
|
|
130
|
+
typeof CSS !== 'undefined' &&
|
|
131
|
+
CSS.supports('animation-timeline: --a') &&
|
|
132
|
+
CSS.supports('timeline-scope: --a'))
|
|
133
|
+
|
|
134
|
+
/** Options into attributes and custom properties on the root and viewport. */
|
|
135
|
+
function configure({ e, options, renderer }: Setup) {
|
|
136
|
+
const { root, vp } = e
|
|
137
|
+
root.dataset.mode = e.o.mode
|
|
138
|
+
root.dataset.chain = e.o.chain
|
|
139
|
+
const timeline = e.o.mode === 'native' && renderer === domRenderer && canTimeline()
|
|
140
|
+
if (timeline !== e.timeline) for (const a of e.axes) a.drawn = ''
|
|
141
|
+
e.timeline = timeline
|
|
142
|
+
root.toggleAttribute('data-timeline', timeline)
|
|
143
|
+
e.ax.y.on = e.o.axes !== 'x'
|
|
144
|
+
e.ax.x.on = e.o.axes !== 'y'
|
|
145
|
+
root.dataset.scroll ??= ''
|
|
146
|
+
root.dataset.layout = e.o.bar.layout
|
|
147
|
+
root.dataset.show = e.o.bar.show
|
|
148
|
+
root.dataset.axes = e.o.axes
|
|
149
|
+
vp.dataset.touch = e.o.touch
|
|
150
|
+
writeVars(root, options.bar, e.reduced())
|
|
151
|
+
if (e.o.label) {
|
|
152
|
+
vp.setAttribute('role', 'region')
|
|
153
|
+
vp.setAttribute('aria-label', e.o.label)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* As browsers treat scrollers: a tab stop only when nothing inside can take focus, and always focusable by a click so
|
|
159
|
+
* the keys work after one. Returns the check, to run again when the content changes.
|
|
160
|
+
*/
|
|
161
|
+
function focusable(vp: HTMLElement, hooks: (() => void)[]) {
|
|
162
|
+
if (vp.hasAttribute('tabindex')) return () => {}
|
|
163
|
+
hooks.push(() => vp.removeAttribute('tabindex'))
|
|
164
|
+
return () => {
|
|
165
|
+
vp.tabIndex = vp.querySelector(FOCUSABLE) ? -1 : 0
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Measure again when the viewport, the content or a bar changes size, or the content is swapped. */
|
|
170
|
+
function watch(s: Setup) {
|
|
171
|
+
const { e } = s
|
|
172
|
+
const ro = typeof ResizeObserver === 'function' ? new ResizeObserver(() => s.remeasure()) : null
|
|
173
|
+
const observe = () => {
|
|
174
|
+
ro?.disconnect()
|
|
175
|
+
ro?.observe(e.vp)
|
|
176
|
+
if (e.content) ro?.observe(e.content)
|
|
177
|
+
for (const a of e.axes) if (a.track) ro?.observe(a.track)
|
|
178
|
+
}
|
|
179
|
+
observe()
|
|
180
|
+
const mo =
|
|
181
|
+
typeof MutationObserver === 'function' && e.vp !== e.root
|
|
182
|
+
? new MutationObserver(() => {
|
|
183
|
+
const next = e.vp.firstElementChild as HTMLElement | null
|
|
184
|
+
if (next === e.content) return
|
|
185
|
+
e.content = next
|
|
186
|
+
observe()
|
|
187
|
+
s.remeasure()
|
|
188
|
+
})
|
|
189
|
+
: null
|
|
190
|
+
mo?.observe(e.vp, { childList: true })
|
|
191
|
+
return {
|
|
192
|
+
observe,
|
|
193
|
+
stop: () => {
|
|
194
|
+
ro?.disconnect()
|
|
195
|
+
mo?.disconnect()
|
|
196
|
+
},
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** The browser's own scrolling, the pointer over the area, and the reduced-motion setting. */
|
|
201
|
+
function listen({ e, vis, options }: Setup) {
|
|
202
|
+
let idle: ReturnType<typeof setTimeout> | undefined
|
|
203
|
+
// In native mode the browser's scroll has ended: rest on a snap point, as the engine does after its own input.
|
|
204
|
+
const settle = () => {
|
|
205
|
+
if (e.o.mode !== 'native') return
|
|
206
|
+
for (const a of e.axes) {
|
|
207
|
+
a.v = 0
|
|
208
|
+
e.settle(a.axis)
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
const ends = typeof window !== 'undefined' && 'onscrollend' in window
|
|
212
|
+
const onNative = () => {
|
|
213
|
+
if (e.dispatching) return
|
|
214
|
+
e.adopt()
|
|
215
|
+
if (ends) return
|
|
216
|
+
clearTimeout(idle)
|
|
217
|
+
idle = setTimeout(settle, 150)
|
|
218
|
+
}
|
|
219
|
+
const inside = (on: boolean) => () => {
|
|
220
|
+
vis.inside = on
|
|
221
|
+
vis.apply()
|
|
222
|
+
}
|
|
223
|
+
const enter = inside(true)
|
|
224
|
+
const leave = inside(false)
|
|
225
|
+
const motion = reduceQuery()
|
|
226
|
+
const onMotion = () => writeVars(e.root, options.bar, e.reduced())
|
|
227
|
+
e.vp.addEventListener('scroll', onNative, { passive: true })
|
|
228
|
+
e.vp.addEventListener('scrollend', settle)
|
|
229
|
+
e.root.addEventListener('pointerenter', enter)
|
|
230
|
+
e.root.addEventListener('pointerleave', leave)
|
|
231
|
+
motion?.addEventListener?.('change', onMotion)
|
|
232
|
+
return () => {
|
|
233
|
+
clearTimeout(idle)
|
|
234
|
+
e.vp.removeEventListener('scroll', onNative)
|
|
235
|
+
e.vp.removeEventListener('scrollend', settle)
|
|
236
|
+
e.root.removeEventListener('pointerenter', enter)
|
|
237
|
+
e.root.removeEventListener('pointerleave', leave)
|
|
238
|
+
motion?.removeEventListener?.('change', onMotion)
|
|
239
|
+
}
|
|
240
|
+
}
|
package/src/snap.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Choosing the snap point a scroll comes to rest on. Pure: the points come in already measured.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The point to rest at for a scroll that would end at `end`. `dir` (-1, 0, 1) is the way it was moving: a mandatory
|
|
5
|
+
* snap never pulls back against it once the scroll has passed a point. Returns `end` when nothing applies.
|
|
6
|
+
*/
|
|
7
|
+
export function snapTo(
|
|
8
|
+
points: readonly number[],
|
|
9
|
+
end: number,
|
|
10
|
+
o: { type?: 'mandatory' | 'proximity'; proximity?: number; dir?: number; max?: number } = {},
|
|
11
|
+
) {
|
|
12
|
+
if (!points.length) return end
|
|
13
|
+
const max = o.max ?? Infinity
|
|
14
|
+
let best = end
|
|
15
|
+
let gap = Infinity
|
|
16
|
+
for (const raw of points) {
|
|
17
|
+
const p = Math.min(max, Math.max(0, raw))
|
|
18
|
+
const d = Math.abs(p - end)
|
|
19
|
+
if (d < gap) {
|
|
20
|
+
gap = d
|
|
21
|
+
best = p
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
if (o.type !== 'mandatory' && gap > (o.proximity ?? 120)) return end
|
|
25
|
+
return best
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The next point past `from` in direction `dir`, for a key press or a wheel notch under mandatory snapping. */
|
|
29
|
+
export function snapStep(points: readonly number[], from: number, dir: number, max: number) {
|
|
30
|
+
const sorted = [...new Set(points.map((p) => Math.min(max, Math.max(0, p))))].sort(
|
|
31
|
+
(a, b) => a - b,
|
|
32
|
+
)
|
|
33
|
+
if (dir > 0) return sorted.find((p) => p > from + 1) ?? Math.min(max, sorted.at(-1) ?? max)
|
|
34
|
+
return sorted.findLast((p) => p < from - 1) ?? Math.max(0, sorted[0] ?? 0)
|
|
35
|
+
}
|