@glitchr/stickyjs 1.0.51 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1 +1,46 @@
1
1
  # Sticky JS library
2
+
3
+ ## Scroll stops
4
+
5
+ `src/js/stops.js` - places on the page a scroll settles on, once it has come
6
+ to rest. Standalone: no jQuery, and `import '@glitchr/stickyjs/src/js/stops.js'`
7
+ brings in nothing else.
8
+
9
+ ```html
10
+ <header class="sticky-stop" data-sticky-band-down="0">…</header>
11
+ <div id="site" class="sticky-stop" data-sticky-band-up="90" data-sticky-band-down="280"></div>
12
+ ```
13
+
14
+ Each stop has a band above and below it, in px (`rem` and `em` work too).
15
+ Once a scroll comes to rest - trackpad momentum included:
16
+
17
+ - between two stops it glides on to the one it was heading for, unless it
18
+ rests inside the band of the stop it just left: that was a nudge, and it
19
+ settles back. So a band is the least a scroll must travel before the next
20
+ stop takes;
21
+ - past the last stop (and before the first) it scrolls freely, except inside
22
+ that stop's band: a scroll back towards the stop, or an overshoot past it,
23
+ returns to it.
24
+
25
+ A band never reaches past halfway to the neighbouring stop. The glide is the
26
+ browser's smooth scroll, which any wheel, touch, key or click interrupts, and
27
+ a jump under `prefers-reduced-motion`.
28
+
29
+ With two stops or more the scroller (`<html>` for the page) carries
30
+ `--sticky-stop` (the index of the stop above), `--sticky-progress` (0 at that
31
+ stop, 1 at the next) and `--sticky-progress-held` (the same, held still across
32
+ the bands), and dispatches `sticky:progress` with `{index, from, to, top,
33
+ progress, held}` in `event.detail` when they change, and `sticky:stop` with
34
+ `{stop, top}` at each glide. Both bubble to the window.
35
+
36
+ The window attaches itself when the document has stops at `DOMContentLoaded`
37
+ or `load`. Otherwise, and for scrolling elements:
38
+
39
+ ```js
40
+ StickyStops.attach(scroller, { settle: 160, tolerance: 3, bandUp: 0, bandDown: 0, progress: true, smooth: true });
41
+ StickyStops.get(scroller).state; // the last progress, or null
42
+ StickyStops.get(scroller).refresh(); // after content moved
43
+ StickyStops.pause(); StickyStops.resume(); // while a script scrolls the page itself; nests
44
+ ```
45
+
46
+ `Sticky.stops` is the same object when `sticky.js` is loaded.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glitchr/stickyjs",
3
- "version": "1.0.51",
3
+ "version": "1.2.0",
4
4
  "description": "Sticky scroll library",
5
5
  "main": "src/index.js",
6
6
  "repository": {
@@ -12,4 +12,4 @@
12
12
  "scripts": {
13
13
  "test": "echo \"Error: no test specified\" && exit 1"
14
14
  }
15
- }
15
+ }
package/src/index.js CHANGED
@@ -1,2 +1,4 @@
1
1
  import './js/sticky.js';
