@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/touch.ts ADDED
@@ -0,0 +1,169 @@
1
+ // Touch and pen drags (and mouse drags with `grab`). One gesture at a time across every scroller: the innermost one
2
+ // under the finger starts it, and once the direction is clear it goes to the innermost scroller that can move that
3
+ // way, or to the page when none can.
4
+ import { Velocity } from './physics'
5
+ import { registry, type Engine } from './engine'
6
+ import type { Axis } from './types'
7
+
8
+ const SLOP = 8
9
+
10
+ interface Gesture {
11
+ id: number
12
+ chain: Engine[]
13
+ owner: Engine | null
14
+ page: Element | null
15
+ axes: Axis[]
16
+ x0: number
17
+ y0: number
18
+ lastX: number
19
+ lastY: number
20
+ raw: { x: number; y: number }
21
+ vel: Velocity
22
+ moved: boolean
23
+ }
24
+
25
+ let g: Gesture | null = null
26
+
27
+ /** The scrollers from `e` outwards. */
28
+ function chainFrom(e: Engine) {
29
+ const out: Engine[] = []
30
+ for (let at: Engine | null = e; at; at = at.parent()) out.push(at)
31
+ return out
32
+ }
33
+
34
+ /** Listen for drags starting in the viewport. Returns the cleanup. */
35
+ export function touchInput(e: Engine) {
36
+ const onDown = (ev: PointerEvent) => {
37
+ if (g?.id === ev.pointerId || !ev.isPrimary) return
38
+ const mouse = ev.pointerType === 'mouse'
39
+ const js = e.o.touch === 'js' && e.o.mode === 'engine'
40
+ if (mouse ? !e.o.grab || ev.button !== 0 : !js) return
41
+ const chain = chainFrom(e)
42
+ let caught = false
43
+ for (const s of chain)
44
+ for (const a of s.axes) {
45
+ if (a.anim?.kind === 'coast' && Math.abs(a.v) > 0.1) caught = true
46
+ if (a.anim?.kind !== 'spring') s.stop(a.axis)
47
+ }
48
+ if (mouse) ev.preventDefault()
49
+ g = {
50
+ id: ev.pointerId,
51
+ chain,
52
+ owner: null,
53
+ page: null,
54
+ axes: [],
55
+ x0: ev.clientX,
56
+ y0: ev.clientY,
57
+ lastX: ev.clientX,
58
+ lastY: ev.clientY,
59
+ raw: { x: 0, y: 0 },
60
+ vel: new Velocity(),
61
+ moved: caught,
62
+ }
63
+ g.vel.add(ev.timeStamp, -ev.clientX, -ev.clientY)
64
+ addEventListener('pointermove', onMove)
65
+ addEventListener('pointerup', onUp)
66
+ addEventListener('pointercancel', onUp)
67
+ }
68
+ e.vp.addEventListener('pointerdown', onDown)
69
+ return () => {
70
+ e.vp.removeEventListener('pointerdown', onDown)
71
+ if (g?.chain[0] === e) end()
72
+ }
73
+ }
74
+
75
+ function onMove(ev: PointerEvent) {
76
+ if (!g || ev.pointerId !== g.id) return
77
+ const dx = ev.clientX - g.x0
78
+ const dy = ev.clientY - g.y0
79
+ if (!g.axes.length) {
80
+ if (Math.hypot(dx, dy) < SLOP) return
81
+ pick(g, dx, dy)
82
+ g.moved = true
83
+ }
84
+ g.vel.add(ev.timeStamp, -ev.clientX, -ev.clientY)
85
+ if (g.owner) {
86
+ for (const axis of g.axes) g.owner.drag(axis, g.raw[axis] - (axis === 'x' ? dx : dy))
87
+ } else if (g.page) {
88
+ g.page.scrollBy({
89
+ left: g.axes.includes('x') ? g.lastX - ev.clientX : 0,
90
+ top: g.axes.includes('y') ? g.lastY - ev.clientY : 0,
91
+ behavior: 'instant',
92
+ })
93
+ }
94
+ g.lastX = ev.clientX
95
+ g.lastY = ev.clientY
96
+ }
97
+
98
+ /** Lock the direction and choose who moves: the innermost scroller that can, as browsers chain overscroll. */
99
+ function pick(gest: Gesture, dx: number, dy: number) {
100
+ const ax = Math.abs(dx)
101
+ const ay = Math.abs(dy)
102
+ const both = gest.chain[0]!.ax
103
+ if (ax > ay * 1.5 || !both.y.on) gest.axes = ['x']
104
+ else if (ay > ax * 1.5 || !both.x.on) gest.axes = ['y']
105
+ else gest.axes = ['x', 'y']
106
+ const can = (s: Engine) => gest.axes.some((a) => s.canMove(a, -Math.sign(a === 'x' ? dx : dy)))
107
+ let owner: Engine | null = null
108
+ for (const s of gest.chain) {
109
+ if (can(s) || s.o.chain === 'contain') {
110
+ owner = s
111
+ break
112
+ }
113
+ }
114
+ const outer = gest.chain.at(-1)!
115
+ if (!owner && outer.o.chain === 'auto') {
116
+ const page = scrollParent(outer.root)
117
+ if (page && gest.axes.some((a) => nativeRoom(page, a, -Math.sign(a === 'x' ? dx : dy))))
118
+ gest.page = page
119
+ }
120
+ // Nothing anywhere can move this way: the innermost area takes it, to rubber-band.
121
+ if (!owner && !gest.page) owner = gest.chain[0]!
122
+ gest.owner = owner
123
+ // Measured from where the slop ended, so the content doesn't jump by the slop when the drag starts.
124
+ if (owner) for (const a of gest.axes) gest.raw[a] = owner.rawPos(a) + (a === 'x' ? dx : dy)
125
+ }
126
+
127
+ function onUp(ev: PointerEvent) {
128
+ if (!g || ev.pointerId !== g.id) return
129
+ const v = ev.type === 'pointercancel' ? { x: 0, y: 0 } : g.vel.get(ev.timeStamp)
130
+ if (g.owner) for (const axis of g.axes) g.owner.release(axis, v[axis])
131
+ if (g.moved) swallowClick()
132
+ end()
133
+ }
134
+
135
+ function end() {
136
+ g = null
137
+ removeEventListener('pointermove', onMove)
138
+ removeEventListener('pointerup', onUp)
139
+ removeEventListener('pointercancel', onUp)
140
+ }
141
+
142
+ /** A drag that moved, or a tap that caught a coast, isn't a click on what's under it. */
143
+ function swallowClick() {
144
+ const stop = (ev: Event) => {
145
+ ev.stopPropagation()
146
+ ev.preventDefault()
147
+ }
148
+ addEventListener('click', stop, { capture: true, once: true })
149
+ setTimeout(() => removeEventListener('click', stop, { capture: true }), 350)
150
+ }
151
+
152
+ /** The nearest natively scrolling ancestor, or the page. */
153
+ function scrollParent(from: Element): Element | null {
154
+ for (let el = from.parentElement; el; el = el.parentElement) {
155
+ if (registry.has(el)) return null
156
+ const s = getComputedStyle(el)
157
+ if (/(auto|scroll)/.test(s.overflowY + s.overflowX) && el.scrollHeight > el.clientHeight)
158
+ return el
159
+ }
160
+ return document.scrollingElement
161
+ }
162
+
163
+ /** Whether a native scroller has room to move `dir` along `axis`. */
164
+ function nativeRoom(el: Element, axis: Axis, dir: number) {
165
+ const max = axis === 'y' ? el.scrollHeight - el.clientHeight : el.scrollWidth - el.clientWidth
166
+ const at = axis === 'y' ? el.scrollTop : Math.abs(el.scrollLeft)
167
+ const forward = axis === 'x' && getComputedStyle(el).direction === 'rtl' ? -dir : dir
168
+ return forward > 0 ? at < max - 0.5 : at > 0.5
169
+ }
package/src/types.ts ADDED
@@ -0,0 +1,178 @@
1
+ // The public shapes: options in, state out, and what a renderer is handed each frame.
2
+
3
+ /** A scroll axis. */
4
+ export type Axis = 'x' | 'y'
5
+ /** Where the movement came from. `native` is the browser moving it: focus, find-in-page, an anchor. */
6
+ export type ScrollSource = 'wheel' | 'touch' | 'key' | 'bar' | 'api' | 'snap' | 'select' | 'native'
7
+ /** Maps 0..1 time to 0..1 progress. */
8
+ export type Easing = (t: number) => number
9
+
10
+ /** The thumb's size. Leave `size` out for a thumb that grows and shrinks with the content, as a native one does. */
11
+ export interface ThumbOptions {
12
+ /** A fixed length in px: the thumb never grows or shrinks. */
13
+ size?: number
14
+ /** The shortest a proportional thumb gets. Default 24. */
15
+ min?: number
16
+ /** The longest a proportional thumb gets. */
17
+ max?: number
18
+ }
19
+
20
+ /** How the bars look and where they sit. Colours are any CSS colour. */
21
+ export interface BarOptions {
22
+ /** Thumb thickness at rest, px. Default 6. */
23
+ width?: number
24
+ /** Thumb thickness while hovered or dragged, px. Default 10. */
25
+ hoverWidth?: number
26
+ /** Gap between the thumb and the edge of the area, px. Default 2. */
27
+ inset?: number
28
+ /** Corner radius, px. Default: half the thickness. */
29
+ radius?: number
30
+ color?: string
31
+ hoverColor?: string
32
+ activeColor?: string
33
+ /** The track behind the thumb. Default transparent. */
34
+ track?: string
35
+ hoverTrack?: string
36
+ /** `overlay` floats over the content; `gutter` reserves room beside it. Default `overlay`. */
37
+ layout?: 'overlay' | 'gutter'
38
+ /** When the bar shows. `scroll` (default): while scrolling or hovered. `hover`: while the pointer is over the area. */
39
+ show?: 'always' | 'scroll' | 'hover' | 'never'
40
+ /** How long the bar stays after scrolling stops, ms. Default 900. */
41
+ hideDelay?: number
42
+ /** Fade length, ms. Default 200. */
43
+ fade?: number
44
+ /** A press on the track: `page` steps a page (repeating while held), `jump` moves there. Alt flips it. */
45
+ trackClick?: 'page' | 'jump'
46
+ }
47
+
48
+ /** Snapping, as CSS scroll-snap does it. */
49
+ export interface SnapOptions {
50
+ /** Snap positions in px, or a function returning them (read again on every resize). */
51
+ points?: readonly number[] | (() => readonly number[])
52
+ /** Elements in the content to snap to, aligned by `align`. */
53
+ selector?: string
54
+ align?: 'start' | 'center' | 'end'
55
+ /** `mandatory` always rests on a point; `proximity` only when within `proximity` px. Default `proximity`. */
56
+ type?: 'mandatory' | 'proximity'
57
+ proximity?: number
58
+ axis?: Axis
59
+ }
60
+
61
+ /** What a scroller does and how it looks. Every field is optional. */
62
+ export interface ScrollOptions {
63
+ /**
64
+ * Who moves the content. `native` (default): the browser scrolls it, on the compositor, so it keeps moving while
65
+ * the main thread is busy; this draws the bars and runs the API, and the wheel, touch and key options are the
66
+ * browser's own. `engine`: every input runs here, with this package's easing, momentum, rubber band and keys.
67
+ */
68
+ mode?: 'native' | 'engine'
69
+ /** Which axes scroll. Default `both`: an axis whose content fits shows no bar. */
70
+ axes?: Axis | 'both'
71
+ thumb?: ThumbOptions
72
+ bar?: BarOptions
73
+ /** Draws the bars. Default: DOM elements styled by CSS. `canvas()` from `@weft-js/scroll/canvas` paints them. */
74
+ renderer?: Renderer
75
+ /** Animation length for wheel notches, keys and smooth `scrollTo`, ms. Default 220. */
76
+ duration?: number
77
+ easing?: Easing
78
+ /** Touch inertia: the share of velocity kept per ms. Default 0.998 (iOS's normal rate). */
79
+ friction?: number
80
+ /** Coast after a touch fling. Default true. */
81
+ momentum?: boolean
82
+ /** Wheel distance multiplier. Default 1. */
83
+ wheelSpeed?: number
84
+ /** px for a wheel "line" and an arrow key. Default 40. */
85
+ lineHeight?: number
86
+ /** Rubber-band past the edges on touch. Default `bounce`. */
87
+ overscroll?: 'bounce' | 'none'
88
+ /** At an edge, hand the gesture to the scroller outside (`auto`), or keep it (`contain`). Default `auto`. */
89
+ chain?: 'auto' | 'contain'
90
+ snap?: SnapOptions
91
+ /** Arrows, Page keys, Home, End and Space scroll the area. Default true. */
92
+ keyboard?: boolean
93
+ /** Scroll while a text selection or a drag is held past the edge. Default true. */
94
+ autoscroll?: boolean
95
+ /** Touch input: `js` (default) runs it here; `native` leaves touch to the browser. */
96
+ touch?: 'js' | 'native'
97
+ /** Drag the content with a mouse, as a hand tool. Default false. */
98
+ grab?: boolean
99
+ /** `reduce` makes every animated scroll instant. Default `auto`: follows prefers-reduced-motion. */
100
+ motion?: 'auto' | 'reduce' | 'full'
101
+ /** The viewport's accessible name. With it, the viewport is a `region` landmark. */
102
+ label?: string
103
+ /** Also fire a `scroll` event in the frame a position is written, so listeners draw with that frame. Default true. */
104
+ syncEvents?: boolean
105
+ /** Called once per frame in which the position or overscroll changed. */
106
+ onScroll?: (state: ScrollState) => void
107
+ }
108
+
109
+ /** Where the scroller is. `x` is the DOM's `scrollLeft`, so it runs negative in right-to-left content. */
110
+ export interface ScrollState {
111
+ x: number
112
+ y: number
113
+ /** The furthest `y` and furthest-from-zero `x` can go. */
114
+ maxX: number
115
+ maxY: number
116
+ /** 0 at the start, 1 at the end, for each axis. In right-to-left content `progressX` 0 is the right edge. */
117
+ progressX: number
118
+ progressY: number
119
+ /** Rubber-band offset past an edge, px. */
120
+ overscrollX: number
121
+ overscrollY: number
122
+ /** px per ms. */
123
+ velocityX: number
124
+ velocityY: number
125
+ source: ScrollSource
126
+ }
127
+
128
+ /** One bar as a renderer draws it: boxes are px within the bar's own track. */
129
+ export interface BarFrame {
130
+ axis: Axis
131
+ track: { width: number; height: number }
132
+ thumb: { x: number; y: number; width: number; height: number }
133
+ /** 0 at rest, 1 fully hovered: a renderer can ease the thickness with it. */
134
+ grow: number
135
+ hover: boolean
136
+ active: boolean
137
+ progress: number
138
+ /** The thumb's offset along the track at the scroll start and at the end, px: what a scroll timeline animates. */
139
+ span: { start: number; end: number }
140
+ rtl: boolean
141
+ bar: Required<Omit<BarOptions, 'radius'>> & { radius: number }
142
+ }
143
+
144
+ /** Draws the bars. The scroller owns the track elements and their pointer handling; a renderer only paints. */
145
+ export interface Renderer {
146
+ /** Build what it needs inside a bar's track element. */
147
+ mount(track: HTMLElement, axis: Axis): void
148
+ /** Paint a frame. Called only when something about it changed. */
149
+ draw(track: HTMLElement, frame: BarFrame): void
150
+ /** Called while `grow` eases: true asks for another frame. */
151
+ animates?: boolean
152
+ unmount(track: HTMLElement): void
153
+ }
154
+
155
+ /** What `scroller` returns. */
156
+ export interface Scroller {
157
+ /** The current position and limits. */
158
+ state(): ScrollState
159
+ scrollTo(to: { top?: number; left?: number; behavior?: ScrollBehavior }): void
160
+ scrollBy(by: { top?: number; left?: number; behavior?: ScrollBehavior }): void
161
+ /** Bring `el` into view, as `Element.scrollIntoView` does, within this scroller only. */
162
+ scrollIntoView(
163
+ el: Element,
164
+ o?: {
165
+ block?: ScrollLogicalPosition
166
+ inline?: ScrollLogicalPosition
167
+ behavior?: ScrollBehavior
168
+ margin?: number
169
+ },
170
+ ): void
171
+ /** Stop any animation or coast where it is. */
172
+ stop(): void
173
+ /** Measure again: call after a change no ResizeObserver can see. */
174
+ refresh(): void
175
+ update(options: ScrollOptions): void
176
+ destroy(): void
177
+ readonly viewport: HTMLElement
178
+ }
@@ -0,0 +1,112 @@
1
+ // Wheel and trackpad input. A gesture latches to the scroller it started on, as browsers do: one that starts at an
2
+ // edge goes to the scroller outside (or to the page), one that reaches an edge midway stays put.
3
+ import { registry, type Engine } from './engine'
4
+ import { normalizeWheel, pageStep } from './wheel'
5
+ import type { Axis } from './types'
6
+
7
+ const LATCH_MS = 300
8
+ const SETTLE_MS = 140
9
+ let latch: { owner: Engine | Element; t: number } | null = null
10
+
11
+ /** Listen for wheel events on the viewport. Returns the cleanup. */
12
+ export function wheelInput(e: Engine) {
13
+ let settle: ReturnType<typeof setTimeout> | undefined
14
+ const onWheel = (ev: WheelEvent) => {
15
+ if (ev.defaultPrevented || ev.ctrlKey) return
16
+ const o = e.o
17
+ const step = normalizeWheel(ev, o.lineHeight, {
18
+ x: pageStep(e.ax.x.view),
19
+ y: pageStep(e.ax.y.view),
20
+ })
21
+ const dx = step.x * o.wheelSpeed
22
+ const dy = step.y * o.wheelSpeed
23
+ if (!dx && !dy) return
24
+ if (!claims(e, ev, dx, dy)) return
25
+ ev.preventDefault()
26
+ latch = { owner: e, t: performance.now() }
27
+ for (const [axis, d] of [
28
+ ['x', dx],
29
+ ['y', dy],
30
+ ] as [Axis, number][]) {
31
+ if (!d || !e.ax[axis].on) continue
32
+ if (step.notched) e.tweenBy(axis, d, 'wheel')
33
+ else {
34
+ const a = e.ax[axis]
35
+ const from = a.anim?.kind === 'tween' ? a.anim.to : a.pos
36
+ e.moveTo(axis, from + d, 'wheel')
37
+ }
38
+ }
39
+ clearTimeout(settle)
40
+ settle = setTimeout(() => {
41
+ e.settle('x')
42
+ e.settle('y')
43
+ }, SETTLE_MS)
44
+ }
45
+ e.vp.addEventListener('wheel', onWheel, { passive: false })
46
+ if (!watching) {
47
+ watching = true
48
+ addEventListener('wheel', pageTook, { passive: true })
49
+ }
50
+ return () => {
51
+ clearTimeout(settle)
52
+ e.vp.removeEventListener('wheel', onWheel)
53
+ if (latch?.owner === e) latch = null
54
+ }
55
+ }
56
+
57
+ /** Whether this scroller takes the event: it is latched here, or a new gesture it can move with. */
58
+ function claims(e: Engine, ev: WheelEvent, dx: number, dy: number) {
59
+ const live = latch && performance.now() - latch.t < LATCH_MS && performance.now() >= latch.t
60
+ if (live && latch) {
61
+ if (latch.owner === e) {
62
+ latch.t = performance.now()
63
+ return true
64
+ }
65
+ // Latched outside this scroller (a parent, or the page), or on a native scroller inside it: not ours.
66
+ const el = latch.owner instanceof Element ? latch.owner : latch.owner.vp
67
+ if (el.contains(e.vp) || (e.vp.contains(el) && el.contains(ev.target as Node))) {
68
+ latch.t = performance.now()
69
+ return false
70
+ }
71
+ }
72
+ const native = nativeTaker(ev.target as Element, e.vp, dx, dy)
73
+ if (native) {
74
+ latch = { owner: native, t: performance.now() }
75
+ return false
76
+ }
77
+ if (e.canMove('x', Math.sign(dx)) || e.canMove('y', Math.sign(dy))) return true
78
+ if (e.o.chain === 'contain') return true
79
+ // Handed on: a scroller outside may claim it; if none does, the page has it (see `pageTook`).
80
+ handed = ev
81
+ return false
82
+ }
83
+
84
+ let handed: WheelEvent | null = null
85
+ let watching = false
86
+ /** Passive, so it costs the page's own scrolling nothing: notes that a handed-on gesture went to the page. */
87
+ const pageTook = (ev: WheelEvent) => {
88
+ if (ev === handed && !ev.defaultPrevented)
89
+ latch = { owner: document.documentElement, t: performance.now() }
90
+ handed = null
91
+ }
92
+
93
+ /** A natively scrolling element between the target and the viewport that can take this delta itself. */
94
+ function nativeTaker(target: Element | null, stop: Element, dx: number, dy: number) {
95
+ for (let el = target; el && el !== stop; el = el.parentElement) {
96
+ if (registry.has(el)) return null
97
+ if (el.scrollHeight <= el.clientHeight && el.scrollWidth <= el.clientWidth) continue
98
+ const s = getComputedStyle(el)
99
+ const scrolls = (v: string) => v === 'auto' || v === 'scroll'
100
+ if (dy && scrolls(s.overflowY) && room(el.scrollTop, el.scrollHeight - el.clientHeight, dy))
101
+ return el
102
+ if (dx && scrolls(s.overflowX)) {
103
+ const wide = el.scrollWidth - el.clientWidth
104
+ const rtl = s.direction === 'rtl'
105
+ const at = rtl ? el.scrollLeft + wide : el.scrollLeft
106
+ if (room(at, wide, dx)) return el
107
+ }
108
+ }
109
+ return null
110
+ }
111
+
112
+ const room = (at: number, max: number, d: number) => (d > 0 ? at < max - 0.5 : at > 0.5)
package/src/wheel.ts ADDED
@@ -0,0 +1,32 @@
1
+ // Wheel events into pixels, and which kind of device sent them: a notched wheel animates each step, a trackpad's
2
+ // pixel deltas (already carrying the OS's own momentum) apply as they come.
3
+
4
+ /** One wheel event, normalised. */
5
+ export interface WheelStep {
6
+ x: number
7
+ y: number
8
+ /** A mouse wheel's notch, not a trackpad: animate it. */
9
+ notched: boolean
10
+ }
11
+
12
+ /** Pixels for a wheel event. `line` px per line; a page is `page` px (the viewport less a little overlap). */
13
+ export function normalizeWheel(
14
+ e: Pick<WheelEvent, 'deltaX' | 'deltaY' | 'deltaMode' | 'shiftKey'> & { wheelDeltaY?: number },
15
+ line: number,
16
+ page: { x: number; y: number },
17
+ ): WheelStep {
18
+ const scale = e.deltaMode === 1 ? line : 1
19
+ let x = e.deltaMode === 2 ? e.deltaX * page.x : e.deltaX * scale
20
+ let y = e.deltaMode === 2 ? e.deltaY * page.y : e.deltaY * scale
21
+ const raw = e.wheelDeltaY ?? 0
22
+ const notched = e.deltaMode !== 0 || (raw !== 0 && Math.abs(raw) % 120 === 0 && Math.abs(y) >= 50)
23
+ // Windows and Linux send Shift+wheel as vertical; macOS has already swapped it, so deltaX is set there.
24
+ if (e.shiftKey && !x && y) {
25
+ x = y
26
+ y = 0
27
+ }
28
+ return { x, y, notched }
29
+ }
30
+
31
+ /** The page step for a viewport of `size` px, as browsers size Page Down: most of a screen, keeping some context. */
32
+ export const pageStep = (size: number) => Math.max(size * 0.875, size - 40)
package/src/wrap.ts ADDED
@@ -0,0 +1,27 @@
1
+ // For pages without a framework: turn an element into a scroll area in place.
2
+ import { scroller } from './scroller'
3
+ import type { ScrollOptions, Scroller } from './types'
4
+
5
+ /**
6
+ * Make `el` a scroll area without writing the markup: its children move into a viewport and content wrapper, `el`
7
+ * becomes the root. `destroy` puts them back.
8
+ */
9
+ export function wrap(el: HTMLElement, options: ScrollOptions = {}): Scroller {
10
+ const viewport = document.createElement('div')
11
+ viewport.dataset.part = 'viewport'
12
+ const content = document.createElement('div')
13
+ content.dataset.part = 'content'
14
+ content.append(...el.childNodes)
15
+ viewport.append(content)
16
+ el.append(viewport)
17
+ const s = scroller(el, options)
18
+ return {
19
+ ...s,
20
+ destroy() {
21
+ s.destroy()
22
+ el.append(...content.childNodes)
23
+ viewport.remove()
24
+ delete el.dataset.scroll
25
+ },
26
+ }
27
+ }