@glitchr/stickyjs 1.0.51 → 1.2.1
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 +45 -0
- package/package.json +2 -2
- package/src/index.js +3 -1
- package/src/js/sortable.js +399 -0
- package/src/js/stops.js +465 -0
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.
|
|
3
|
+
"version": "1.2.1",
|
|
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
|
@@ -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
|
+
});
|
package/src/js/stops.js
ADDED
|
@@ -0,0 +1,465 @@
|
|
|
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
|
+
// Nor does a scroll the browser makes to bring a focused field into view: an
|
|
34
|
+
// invalid form submission revealing its first empty field, or Tab. Heading up
|
|
35
|
+
// from the text, that scroll was taken for the reader's and carried the page
|
|
36
|
+
// on to the stop above - the form gone from the screen it had just asked for.
|
|
37
|
+
//
|
|
38
|
+
// Progress: with two stops or more, the scroller (the <html> element for the
|
|
39
|
+
// page) carries three custom properties - --sticky-stop, the index of the stop
|
|
40
|
+
// above the scroll; --sticky-progress, 0 at that stop and 1 at the next; and
|
|
41
|
+
// --sticky-progress-held, the same but held at 0 across the upper stop's band
|
|
42
|
+
// below it and at 1 across the lower stop's band above it, so that whatever it
|
|
43
|
+
// drives does not move for a nudge that will settle back. A sticky:progress
|
|
44
|
+
// event on the scroller (it bubbles to the window) carries the same numbers
|
|
45
|
+
// for scripts, and a sticky:stop event announces each glide.
|
|
46
|
+
//
|
|
47
|
+
// Standalone: no jQuery and no sticky.js, and only the window's own scroll
|
|
48
|
+
// listener until a stop exists. Attached to the window by itself when the
|
|
49
|
+
// document has stops; StickyStops.attach(scroller) for a scrolling element,
|
|
50
|
+
// or for stops added after the page loaded. Sticky.stops is the same object
|
|
51
|
+
// when sticky.js is loaded.
|
|
52
|
+
(function (root, factory) {
|
|
53
|
+
|
|
54
|
+
var api = factory(root);
|
|
55
|
+
if (typeof module === 'object' && module.exports) module.exports = api;
|
|
56
|
+
root.StickyStops = api;
|
|
57
|
+
if (root.Sticky) root.Sticky.stops = api;
|
|
58
|
+
|
|
59
|
+
})(typeof window !== 'undefined' ? window : this, function (window) {
|
|
60
|
+
|
|
61
|
+
var document = window.document;
|
|
62
|
+
|
|
63
|
+
var defaults = {
|
|
64
|
+
selector: '.sticky-stop',
|
|
65
|
+
settle: 160, // ms of quiet that counts as "at rest": longer than the gap between momentum events
|
|
66
|
+
tolerance: 3, // px: within this of a stop is at it
|
|
67
|
+
bandUp: 0, // px, for a stop without data-sticky-band-up
|
|
68
|
+
bandDown: 0, // px, for a stop without data-sticky-band-down
|
|
69
|
+
progress: true, // the custom properties and sticky:progress events
|
|
70
|
+
smooth: true // glide, rather than jump, to a stop
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// The reader taking over ends a glide; the browser has already stopped it.
|
|
74
|
+
var INTERRUPT = ['wheel', 'touchstart', 'keydown', 'mousedown'];
|
|
75
|
+
|
|
76
|
+
// ms after a focus change in which a scroll starting is the browser
|
|
77
|
+
// revealing the focused field, not the reader.
|
|
78
|
+
var REVEAL = 250;
|
|
79
|
+
|
|
80
|
+
function now() { return window.performance ? performance.now() : Date.now(); }
|
|
81
|
+
|
|
82
|
+
function extend(target) {
|
|
83
|
+
for (var i = 1; i < arguments.length; i++) {
|
|
84
|
+
var source = arguments[i];
|
|
85
|
+
if (!source) continue;
|
|
86
|
+
for (var key in source) {
|
|
87
|
+
if (Object.prototype.hasOwnProperty.call(source, key) && source[key] !== undefined) target[key] = source[key];
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return target;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function clamp(x, low, high) { return Math.min(high, Math.max(low, x)); }
|
|
94
|
+
|
|
95
|
+
// "90", "90px", "2rem" or "1.5em" to px; anything else is the fallback.
|
|
96
|
+
function toPixels(value, el, fallback) {
|
|
97
|
+
if (value === undefined || value === null || value === '') return fallback;
|
|
98
|
+
var text = String(value).trim();
|
|
99
|
+
var number = parseFloat(text);
|
|
100
|
+
if (isNaN(number)) return fallback;
|
|
101
|
+
if (text.slice(-3) === 'rem') return number * parseFloat(getComputedStyle(document.documentElement).fontSize);
|
|
102
|
+
if (text.slice(-2) === 'em') return number * parseFloat(getComputedStyle(el).fontSize);
|
|
103
|
+
return number;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function reducedMotion() {
|
|
107
|
+
return !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function Stops(scroller, options) {
|
|
111
|
+
this.scroller = scroller;
|
|
112
|
+
this.isWindow = scroller === window;
|
|
113
|
+
// Carries the custom properties and dispatches the events.
|
|
114
|
+
this.root = this.isWindow ? document.documentElement : scroller;
|
|
115
|
+
this.options = extend({}, defaults, options);
|
|
116
|
+
|
|
117
|
+
this.stops = [];
|
|
118
|
+
this.state = null;
|
|
119
|
+
this.last = this.top();
|
|
120
|
+
// Where the current gesture began: the position before its first event.
|
|
121
|
+
this.start = this.last;
|
|
122
|
+
this.direction = 0;
|
|
123
|
+
this.gliding = false;
|
|
124
|
+
this.paused = 0;
|
|
125
|
+
this.timer = null;
|
|
126
|
+
this.glideTimer = null;
|
|
127
|
+
this.frame = null;
|
|
128
|
+
this.focusedAt = -Infinity;
|
|
129
|
+
this.revealing = false;
|
|
130
|
+
|
|
131
|
+
this.onScroll = this.onScroll.bind(this);
|
|
132
|
+
this.onInterrupt = this.onInterrupt.bind(this);
|
|
133
|
+
this.onFocus = this.onFocus.bind(this);
|
|
134
|
+
this.onResize = this.onResize.bind(this);
|
|
135
|
+
this.settle = this.settle.bind(this);
|
|
136
|
+
this.tick = this.tick.bind(this);
|
|
137
|
+
|
|
138
|
+
// focusin bubbles, so the scroller hears its own fields; the window
|
|
139
|
+
// does not, so the page's are heard on the document.
|
|
140
|
+
this.focusTarget = this.isWindow ? document : this.scroller;
|
|
141
|
+
|
|
142
|
+
this.scroller.addEventListener('scroll', this.onScroll, { passive: true });
|
|
143
|
+
for (var i = 0; i < INTERRUPT.length; i++) {
|
|
144
|
+
this.scroller.addEventListener(INTERRUPT[i], this.onInterrupt, { passive: true });
|
|
145
|
+
}
|
|
146
|
+
this.focusTarget.addEventListener('focusin', this.onFocus, true);
|
|
147
|
+
window.addEventListener('resize', this.onResize);
|
|
148
|
+
|
|
149
|
+
this.refresh();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
Stops.prototype.detach = function () {
|
|
153
|
+
this.scroller.removeEventListener('scroll', this.onScroll);
|
|
154
|
+
for (var i = 0; i < INTERRUPT.length; i++) {
|
|
155
|
+
this.scroller.removeEventListener(INTERRUPT[i], this.onInterrupt);
|
|
156
|
+
}
|
|
157
|
+
this.focusTarget.removeEventListener('focusin', this.onFocus, true);
|
|
158
|
+
window.removeEventListener('resize', this.onResize);
|
|
159
|
+
clearTimeout(this.timer);
|
|
160
|
+
clearTimeout(this.glideTimer);
|
|
161
|
+
if (this.frame !== null) cancelAnimationFrame(this.frame);
|
|
162
|
+
|
|
163
|
+
var style = this.root.style;
|
|
164
|
+
style.removeProperty('--sticky-stop');
|
|
165
|
+
style.removeProperty('--sticky-progress');
|
|
166
|
+
style.removeProperty('--sticky-progress-held');
|
|
167
|
+
|
|
168
|
+
var at = instances.indexOf(this);
|
|
169
|
+
if (at !== -1) instances.splice(at, 1);
|
|
170
|
+
return this;
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
/** Hold the stops off, e.g. while a script scrolls the page itself. Nests. */
|
|
174
|
+
Stops.prototype.pause = function () { this.paused++; return this; };
|
|
175
|
+
Stops.prototype.resume = function () { this.paused = Math.max(0, this.paused - 1); return this; };
|
|
176
|
+
|
|
177
|
+
Stops.prototype.top = function () {
|
|
178
|
+
return this.isWindow ? (window.scrollY || window.pageYOffset || 0) : this.scroller.scrollTop;
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
/** The scroller's scroll-padding-top: where a stop should rest below its edge. */
|
|
182
|
+
Stops.prototype.padding = function () {
|
|
183
|
+
var value = parseFloat(getComputedStyle(this.root).scrollPaddingTop);
|
|
184
|
+
return isNaN(value) ? 0 : value;
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
/** A stop's scroll position: where it is on screen now, not its layout box. */
|
|
188
|
+
Stops.prototype.position = function (el) {
|
|
189
|
+
var origin = this.isWindow ? 0 : this.scroller.getBoundingClientRect().top;
|
|
190
|
+
return el.getBoundingClientRect().top - origin + this.top() - this.padding();
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
Stops.prototype.measure = function () {
|
|
194
|
+
var options = this.options;
|
|
195
|
+
var elements = this.root.querySelectorAll(options.selector);
|
|
196
|
+
var stops = [];
|
|
197
|
+
var i;
|
|
198
|
+
|
|
199
|
+
for (i = 0; i < elements.length; i++) {
|
|
200
|
+
var el = elements[i];
|
|
201
|
+
if (!el.getClientRects().length) continue; // display: none
|
|
202
|
+
stops.push({
|
|
203
|
+
element: el,
|
|
204
|
+
top: Math.round(this.position(el)),
|
|
205
|
+
bandUp: Math.max(0, toPixels(el.dataset.stickyBandUp, el, options.bandUp)),
|
|
206
|
+
bandDown: Math.max(0, toPixels(el.dataset.stickyBandDown, el, options.bandDown))
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
stops.sort(function (a, b) { return a.top - b.top; });
|
|
210
|
+
|
|
211
|
+
// Two stops at the same place are one stop.
|
|
212
|
+
var unique = [];
|
|
213
|
+
for (i = 0; i < stops.length; i++) {
|
|
214
|
+
if (unique.length && stops[i].top - unique[unique.length - 1].top <= options.tolerance) continue;
|
|
215
|
+
unique.push(stops[i]);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// A band reaches at most halfway to the neighbouring stop, so both
|
|
219
|
+
// stay reachable however the bands are set.
|
|
220
|
+
for (i = 0; i < unique.length; i++) {
|
|
221
|
+
if (i > 0) unique[i].bandUp = Math.min(unique[i].bandUp, (unique[i].top - unique[i - 1].top) / 2);
|
|
222
|
+
if (i < unique.length - 1) unique[i].bandDown = Math.min(unique[i].bandDown, (unique[i + 1].top - unique[i].top) / 2);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
this.stops = unique;
|
|
226
|
+
return unique;
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
Stops.prototype.glide = function (stop) {
|
|
230
|
+
var behavior = this.options.smooth && !reducedMotion() ? 'smooth' : 'auto';
|
|
231
|
+
|
|
232
|
+
this.gliding = true;
|
|
233
|
+
// A stop the scroller cannot reach (past its end) fires no scroll
|
|
234
|
+
// event: do not stay "gliding" waiting for one.
|
|
235
|
+
clearTimeout(this.glideTimer);
|
|
236
|
+
this.glideTimer = setTimeout(this.onInterrupt, 2 * this.options.settle);
|
|
237
|
+
|
|
238
|
+
this.root.dispatchEvent(new CustomEvent('sticky:stop', {
|
|
239
|
+
bubbles: true,
|
|
240
|
+
detail: { scroller: this.scroller, stop: stop.element, top: stop.top }
|
|
241
|
+
}));
|
|
242
|
+
this.scroller.scrollTo({ top: stop.top, behavior: behavior });
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
Stops.prototype.settle = function () {
|
|
246
|
+
this.timer = null;
|
|
247
|
+
|
|
248
|
+
// A glide of ours has just arrived (or been interrupted): let go.
|
|
249
|
+
if (this.gliding) {
|
|
250
|
+
this.gliding = false;
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
// The browser bringing a focused field into view: it rests where the
|
|
254
|
+
// field is, wherever that falls between the stops.
|
|
255
|
+
if (this.revealing) {
|
|
256
|
+
this.revealing = false;
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
if (this.paused || api.paused) return;
|
|
260
|
+
|
|
261
|
+
var stops = this.measure();
|
|
262
|
+
if (!stops.length) return;
|
|
263
|
+
|
|
264
|
+
var tolerance = this.options.tolerance;
|
|
265
|
+
var y = this.top();
|
|
266
|
+
var direction = this.direction;
|
|
267
|
+
var from = this.start;
|
|
268
|
+
var i;
|
|
269
|
+
|
|
270
|
+
// At a stop already: nothing to finish.
|
|
271
|
+
for (i = 0; i < stops.length; i++) {
|
|
272
|
+
if (Math.abs(y - stops[i].top) <= tolerance) return;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
var first = stops[0];
|
|
276
|
+
var last = stops[stops.length - 1];
|
|
277
|
+
|
|
278
|
+
// Before the first stop: inside its band above it, heading down - or
|
|
279
|
+
// an overshoot from below - goes to the stop. Otherwise free.
|
|
280
|
+
if (y < first.top) {
|
|
281
|
+
var overshotUp = from > first.top + tolerance;
|
|
282
|
+
if (y > first.top - first.bandUp && (direction > 0 || overshotUp)) this.glide(first);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Past the last stop: inside its band below it, heading up - or an
|
|
287
|
+
// overshoot from above - goes back to the stop. Otherwise free.
|
|
288
|
+
if (y > last.top) {
|
|
289
|
+
var overshotDown = from < last.top - tolerance;
|
|
290
|
+
if (y < last.top + last.bandDown && (direction < 0 || overshotDown)) this.glide(last);
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Between two stops: on to the one the reader was heading for, unless
|
|
295
|
+
// the scroll barely left the other, in which case back to it.
|
|
296
|
+
for (i = 0; i < stops.length - 1; i++) {
|
|
297
|
+
var upper = stops[i];
|
|
298
|
+
var lower = stops[i + 1];
|
|
299
|
+
if (y <= upper.top || y >= lower.top) continue;
|
|
300
|
+
|
|
301
|
+
if (direction > 0) this.glide(y < upper.top + upper.bandDown ? upper : lower);
|
|
302
|
+
else if (direction < 0) this.glide(y > lower.top - lower.bandUp ? lower : upper);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
Stops.prototype.onScroll = function () {
|
|
308
|
+
var y = this.top();
|
|
309
|
+
var step = y > this.last ? 1 : (y < this.last ? -1 : 0);
|
|
310
|
+
|
|
311
|
+
clearTimeout(this.glideTimer);
|
|
312
|
+
|
|
313
|
+
// The first event after a rest starts a new gesture.
|
|
314
|
+
if (this.timer === null && !this.gliding) {
|
|
315
|
+
this.start = this.last;
|
|
316
|
+
this.revealing = now() - this.focusedAt < REVEAL;
|
|
317
|
+
}
|
|
318
|
+
this.last = y;
|
|
319
|
+
|
|
320
|
+
// Which way the READER is going, not our own glide.
|
|
321
|
+
if (!this.gliding && step) this.direction = step;
|
|
322
|
+
|
|
323
|
+
clearTimeout(this.timer);
|
|
324
|
+
this.timer = setTimeout(this.settle, this.options.settle);
|
|
325
|
+
|
|
326
|
+
if (this.options.progress) this.queue();
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
Stops.prototype.onInterrupt = function () {
|
|
330
|
+
this.gliding = false;
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
Stops.prototype.onFocus = function () {
|
|
334
|
+
this.focusedAt = now();
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
Stops.prototype.onResize = function () {
|
|
338
|
+
this.refresh();
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
/** Re-measure the stops and report the progress. For content that moved. */
|
|
342
|
+
Stops.prototype.refresh = function () {
|
|
343
|
+
this.measure();
|
|
344
|
+
if (this.options.progress) this.progress();
|
|
345
|
+
return this;
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
Stops.prototype.queue = function () {
|
|
349
|
+
if (this.frame !== null) return;
|
|
350
|
+
this.frame = requestAnimationFrame(this.tick);
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
Stops.prototype.tick = function () {
|
|
354
|
+
this.frame = null;
|
|
355
|
+
this.progress();
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Where the scroll is between its two nearest stops: {index, from, to,
|
|
360
|
+
* top, progress, held}, also written to the custom properties and sent as
|
|
361
|
+
* sticky:progress when it changed. null with fewer than two stops.
|
|
362
|
+
*/
|
|
363
|
+
Stops.prototype.progress = function () {
|
|
364
|
+
var stops = this.measure();
|
|
365
|
+
var style = this.root.style;
|
|
366
|
+
|
|
367
|
+
if (stops.length < 2) {
|
|
368
|
+
if (this.state) {
|
|
369
|
+
style.removeProperty('--sticky-stop');
|
|
370
|
+
style.removeProperty('--sticky-progress');
|
|
371
|
+
style.removeProperty('--sticky-progress-held');
|
|
372
|
+
}
|
|
373
|
+
this.state = null;
|
|
374
|
+
return null;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
var y = this.top();
|
|
378
|
+
var i = 0;
|
|
379
|
+
while (i < stops.length - 2 && y >= stops[i + 1].top) i++;
|
|
380
|
+
|
|
381
|
+
var upper = stops[i];
|
|
382
|
+
var lower = stops[i + 1];
|
|
383
|
+
var progress = clamp((y - upper.top) / (lower.top - upper.top), 0, 1);
|
|
384
|
+
|
|
385
|
+
// Held across the bands: still until the scroll has cleared them.
|
|
386
|
+
var a = upper.top + upper.bandDown;
|
|
387
|
+
var b = lower.top - lower.bandUp;
|
|
388
|
+
var held = b > a ? clamp((y - a) / (b - a), 0, 1) : (y < b ? 0 : 1);
|
|
389
|
+
|
|
390
|
+
var previous = this.state;
|
|
391
|
+
var state = {
|
|
392
|
+
scroller: this.scroller,
|
|
393
|
+
index: i,
|
|
394
|
+
from: upper.element,
|
|
395
|
+
to: lower.element,
|
|
396
|
+
top: y,
|
|
397
|
+
progress: progress,
|
|
398
|
+
held: held
|
|
399
|
+
};
|
|
400
|
+
this.state = state;
|
|
401
|
+
|
|
402
|
+
if (previous && previous.index === i && previous.progress === progress && previous.held === held) return state;
|
|
403
|
+
|
|
404
|
+
style.setProperty('--sticky-stop', String(i));
|
|
405
|
+
style.setProperty('--sticky-progress', progress.toFixed(4));
|
|
406
|
+
style.setProperty('--sticky-progress-held', held.toFixed(4));
|
|
407
|
+
this.root.dispatchEvent(new CustomEvent('sticky:progress', { bubbles: true, detail: state }));
|
|
408
|
+
|
|
409
|
+
return state;
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
var instances = [];
|
|
413
|
+
|
|
414
|
+
function normalize(scroller) {
|
|
415
|
+
if (!scroller || scroller === document || scroller === document.documentElement || scroller === document.body) return window;
|
|
416
|
+
return scroller;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
var api = {
|
|
420
|
+
defaults: defaults,
|
|
421
|
+
Stops: Stops,
|
|
422
|
+
paused: 0,
|
|
423
|
+
|
|
424
|
+
/** The stops of a scroller (the window by default); made if needed. */
|
|
425
|
+
attach: function (scroller, options) {
|
|
426
|
+
scroller = normalize(scroller);
|
|
427
|
+
var found = api.get(scroller);
|
|
428
|
+
if (found) {
|
|
429
|
+
if (options) extend(found.options, options);
|
|
430
|
+
return found.refresh();
|
|
431
|
+
}
|
|
432
|
+
var made = new Stops(scroller, options);
|
|
433
|
+
instances.push(made);
|
|
434
|
+
return made;
|
|
435
|
+
},
|
|
436
|
+
|
|
437
|
+
get: function (scroller) {
|
|
438
|
+
scroller = normalize(scroller);
|
|
439
|
+
for (var i = 0; i < instances.length; i++) {
|
|
440
|
+
if (instances[i].scroller === scroller) return instances[i];
|
|
441
|
+
}
|
|
442
|
+
return null;
|
|
443
|
+
},
|
|
444
|
+
|
|
445
|
+
/** Hold every scroller's stops off, e.g. while a script scrolls the page. Nests. */
|
|
446
|
+
pause: function () { api.paused++; return api; },
|
|
447
|
+
resume: function () { api.paused = Math.max(0, api.paused - 1); return api; },
|
|
448
|
+
|
|
449
|
+
refresh: function () {
|
|
450
|
+
for (var i = 0; i < instances.length; i++) instances[i].refresh();
|
|
451
|
+
return api;
|
|
452
|
+
}
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
// The page's own stops, without being asked - and again on load, for a
|
|
456
|
+
// page swapped in place that re-dispatches it.
|
|
457
|
+
function auto() {
|
|
458
|
+
if (document.querySelector(defaults.selector)) api.attach(window);
|
|
459
|
+
}
|
|
460
|
+
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', auto);
|
|
461
|
+
else auto();
|
|
462
|
+
window.addEventListener('load', auto);
|
|
463
|
+
|
|
464
|
+
return api;
|
|
465
|
+
});
|