2
- import './css/index.css';
2
+ import './js/stops.js';
3
+ import './js/sortable.js';
4
+ import './css/index.css';
@@ -0,0 +1,399 @@
1
+ // StickySortable - drag-and-drop reordering, built to stand alone (no
2
+ // dependency on sticky.js's own scroll/hash machinery) so a consumer who
3
+ // only wants sorting doesn't pay for sticky.js's DOMContentLoaded-triggered
4
+ // global scroll/wheel/hashchange handlers. Pointer Events (not HTML5 drag-
5
+ // and-drop) so a custom placeholder and touch both work with one code path -
6
+ // requires touch-action: none on the handle in CSS.
7
+ (function (root, factory) {
8
+
9
+ if (typeof define === 'function' && define.amd) {
10
+ define(['jquery'], factory);
11
+ } else if (typeof exports === 'object') {
12
+ module.exports = factory(require('jquery'));
13
+ } else {
14
+ root.StickySortable = factory(root.jQuery);
15
+ }
16
+
17
+ })(this, function ($) {
18
+
19
+ var defaults = {
20
+ handle: null, // selector, relative to the item, or null to drag the whole item
21
+ items: '[data-sortable-item]',
22
+ axis: 'y',
23
+ dragThreshold: 4, // px of pointer movement before a press counts as a drag, not a click
24
+ autoscroll: true,
25
+ autoscrollEdge: 40, // px from the scroll container's edge that triggers scrolling
26
+ autoscrollSpeed: 12, // px per animation frame at full deflection
27
+ draggingClass: 'is-dragging',
28
+ placeholderClass: 'is-sortable-placeholder',
29
+ onChange: null, // function(order, container) - fires once, on drop, if the order changed
30
+ mergeable: false, // opt-in: dropping in the INNER zone of another item fires onMerge instead of reordering
31
+ mergeInset: 28, // px inset from an item's edges that counts as its "merge zone" rather than its "reorder edge"
32
+ mergeTargetClass: 'is-merge-target',
33
+ isMergeable: null, // function(el): bool - gates the item being DRAGGED (can it be a merge source at all); null = anything can
34
+ isMergeTarget: null, // function(el): bool - gates merge TARGET candidates; null = falls back to isMergeable (same rule both ways)
35
+ onMerge: null, // function(draggedEl, targetEl, container) - fires on drop instead of onChange, only when armed
36
+ };
37
+
38
+ function closestScrollable(el) {
39
+ var node = el.parentElement;
40
+ while (node && node !== document.body) {
41
+ var style = getComputedStyle(node);
42
+ var overflowY = style.overflowY;
43
+ if ((overflowY === 'auto' || overflowY === 'scroll') && node.scrollHeight > node.clientHeight) {
44
+ return node;
45
+ }
46
+ node = node.parentElement;
47
+ }
48
+ return document.scrollingElement || document.documentElement;
49
+ }
50
+
51
+ function StickySortable(container, options) {
52
+ this.container = container;
53
+ this.options = $.extend({}, defaults, options);
54
+ this.scroller = closestScrollable(container);
55
+ this.dragging = null;
56
+ this.placeholder = null;
57
+ this.startY = 0;
58
+ this.startIndex = -1;
59
+ this.pointerId = null;
60
+ this.autoscrollFrame = null;
61
+ this.autoscrollDirection = 0;
62
+ this.mergeTarget = null;
63
+
64
+ this.onPointerDown = this.onPointerDown.bind(this);
65
+ this.onPointerMove = this.onPointerMove.bind(this);
66
+ this.onPointerUp = this.onPointerUp.bind(this);
67
+
68
+ container.addEventListener('pointerdown', this.onPointerDown);
69
+ container.addEventListener('keydown', this.onKeyDown.bind(this));
70
+ }
71
+
72
+ StickySortable.prototype.items = function () {
73
+ return Array.prototype.slice.call(this.container.querySelectorAll(this.options.items))
74
+ .filter(function (el) { return el.parentElement === this.container; }.bind(this));
75
+ };
76
+
77
+ StickySortable.prototype.onPointerDown = function (e) {
78
+ if (this.dragging || e.button !== undefined && e.button !== 0) {
79
+ return;
80
+ }
81
+
82
+ var handle = this.options.handle ? e.target.closest(this.options.handle) : e.target;
83
+ if (!handle) {
84
+ return;
85
+ }
86
+
87
+ var item = e.target.closest(this.options.items);
88
+ if (!item || item.parentElement !== this.container) {
89
+ return;
90
+ }
91
+
92
+ this.pendingItem = item;
93
+ this.pendingPointerId = e.pointerId;
94
+ this.pendingStartX = e.clientX;
95
+ this.pendingStartY = e.clientY;
96
+
97
+ this.container.addEventListener('pointermove', this.onPendingMove = function (moveEvent) {
98
+ if (moveEvent.pointerId !== this.pendingPointerId) {
99
+ return;
100
+ }
101
+ var dx = moveEvent.clientX - this.pendingStartX;
102
+ var dy = moveEvent.clientY - this.pendingStartY;
103
+ if (Math.sqrt(dx * dx + dy * dy) >= this.options.dragThreshold) {
104
+ this.beginDrag(this.pendingItem, this.pendingPointerId, moveEvent);
105
+ }
106
+ }.bind(this));
107
+
108
+ this.container.addEventListener('pointerup', this.onPendingUp = function () {
109
+ this.container.removeEventListener('pointermove', this.onPendingMove);
110
+ this.container.removeEventListener('pointerup', this.onPendingUp);
111
+ }.bind(this), { once: true });
112
+ };
113
+
114
+ StickySortable.prototype.beginDrag = function (item, pointerId, e) {
115
+ this.container.removeEventListener('pointermove', this.onPendingMove);
116
+
117
+ this.dragging = item;
118
+ this.pointerId = pointerId;
119
+ this.startIndex = this.items().indexOf(item);
120
+
121
+ item.setPointerCapture(pointerId);
122
+ item.classList.add(this.options.draggingClass);
123
+
124
+ this.placeholder = document.createElement(item.tagName);
125
+ this.placeholder.className = this.options.placeholderClass;
126
+ this.placeholder.style.height = item.offsetHeight + 'px';
127
+ item.parentElement.insertBefore(this.placeholder, item.nextSibling);
128
+
129
+ var rect = item.getBoundingClientRect();
130
+ this.offsetX = e.clientX - rect.left;
131
+ this.offsetY = e.clientY - rect.top;
132
+ item.style.position = 'fixed';
133
+ item.style.width = rect.width + 'px';
134
+ item.style.zIndex = 1000;
135
+ item.style.pointerEvents = 'none';
136
+ this.moveItemTo(e.clientX, e.clientY);
137
+
138
+ document.addEventListener('pointermove', this.onPointerMove);
139
+ document.addEventListener('pointerup', this.onPointerUp);
140
+ };
141
+
142
+ // axis:'y' locks left to wherever the drag started (a single-column
143
+ // list has nothing to gain from tracking x, and this keeps that case's
144
+ // behavior byte-for-byte unchanged) - any other axis value follows the
145
+ // pointer on both axes, needed for a multi-column grid like the
146
+ // dashboard's widget grid.
147
+ StickySortable.prototype.moveItemTo = function (clientX, clientY) {
148
+ this.dragging.style.top = (clientY - this.offsetY) + 'px';
149
+ this.dragging.style.left = 'y' === this.options.axis
150
+ ? this.dragging.getBoundingClientRect().left + 'px'
151
+ : (clientX - this.offsetX) + 'px';
152
+ };
153
+
154
+ // Single-column mode (axis:'y'): unchanged from before - compare only
155
+ // vertical midpoints, in DOM order. Free/grid mode: "nearest sibling
156
+ // wins" alone doesn't say which SIDE to drop on (a grid has more than
157
+ // one neighbor at similar distance), so whichever axis has the larger
158
+ // offset from that sibling's center decides before-vs-after.
159
+ StickySortable.prototype.findDropTarget = function (siblings, x, y) {
160
+ if ('y' === this.options.axis) {
161
+ for (var i = 0; i < siblings.length; i++) {
162
+ var rect = siblings[i].getBoundingClientRect();
163
+ if (y < rect.top + rect.height / 2) {
164
+ return { el: siblings[i], before: true };
165
+ }
166
+ }
167
+ return null;
168
+ }
169
+
170
+ // Distance to the nearest point ON EACH RECT (clamped to its
171
+ // edges), not to its center. A wide/tall item (e.g. a 3-of-5-
172
+ // column chart card) has empty grid space free in its OWN row -
173
+ // center-to-center distance made that empty space attract to
174
+ // whatever smaller item happened to sit directly above/below it
175
+ // in a different row instead, since a small item's center can be
176
+ // numerically closer than a big item's center even though the
177
+ // big item's actual edge is right there. Rect-distance is 0 for
178
+ // any point already alongside the item (same row, past its edge),
179
+ // so the item you're visually next to wins regardless of size.
180
+ var closest = null;
181
+ var closestDist = Infinity;
182
+ siblings.forEach(function (el) {
183
+ var r = el.getBoundingClientRect();
184
+ var nearestX = Math.max(r.left, Math.min(x, r.right));
185
+ var nearestY = Math.max(r.top, Math.min(y, r.bottom));
186
+ var dist = Math.pow(x - nearestX, 2) + Math.pow(y - nearestY, 2);
187
+ if (dist < closestDist) {
188
+ closestDist = dist;
189
+ closest = { el: el, cx: r.left + r.width / 2, cy: r.top + r.height / 2 };
190
+ }
191
+ });
192
+ if (!closest) {
193
+ return null;
194
+ }
195
+
196
+ var dx = x - closest.cx;
197
+ var dy = y - closest.cy;
198
+ var before = Math.abs(dx) > Math.abs(dy) ? dx < 0 : dy < 0;
199
+ return { el: closest.el, before: before };
200
+ };
201
+
202
+ // Only relevant when options.mergeable - an inset "inner zone" of a
203
+ // sibling's own rect, distinct from findDropTarget()'s reorder logic
204
+ // above (which reacts to nearest EDGE/point, not containment). A
205
+ // point can be simultaneously "nearest" a sibling for reorder purposes
206
+ // and inside its merge zone; onPointerMove checks merge FIRST and
207
+ // short-circuits reorder entirely while a merge target is armed, so
208
+ // there's never a conflict between the two at drop time.
209
+ StickySortable.prototype.findMergeTarget = function (siblings, x, y) {
210
+ var inset = this.options.mergeInset;
211
+ // isMergeTarget, not isMergeable, for the CANDIDATE side - a
212
+ // composite can be a valid drop target (gains a pane) without
213
+ // being draggable to merge further itself; falls back to
214
+ // isMergeable when the two aren't meant to differ.
215
+ var isTarget = this.options.isMergeTarget || this.options.isMergeable;
216
+ for (var i = 0; i < siblings.length; i++) {
217
+ var el = siblings[i];
218
+ if (isTarget && !isTarget(el)) {
219
+ continue;
220
+ }
221
+ var r = el.getBoundingClientRect();
222
+ if (x >= r.left + inset && x <= r.right - inset && y >= r.top + inset && y <= r.bottom - inset) {
223
+ return el;
224
+ }
225
+ }
226
+ return null;
227
+ };
228
+
229
+ StickySortable.prototype.onPointerMove = function (e) {
230
+ if (!this.dragging || e.pointerId !== this.pointerId) {
231
+ return;
232
+ }
233
+
234
+ this.moveItemTo(e.clientX, e.clientY);
235
+
236
+ var siblings = Array.prototype.slice.call(this.container.children).filter(function (el) {
237
+ return el !== this.dragging && el !== this.placeholder && el.matches(this.options.items);
238
+ }.bind(this));
239
+
240
+ if (this.options.mergeable) {
241
+ var mergeCandidate = this.options.isMergeable && !this.options.isMergeable(this.dragging)
242
+ ? null // the item being dragged isn't itself a valid merge source (e.g. a composite) - never arm a merge for it, only allow it to reorder
243
+ : this.findMergeTarget(siblings, e.clientX, e.clientY);
244
+ if (mergeCandidate !== this.mergeTarget) {
245
+ if (this.mergeTarget) {
246
+ this.mergeTarget.classList.remove(this.options.mergeTargetClass);
247
+ }
248
+ this.mergeTarget = mergeCandidate;
249
+ if (this.mergeTarget) {
250
+ this.mergeTarget.classList.add(this.options.mergeTargetClass);
251
+ }
252
+ }
253
+ if (this.mergeTarget) {
254
+ // Armed: leave the placeholder exactly where it is - the
255
+ // dragged item's eventual REORDER position is moot, it's
256
+ // about to be replaced (along with the target) by onMerge.
257
+ if (this.options.autoscroll) {
258
+ this.updateAutoscroll(e.clientY);
259
+ }
260
+ return;
261
+ }
262
+ }
263
+
264
+ var target = this.findDropTarget(siblings, e.clientX, e.clientY);
265
+ if (target) {
266
+ this.container.insertBefore(this.placeholder, target.before ? target.el : target.el.nextSibling);
267
+ } else if (siblings.length) {
268
+ this.container.insertBefore(this.placeholder, null);
269
+ }
270
+
271
+ if (this.options.autoscroll) {
272
+ this.updateAutoscroll(e.clientY);
273
+ }
274
+ };
275
+
276
+ StickySortable.prototype.updateAutoscroll = function (clientY) {
277
+ var rect = this.scroller === document.scrollingElement || this.scroller === document.documentElement
278
+ ? { top: 0, bottom: window.innerHeight }
279
+ : this.scroller.getBoundingClientRect();
280
+
281
+ var edge = this.options.autoscrollEdge;
282
+ if (clientY < rect.top + edge) {
283
+ this.autoscrollDirection = -1;
284
+ } else if (clientY > rect.bottom - edge) {
285
+ this.autoscrollDirection = 1;
286
+ } else {
287
+ this.autoscrollDirection = 0;
288
+ }
289
+
290
+ if (this.autoscrollDirection !== 0 && !this.autoscrollFrame) {
291
+ this.runAutoscroll();
292
+ }
293
+ };
294
+
295
+ StickySortable.prototype.runAutoscroll = function () {
296
+ if (!this.dragging || this.autoscrollDirection === 0) {
297
+ this.autoscrollFrame = null;
298
+ return;
299
+ }
300
+ this.scroller.scrollTop += this.autoscrollDirection * this.options.autoscrollSpeed;
301
+ this.autoscrollFrame = requestAnimationFrame(this.runAutoscroll.bind(this));
302
+ };
303
+
304
+ StickySortable.prototype.onPointerUp = function (e) {
305
+ if (!this.dragging || e.pointerId !== this.pointerId) {
306
+ return;
307
+ }
308
+
309
+ var item = this.dragging;
310
+ item.releasePointerCapture(this.pointerId);
311
+ item.classList.remove(this.options.draggingClass);
312
+ item.style.position = '';
313
+ item.style.top = '';
314
+ item.style.left = '';
315
+ item.style.width = '';
316
+ item.style.zIndex = '';
317
+ item.style.pointerEvents = '';
318
+
319
+ this.container.insertBefore(item, this.placeholder);
320
+ this.placeholder.remove();
321
+ this.placeholder = null;
322
+
323
+ this.autoscrollDirection = 0;
324
+ if (this.autoscrollFrame) {
325
+ cancelAnimationFrame(this.autoscrollFrame);
326
+ this.autoscrollFrame = null;
327
+ }
328
+
329
+ document.removeEventListener('pointermove', this.onPointerMove);
330
+ document.removeEventListener('pointerup', this.onPointerUp);
331
+
332
+ var mergeTarget = this.mergeTarget;
333
+ if (mergeTarget) {
334
+ mergeTarget.classList.remove(this.options.mergeTargetClass);
335
+ this.mergeTarget = null;
336
+ }
337
+
338
+ var newIndex = this.items().indexOf(item);
339
+ this.dragging = null;
340
+ this.pointerId = null;
341
+
342
+ if (mergeTarget) {
343
+ if (typeof this.options.onMerge === 'function') {
344
+ this.options.onMerge(item, mergeTarget, this.container);
345
+ }
346
+ return;
347
+ }
348
+
349
+ if (newIndex !== this.startIndex && typeof this.options.onChange === 'function') {
350
+ this.options.onChange(this.items(), this.container);
351
+ }
352
+ };
353
+
354
+ /** Keyboard fallback: focus a handle, ArrowUp/ArrowDown moves its item, no pointer needed. */
355
+ StickySortable.prototype.onKeyDown = function (e) {
356
+ if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') {
357
+ return;
358
+ }
359
+
360
+ var handle = this.options.handle ? e.target.closest(this.options.handle) : null;
361
+ if (!handle) {
362
+ return;
363
+ }
364
+
365
+ var item = e.target.closest(this.options.items);
366
+ if (!item || item.parentElement !== this.container) {
367
+ return;
368
+ }
369
+
370
+ var items = this.items();
371
+ var index = items.indexOf(item);
372
+ var swapWith = e.key === 'ArrowUp' ? items[index - 1] : items[index + 1];
373
+ if (!swapWith) {
374
+ return;
375
+ }
376
+
377
+ e.preventDefault();
378
+ if (e.key === 'ArrowUp') {
379
+ this.container.insertBefore(item, swapWith);
380
+ } else {
381
+ this.container.insertBefore(item, swapWith.nextSibling);
382
+ }
383
+ handle.focus();
384
+
385
+ if (typeof this.options.onChange === 'function') {
386
+ this.options.onChange(this.items(), this.container);
387
+ }
388
+ };
389
+
390
+ $.fn.stickySortable = function (options) {
391
+ return this.each(function () {
392
+ if (!this._stickySortable) {
393
+ this._stickySortable = new StickySortable(this, options);
394
+ }
395
+ });
396
+ };
397
+
398
+ return StickySortable;
399
+ });
@@ -0,0 +1,432 @@
1
+ // StickyStops - scroll stops that act once the scroll has come to rest.
2
+ //
3
+ // Mark the stops with .sticky-stop, in the page or inside any scrolling
4
+ // element. Once a scroll comes to rest - momentum included - the scroller
5
+ // glides on to the stop the reader was heading for, or back to the one they
6
+ // had barely left:
7
+ //
8
+ // - between two stops, heading down: the lower stop - unless the scroll rests
9
+ // inside the upper stop's band below it (data-sticky-band-down), in which
10
+ // case it was a nudge and the page settles back up;
11
+ // - between two stops, heading up: the upper stop - unless inside the lower
12
+ // stop's band above it (data-sticky-band-up), which settles back down. So a
13
+ // band is also the least a scroll must travel before the next stop takes;
14
+ // - past the last stop: free scrolling, from the first pixel, except that a
15
+ // scroll up that rests inside the last stop's band below it goes back to
16
+ // the stop - and so does a scroll down that started above the stop and
17
+ // overshot into that band (a trackpad flick past a stop near the top);
18
+ // - before the first stop: the mirror image.
19
+ //
20
+ // A band never reaches past halfway to the neighbouring stop, so every stop
21
+ // stays reachable however the bands are set. Two stops at the same place are
22
+ // one stop.
23
+ //
24
+ // Nothing happens during the scroll. Acting at the first movement fights
25
+ // trackpad momentum, which keeps pushing during and after the glide; and the
26
+ // browser's own scroll-snap is blind to direction and pulls to the NEAREST
27
+ // stop, so a small scroll down from the top jumps straight back up. Here the
28
+ // glide is the browser's smooth scroll, which any wheel, touch, key or click
29
+ // interrupts, so the reader always keeps control: the stops only complete, or
30
+ // undo, a movement the reader started. With prefers-reduced-motion the glide
31
+ // is a jump.
32
+ //
33
+ // Progress: with two stops or more, the scroller (the <html> element for the
34
+ // page) carries three custom properties - --sticky-stop, the index of the stop
35
+ // above the scroll; --sticky-progress, 0 at that stop and 1 at the next; and
36
+ // --sticky-progress-held, the same but held at 0 across the upper stop's band
37
+ // below it and at 1 across the lower stop's band above it, so that whatever it
38
+ // drives does not move for a nudge that will settle back. A sticky:progress
39
+ // event on the scroller (it bubbles to the window) carries the same numbers
40
+ // for scripts, and a sticky:stop event announces each glide.
41
+ //
42
+ // Standalone: no jQuery and no sticky.js, and only the window's own scroll
43
+ // listener until a stop exists. Attached to the window by itself when the
44
+ // document has stops; StickyStops.attach(scroller) for a scrolling element,
45
+ // or for stops added after the page loaded. Sticky.stops is the same object
46
+ // when sticky.js is loaded.
47
+ (function (root, factory) {
48
+
49
+ var api = factory(root);
50
+ if (typeof module === 'object' && module.exports) module.exports = api;
51
+ root.StickyStops = api;
52
+ if (root.Sticky) root.Sticky.stops = api;
53
+
54
+ })(typeof window !== 'undefined' ? window : this, function (window) {
55
+
56
+ var document = window.document;
57
+
58
+ var defaults = {
59
+ selector: '.sticky-stop',
60
+ settle: 160, // ms of quiet that counts as "at rest": longer than the gap between momentum events
61
+ tolerance: 3, // px: within this of a stop is at it
62
+ bandUp: 0, // px, for a stop without data-sticky-band-up
63
+ bandDown: 0, // px, for a stop without data-sticky-band-down
64
+ progress: true, // the custom properties and sticky:progress events
65
+ smooth: true // glide, rather than jump, to a stop
66
+ };
67
+
68
+ // The reader taking over ends a glide; the browser has already stopped it.
69
+ var INTERRUPT = ['wheel', 'touchstart', 'keydown', 'mousedown'];
70
+
71
+ function extend(target) {
72
+ for (var i = 1; i < arguments.length; i++) {
73
+ var source = arguments[i];
74
+ if (!source) continue;
75
+ for (var key in source) {
76
+ if (Object.prototype.hasOwnProperty.call(source, key) && source[key] !== undefined) target[key] = source[key];
77
+ }
78
+ }
79
+ return target;
80
+ }
81
+
82
+ function clamp(x, low, high) { return Math.min(high, Math.max(low, x)); }
83
+
84
+ // "90", "90px", "2rem" or "1.5em" to px; anything else is the fallback.
85
+ function toPixels(value, el, fallback) {
86
+ if (value === undefined || value === null || value === '') return fallback;
87
+ var text = String(value).trim();
88
+ var number = parseFloat(text);
89
+ if (isNaN(number)) return fallback;
90
+ if (text.slice(-3) === 'rem') return number * parseFloat(getComputedStyle(document.documentElement).fontSize);
91
+ if (text.slice(-2) === 'em') return number * parseFloat(getComputedStyle(el).fontSize);
92
+ return number;
93
+ }
94
+
95
+ function reducedMotion() {
96
+ return !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
97
+ }
98
+
99
+ function Stops(scroller, options) {
100
+ this.scroller = scroller;
101
+ this.isWindow = scroller === window;
102
+ // Carries the custom properties and dispatches the events.
103
+ this.root = this.isWindow ? document.documentElement : scroller;
104
+ this.options = extend({}, defaults, options);
105
+
106
+ this.stops = [];
107
+ this.state = null;
108
+ this.last = this.top();
109
+ // Where the current gesture began: the position before its first event.
110
+ this.start = this.last;
111
+ this.direction = 0;
112
+ this.gliding = false;
113
+ this.paused = 0;
114
+ this.timer = null;
115
+ this.glideTimer = null;
116
+ this.frame = null;
117
+
118
+ this.onScroll = this.onScroll.bind(this);
119
+ this.onInterrupt = this.onInterrupt.bind(this);
120
+ this.onResize = this.onResize.bind(this);
121
+ this.settle = this.settle.bind(this);
122
+ this.tick = this.tick.bind(this);
123
+
124
+ this.scroller.addEventListener('scroll', this.onScroll, { passive: true });
125
+ for (var i = 0; i < INTERRUPT.length; i++) {
126
+ this.scroller.addEventListener(INTERRUPT[i], this.onInterrupt, { passive: true });
127
+ }
128
+ window.addEventListener('resize', this.onResize);
129
+
130
+ this.refresh();
131
+ }
132
+
133
+ Stops.prototype.detach = function () {
134
+ this.scroller.removeEventListener('scroll', this.onScroll);
135
+ for (var i = 0; i < INTERRUPT.length; i++) {
136
+ this.scroller.removeEventListener(INTERRUPT[i], this.onInterrupt);
137
+ }
138
+ window.removeEventListener('resize', this.onResize);
139
+ clearTimeout(this.timer);
140
+ clearTimeout(this.glideTimer);
141
+ if (this.frame !== null) cancelAnimationFrame(this.frame);
142
+
143
+ var style = this.root.style;
144
+ style.removeProperty('--sticky-stop');
145
+ style.removeProperty('--sticky-progress');
146
+ style.removeProperty('--sticky-progress-held');
147
+
148
+ var at = instances.indexOf(this);
149
+ if (at !== -1) instances.splice(at, 1);
150
+ return this;
151
+ };
152
+
153
+ /** Hold the stops off, e.g. while a script scrolls the page itself. Nests. */
154
+ Stops.prototype.pause = function () { this.paused++; return this; };
155
+ Stops.prototype.resume = function () { this.paused = Math.max(0, this.paused - 1); return this; };
156
+
157
+ Stops.prototype.top = function () {
158
+ return this.isWindow ? (window.scrollY || window.pageYOffset || 0) : this.scroller.scrollTop;
159
+ };
160
+
161
+ /** The scroller's scroll-padding-top: where a stop should rest below its edge. */
162
+ Stops.prototype.padding = function () {
163
+ var value = parseFloat(getComputedStyle(this.root).scrollPaddingTop);
164
+ return isNaN(value) ? 0 : value;
165
+ };
166
+
167
+ /** A stop's scroll position: where it is on screen now, not its layout box. */
168
+ Stops.prototype.position = function (el) {
169
+ var origin = this.isWindow ? 0 : this.scroller.getBoundingClientRect().top;
170
+ return el.getBoundingClientRect().top - origin + this.top() - this.padding();
171
+ };
172
+
173
+ Stops.prototype.measure = function () {
174
+ var options = this.options;
175
+ var elements = this.root.querySelectorAll(options.selector);
176
+ var stops = [];
177
+ var i;
178
+
179
+ for (i = 0; i < elements.length; i++) {
180
+ var el = elements[i];
181
+ if (!el.getClientRects().length) continue; // display: none
182
+ stops.push({
183
+ element: el,
184
+ top: Math.round(this.position(el)),
185
+ bandUp: Math.max(0, toPixels(el.dataset.stickyBandUp, el, options.bandUp)),
186
+ bandDown: Math.max(0, toPixels(el.dataset.stickyBandDown, el, options.bandDown))
187
+ });
188
+ }
189
+ stops.sort(function (a, b) { return a.top - b.top; });
190
+
191
+ // Two stops at the same place are one stop.
192
+ var unique = [];
193
+ for (i = 0; i < stops.length; i++) {
194
+ if (unique.length && stops[i].top - unique[unique.length - 1].top <= options.tolerance) continue;
195
+ unique.push(stops[i]);
196
+ }
197
+
198
+ // A band reaches at most halfway to the neighbouring stop, so both
199
+ // stay reachable however the bands are set.
200
+ for (i = 0; i < unique.length; i++) {
201
+ if (i > 0) unique[i].bandUp = Math.min(unique[i].bandUp, (unique[i].top - unique[i - 1].top) / 2);
202
+ if (i < unique.length - 1) unique[i].bandDown = Math.min(unique[i].bandDown, (unique[i + 1].top - unique[i].top) / 2);
203
+ }
204
+
205
+ this.stops = unique;
206
+ return unique;
207
+ };
208
+
209
+ Stops.prototype.glide = function (stop) {
210
+ var behavior = this.options.smooth && !reducedMotion() ? 'smooth' : 'auto';
211
+
212
+ this.gliding = true;
213
+ // A stop the scroller cannot reach (past its end) fires no scroll
214
+ // event: do not stay "gliding" waiting for one.
215
+ clearTimeout(this.glideTimer);
216
+ this.glideTimer = setTimeout(this.onInterrupt, 2 * this.options.settle);
217
+
218
+ this.root.dispatchEvent(new CustomEvent('sticky:stop', {
219
+ bubbles: true,
220
+ detail: { scroller: this.scroller, stop: stop.element, top: stop.top }
221
+ }));
222
+ this.scroller.scrollTo({ top: stop.top, behavior: behavior });
223
+ };
224
+
225
+ Stops.prototype.settle = function () {
226
+ this.timer = null;
227
+
228
+ // A glide of ours has just arrived (or been interrupted): let go.
229
+ if (this.gliding) {
230
+ this.gliding = false;
231
+ return;
232
+ }
233
+ if (this.paused || api.paused) return;
234
+
235
+ var stops = this.measure();
236
+ if (!stops.length) return;
237
+
238
+ var tolerance = this.options.tolerance;
239
+ var y = this.top();
240
+ var direction = this.direction;
241
+ var from = this.start;
242
+ var i;
243
+
244
+ // At a stop already: nothing to finish.
245
+ for (i = 0; i < stops.length; i++) {
246
+ if (Math.abs(y - stops[i].top) <= tolerance) return;
247
+ }
248
+
249
+ var first = stops[0];
250
+ var last = stops[stops.length - 1];
251
+
252
+ // Before the first stop: inside its band above it, heading down - or
253
+ // an overshoot from below - goes to the stop. Otherwise free.
254
+ if (y < first.top) {
255
+ var overshotUp = from > first.top + tolerance;
256
+ if (y > first.top - first.bandUp && (direction > 0 || overshotUp)) this.glide(first);
257
+ return;
258
+ }
259
+
260
+ // Past the last stop: inside its band below it, heading up - or an
261
+ // overshoot from above - goes back to the stop. Otherwise free.
262
+ if (y > last.top) {
263
+ var overshotDown = from < last.top - tolerance;
264
+ if (y < last.top + last.bandDown && (direction < 0 || overshotDown)) this.glide(last);
265
+ return;
266
+ }
267
+
268
+ // Between two stops: on to the one the reader was heading for, unless
269
+ // the scroll barely left the other, in which case back to it.
270
+ for (i = 0; i < stops.length - 1; i++) {
271
+ var upper = stops[i];
272
+ var lower = stops[i + 1];
273
+ if (y <= upper.top || y >= lower.top) continue;
274
+
275
+ if (direction > 0) this.glide(y < upper.top + upper.bandDown ? upper : lower);
276
+ else if (direction < 0) this.glide(y > lower.top - lower.bandUp ? lower : upper);
277
+ return;
278
+ }
279
+ };
280
+
281
+ Stops.prototype.onScroll = function () {
282
+ var y = this.top();
283
+ var step = y > this.last ? 1 : (y < this.last ? -1 : 0);
284
+
285
+ clearTimeout(this.glideTimer);
286
+
287
+ // The first event after a rest starts a new gesture.
288
+ if (this.timer === null && !this.gliding) this.start = this.last;
289
+ this.last = y;
290
+
291
+ // Which way the READER is going, not our own glide.
292
+ if (!this.gliding && step) this.direction = step;
293
+
294
+ clearTimeout(this.timer);
295
+ this.timer = setTimeout(this.settle, this.options.settle);
296
+
297
+ if (this.options.progress) this.queue();
298
+ };
299
+
300
+ Stops.prototype.onInterrupt = function () {
301
+ this.gliding = false;
302
+ };
303
+
304
+ Stops.prototype.onResize = function () {
305
+ this.refresh();
306
+ };
307
+
308
+ /** Re-measure the stops and report the progress. For content that moved. */
309
+ Stops.prototype.refresh = function () {
310
+ this.measure();
311
+ if (this.options.progress) this.progress();
312
+ return this;
313
+ };
314
+
315
+ Stops.prototype.queue = function () {
316
+ if (this.frame !== null) return;
317
+ this.frame = requestAnimationFrame(this.tick);
318
+ };
319
+
320
+ Stops.prototype.tick = function () {
321
+ this.frame = null;
322
+ this.progress();
323
+ };
324
+
325
+ /**
326
+ * Where the scroll is between its two nearest stops: {index, from, to,
327
+ * top, progress, held}, also written to the custom properties and sent as
328
+ * sticky:progress when it changed. null with fewer than two stops.
329
+ */
330
+ Stops.prototype.progress = function () {
331
+ var stops = this.measure();
332
+ var style = this.root.style;
333
+
334
+ if (stops.length < 2) {
335
+ if (this.state) {
336
+ style.removeProperty('--sticky-stop');
337
+ style.removeProperty('--sticky-progress');
338
+ style.removeProperty('--sticky-progress-held');
339
+ }
340
+ this.state = null;
341
+ return null;
342
+ }
343
+
344
+ var y = this.top();
345
+ var i = 0;
346
+ while (i < stops.length - 2 && y >= stops[i + 1].top) i++;
347
+
348
+ var upper = stops[i];
349
+ var lower = stops[i + 1];
350
+ var progress = clamp((y - upper.top) / (lower.top - upper.top), 0, 1);
351
+
352
+ // Held across the bands: still until the scroll has cleared them.
353
+ var a = upper.top + upper.bandDown;
354
+ var b = lower.top - lower.bandUp;
355
+ var held = b > a ? clamp((y - a) / (b - a), 0, 1) : (y < b ? 0 : 1);
356
+
357
+ var previous = this.state;
358
+ var state = {
359
+ scroller: this.scroller,
360
+ index: i,
361
+ from: upper.element,
362
+ to: lower.element,
363
+ top: y,
364
+ progress: progress,
365
+ held: held
366
+ };
367
+ this.state = state;
368
+
369
+ if (previous && previous.index === i && previous.progress === progress && previous.held === held) return state;
370
+
371
+ style.setProperty('--sticky-stop', String(i));
372
+ style.setProperty('--sticky-progress', progress.toFixed(4));
373
+ style.setProperty('--sticky-progress-held', held.toFixed(4));
374
+ this.root.dispatchEvent(new CustomEvent('sticky:progress', { bubbles: true, detail: state }));
375
+
376
+ return state;
377
+ };
378
+
379
+ var instances = [];
380
+
381
+ function normalize(scroller) {
382
+ if (!scroller || scroller === document || scroller === document.documentElement || scroller === document.body) return window;
383
+ return scroller;
384
+ }
385
+
386
+ var api = {
387
+ defaults: defaults,
388
+ Stops: Stops,
389
+ paused: 0,
390
+
391
+ /** The stops of a scroller (the window by default); made if needed. */
392
+ attach: function (scroller, options) {
393
+ scroller = normalize(scroller);
394
+ var found = api.get(scroller);
395
+ if (found) {
396
+ if (options) extend(found.options, options);
397
+ return found.refresh();
398
+ }
399
+ var made = new Stops(scroller, options);
400
+ instances.push(made);
401
+ return made;
402
+ },
403
+
404
+ get: function (scroller) {
405
+ scroller = normalize(scroller);
406
+ for (var i = 0; i < instances.length; i++) {
407
+ if (instances[i].scroller === scroller) return instances[i];
408
+ }
409
+ return null;
410
+ },
411
+
412
+ /** Hold every scroller's stops off, e.g. while a script scrolls the page. Nests. */
413
+ pause: function () { api.paused++; return api; },
414
+ resume: function () { api.paused = Math.max(0, api.paused - 1); return api; },
415
+
416
+ refresh: function () {
417
+ for (var i = 0; i < instances.length; i++) instances[i].refresh();
418
+ return api;
419
+ }
420
+ };
421
+
422
+ // The page's own stops, without being asked - and again on load, for a
423
+ // page swapped in place that re-dispatches it.
424
+ function auto() {
425
+ if (document.querySelector(defaults.selector)) api.attach(window);
426
+ }
427
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', auto);
428
+ else auto();
429
+ window.addEventListener('load', auto);
430
+
431
+ return api;
432
+ });