stimeo-ui 0.11.0 → 0.12.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +70 -0
- data/dist/cable/index.js +23 -2
- data/dist/controllers/count_up_controller.js +74 -35
- data/dist/controllers/intersection_controller.js +55 -8
- data/dist/controllers/lazy_frame_controller.js +81 -19
- data/dist/controllers/pointer_drag_controller.js +4 -0
- data/dist/controllers/reading_progress_controller.js +177 -6
- data/dist/controllers/scrollspy_controller.js +13 -2
- data/dist/controllers/smart_sticky_header_controller.js +52 -10
- data/dist/controllers/sortable_controller.js +241 -67
- data/dist/controllers/sticky_observer_controller.js +13 -2
- data/dist/index.js +486 -145
- data/lib/stimeo/ui/version.rb +1 -1
- metadata +2 -2
|
@@ -1,12 +1,158 @@
|
|
|
1
1
|
import { Controller } from '@hotwired/stimulus';
|
|
2
2
|
|
|
3
|
+
// src/controllers/reading_progress_controller.ts
|
|
4
|
+
|
|
5
|
+
// src/utils/before_cache_reset.ts
|
|
6
|
+
var BeforeCacheReset = class _BeforeCacheReset {
|
|
7
|
+
/** Every subscribed instance, iterated by the one shared document listener. */
|
|
8
|
+
static #subscribers = /* @__PURE__ */ new Set();
|
|
9
|
+
/** The shared listener; installed while at least one instance is subscribed. */
|
|
10
|
+
static #onBeforeCache = () => {
|
|
11
|
+
for (const subscriber of _BeforeCacheReset.#subscribers) subscriber.#rewind();
|
|
12
|
+
};
|
|
13
|
+
#rewind;
|
|
14
|
+
/** @param rewind - the pass that returns this controller's state to its initial form. */
|
|
15
|
+
constructor(rewind) {
|
|
16
|
+
this.#rewind = rewind;
|
|
17
|
+
}
|
|
18
|
+
/** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */
|
|
19
|
+
activate() {
|
|
20
|
+
const first = _BeforeCacheReset.#subscribers.size === 0;
|
|
21
|
+
_BeforeCacheReset.#subscribers.add(this);
|
|
22
|
+
if (first) {
|
|
23
|
+
document.addEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */
|
|
27
|
+
deactivate() {
|
|
28
|
+
_BeforeCacheReset.#subscribers.delete(this);
|
|
29
|
+
if (_BeforeCacheReset.#subscribers.size > 0) return;
|
|
30
|
+
document.removeEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// src/utils/layout_observer.ts
|
|
35
|
+
var LayoutObserver = class {
|
|
36
|
+
#callback;
|
|
37
|
+
#resizeObserverFactory;
|
|
38
|
+
#resizeObserver = null;
|
|
39
|
+
#observingViewport = false;
|
|
40
|
+
/** Stable bound handler so add/removeEventListener target the same reference. */
|
|
41
|
+
#handleViewportResize = () => {
|
|
42
|
+
this.#callback();
|
|
43
|
+
};
|
|
44
|
+
constructor(callback, options = {}) {
|
|
45
|
+
this.#callback = callback;
|
|
46
|
+
this.#resizeObserverFactory = options.resizeObserverFactory ?? (typeof ResizeObserver === "undefined" ? null : (cb) => new ResizeObserver(cb));
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Starts observing an element's size. Repeated calls observe additional
|
|
50
|
+
* elements through the same shared observer. No-ops when no
|
|
51
|
+
* `ResizeObserver` implementation is available.
|
|
52
|
+
*/
|
|
53
|
+
observe(element) {
|
|
54
|
+
if (!this.#resizeObserverFactory) return;
|
|
55
|
+
if (!this.#resizeObserver) {
|
|
56
|
+
this.#resizeObserver = this.#resizeObserverFactory(() => {
|
|
57
|
+
this.#callback();
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
this.#resizeObserver.observe(element);
|
|
61
|
+
}
|
|
62
|
+
/** Stops observing a single element while leaving any others in place. */
|
|
63
|
+
unobserve(element) {
|
|
64
|
+
this.#resizeObserver?.unobserve(element);
|
|
65
|
+
}
|
|
66
|
+
/** Starts observing viewport resizes. Idempotent: the listener is added once. */
|
|
67
|
+
observeViewport() {
|
|
68
|
+
if (this.#observingViewport) return;
|
|
69
|
+
this.#observingViewport = true;
|
|
70
|
+
window.addEventListener("resize", this.#handleViewportResize);
|
|
71
|
+
}
|
|
72
|
+
/** Stops observing viewport resizes without affecting element observation. */
|
|
73
|
+
unobserveViewport() {
|
|
74
|
+
if (!this.#observingViewport) return;
|
|
75
|
+
this.#observingViewport = false;
|
|
76
|
+
window.removeEventListener("resize", this.#handleViewportResize);
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Releases every observation: disconnects the {@link ResizeObserver} and
|
|
80
|
+
* removes the viewport listener. Safe to call multiple times. Call this from a
|
|
81
|
+
* controller's `disconnect()`.
|
|
82
|
+
*/
|
|
83
|
+
disconnect() {
|
|
84
|
+
this.#resizeObserver?.disconnect();
|
|
85
|
+
this.#resizeObserver = null;
|
|
86
|
+
this.unobserveViewport();
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// src/utils/style_property_lease.ts
|
|
91
|
+
var StylePropertyLease = class {
|
|
92
|
+
#property;
|
|
93
|
+
#records = /* @__PURE__ */ new Map();
|
|
94
|
+
/** @param property - The CSS property whose temporary values this lease owns. */
|
|
95
|
+
constructor(property) {
|
|
96
|
+
this.#property = property;
|
|
97
|
+
}
|
|
98
|
+
/** Writes or removes the leased declaration while preserving its authored value. */
|
|
99
|
+
write(element, value, priority = "") {
|
|
100
|
+
const existing = this.#records.get(element);
|
|
101
|
+
if (existing) {
|
|
102
|
+
existing.writtenValue = value;
|
|
103
|
+
existing.writtenPriority = value === null ? "" : priority;
|
|
104
|
+
} else {
|
|
105
|
+
this.#records.set(element, {
|
|
106
|
+
originalValue: element.style.getPropertyValue(this.#property),
|
|
107
|
+
originalPriority: element.style.getPropertyPriority(this.#property),
|
|
108
|
+
writtenValue: value,
|
|
109
|
+
writtenPriority: value === null ? "" : priority
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
this.#reflect(element, value, priority);
|
|
113
|
+
}
|
|
114
|
+
/** Returns one lease without overwriting a later consumer declaration. */
|
|
115
|
+
return(element) {
|
|
116
|
+
const record = this.#records.get(element);
|
|
117
|
+
if (!record) return;
|
|
118
|
+
this.#records.delete(element);
|
|
119
|
+
const style = element.style;
|
|
120
|
+
const stillOwned = style.getPropertyValue(this.#property) === (record.writtenValue ?? "") && style.getPropertyPriority(this.#property) === record.writtenPriority;
|
|
121
|
+
if (stillOwned) {
|
|
122
|
+
this.#reflect(element, record.originalValue, record.originalPriority);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/** Returns every outstanding declaration lease. */
|
|
126
|
+
returnAll() {
|
|
127
|
+
for (const element of Array.from(this.#records.keys())) this.return(element);
|
|
128
|
+
}
|
|
129
|
+
/** Reflects only a real declaration transition. */
|
|
130
|
+
#reflect(element, value, priority) {
|
|
131
|
+
const style = element.style;
|
|
132
|
+
const nextValue = value ?? "";
|
|
133
|
+
const nextPriority = value === null ? "" : priority;
|
|
134
|
+
if (style.getPropertyValue(this.#property) === nextValue && style.getPropertyPriority(this.#property) === nextPriority) {
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if (value === null) style.removeProperty(this.#property);
|
|
138
|
+
else style.setProperty(this.#property, value, priority);
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
|
|
3
142
|
// src/controllers/reading_progress_controller.ts
|
|
4
143
|
var PROGRESS_PROPERTY = "--stimeo--reading-progress";
|
|
5
144
|
var ReadingProgressController = class extends Controller {
|
|
6
145
|
static events = ["change", "complete"];
|
|
146
|
+
/** Owns both faces of the published property so teardown can hand them back. */
|
|
147
|
+
#lease = new StylePropertyLease(PROGRESS_PROPERTY);
|
|
148
|
+
/** The article's own box and the viewport: either changes the span. */
|
|
149
|
+
#layout = new LayoutObserver(() => this.#onScroll());
|
|
150
|
+
#beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
|
|
7
151
|
#frame = null;
|
|
8
152
|
/** Last published progress, so `change`/`complete` fire only on movement. */
|
|
9
153
|
#progress = -1;
|
|
154
|
+
/** False until the connect frame has run: `complete` needs real reading. */
|
|
155
|
+
#baselined = false;
|
|
10
156
|
#onScroll = () => {
|
|
11
157
|
if (this.#frame !== null) return;
|
|
12
158
|
this.#frame = requestAnimationFrame(() => {
|
|
@@ -16,20 +162,45 @@ var ReadingProgressController = class extends Controller {
|
|
|
16
162
|
};
|
|
17
163
|
connect() {
|
|
18
164
|
this.#progress = -1;
|
|
165
|
+
this.#baselined = false;
|
|
19
166
|
window.addEventListener("scroll", this.#onScroll, { passive: true, capture: true });
|
|
20
|
-
|
|
167
|
+
this.#layout.observe(this.element);
|
|
168
|
+
this.#layout.observeViewport();
|
|
169
|
+
this.#beforeCache.activate();
|
|
21
170
|
this.#measure();
|
|
171
|
+
this.#frame = requestAnimationFrame(() => {
|
|
172
|
+
this.#frame = null;
|
|
173
|
+
this.#measure();
|
|
174
|
+
this.#baselined = true;
|
|
175
|
+
});
|
|
22
176
|
}
|
|
23
177
|
disconnect() {
|
|
24
178
|
window.removeEventListener("scroll", this.#onScroll, { capture: true });
|
|
25
|
-
|
|
179
|
+
this.#layout.disconnect();
|
|
180
|
+
this.#beforeCache.deactivate();
|
|
181
|
+
this.#cancelFrame();
|
|
182
|
+
this.#lease.returnAll();
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Hands both declarations back before the page is snapshotted, so a restored
|
|
186
|
+
* page starts from the authored DOM rather than from someone else's progress.
|
|
187
|
+
* The baseline goes back with them: a cancelled visit leaves this page on
|
|
188
|
+
* screen, and the next measurement has to publish afresh rather than match a
|
|
189
|
+
* value that has already been handed back.
|
|
190
|
+
*/
|
|
191
|
+
#rewindForCache() {
|
|
192
|
+
this.#cancelFrame();
|
|
193
|
+
this.#lease.returnAll();
|
|
194
|
+
this.#progress = -1;
|
|
195
|
+
}
|
|
196
|
+
#cancelFrame() {
|
|
26
197
|
if (this.#frame !== null) cancelAnimationFrame(this.#frame);
|
|
27
198
|
this.#frame = null;
|
|
28
|
-
document.documentElement.style.removeProperty(PROGRESS_PROPERTY);
|
|
29
199
|
}
|
|
30
200
|
/** Computes and publishes the progress; emits on movement only. */
|
|
31
201
|
#measure() {
|
|
32
202
|
const rect = this.element.getBoundingClientRect();
|
|
203
|
+
if (rect.width === 0 && rect.height === 0) return;
|
|
33
204
|
const span = rect.height - window.innerHeight;
|
|
34
205
|
const raw = span > 0 ? -rect.top / span : rect.top <= 0 ? 1 : 0;
|
|
35
206
|
const progress = Math.min(1, Math.max(0, raw));
|
|
@@ -37,10 +208,10 @@ var ReadingProgressController = class extends Controller {
|
|
|
37
208
|
const previous = this.#progress;
|
|
38
209
|
this.#progress = progress;
|
|
39
210
|
const value = String(progress);
|
|
40
|
-
this.element
|
|
41
|
-
document.documentElement
|
|
211
|
+
this.#lease.write(this.element, value);
|
|
212
|
+
this.#lease.write(document.documentElement, value);
|
|
42
213
|
this.dispatch("change", { detail: { progress } });
|
|
43
|
-
if (progress === 1 && previous !== -1) this.dispatch("complete");
|
|
214
|
+
if (progress === 1 && previous !== -1 && this.#baselined) this.dispatch("complete");
|
|
44
215
|
}
|
|
45
216
|
};
|
|
46
217
|
|
|
@@ -3,6 +3,14 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
3
3
|
// src/controllers/scrollspy_controller.ts
|
|
4
4
|
|
|
5
5
|
// src/utils/intersection_watcher.ts
|
|
6
|
+
function queryRoot(selector) {
|
|
7
|
+
if (!selector) return null;
|
|
8
|
+
try {
|
|
9
|
+
return document.querySelector(selector);
|
|
10
|
+
} catch {
|
|
11
|
+
}
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
6
14
|
var IntersectionWatcher = class {
|
|
7
15
|
#onEntries;
|
|
8
16
|
#observer = null;
|
|
@@ -24,7 +32,10 @@ var IntersectionWatcher = class {
|
|
|
24
32
|
* the watcher inert — without `IntersectionObserver` support (very old
|
|
25
33
|
* browsers; the caller's no-JS fallback stays in charge) or with no targets.
|
|
26
34
|
* If initial construction with the configured options fails, the watcher
|
|
27
|
-
* warns and retries once with the same root and platform defaults.
|
|
35
|
+
* warns and retries once with the same root and platform defaults. A
|
|
36
|
+
* `rootSelector` that does not parse resolves to the viewport (see
|
|
37
|
+
* {@link IntersectionWatchOptions.rootSelector}), so a typo never fails the
|
|
38
|
+
* call.
|
|
28
39
|
*
|
|
29
40
|
* @throws The fallback constructor error if both construction attempts fail,
|
|
30
41
|
* or whatever the platform throws from `observe()`. The exception is passed
|
|
@@ -37,7 +48,7 @@ var IntersectionWatcher = class {
|
|
|
37
48
|
if (typeof IntersectionObserver === "undefined") return false;
|
|
38
49
|
const list = Array.isArray(targets) ? targets : [targets];
|
|
39
50
|
if (list.length === 0) return false;
|
|
40
|
-
const root = "root" in options ? options.root ?? null :
|
|
51
|
+
const root = "root" in options ? options.root ?? null : queryRoot(options.rootSelector);
|
|
41
52
|
let observer = null;
|
|
42
53
|
try {
|
|
43
54
|
const onEntries = (entries) => {
|
|
@@ -1,19 +1,32 @@
|
|
|
1
1
|
import { Controller } from '@hotwired/stimulus';
|
|
2
2
|
|
|
3
3
|
// src/controllers/smart_sticky_header_controller.ts
|
|
4
|
+
var DEFAULT_OFFSET = 80;
|
|
4
5
|
var SmartStickyHeaderController = class extends Controller {
|
|
5
6
|
static values = {
|
|
6
7
|
containerSelector: { type: String, default: "" },
|
|
7
|
-
offset: { type: Number, default:
|
|
8
|
+
offset: { type: Number, default: DEFAULT_OFFSET },
|
|
8
9
|
tolerance: { type: Number, default: 4 }
|
|
9
10
|
};
|
|
10
11
|
static events = ["change"];
|
|
12
|
+
#connected = false;
|
|
11
13
|
#frame = null;
|
|
12
14
|
/** The scroll source resolved at connect — disconnect must unbind the SAME node. */
|
|
13
15
|
#scrollerEl = window;
|
|
16
|
+
/** The validated `containerSelector`, or `""` when the declaration cannot be parsed. */
|
|
17
|
+
#containerSelector = "";
|
|
14
18
|
#lastY = 0;
|
|
15
19
|
/** Last published state, so `change` fires only on transitions. */
|
|
16
20
|
#hidden = null;
|
|
21
|
+
/**
|
|
22
|
+
* The depth that never hides. A declaration that is not a finite number reads
|
|
23
|
+
* as the default, so the comparison path never sees `NaN` — which would
|
|
24
|
+
* answer `false` to every comparison and hide the header inside the very
|
|
25
|
+
* zone the value exists to protect.
|
|
26
|
+
*/
|
|
27
|
+
get #offset() {
|
|
28
|
+
return Number.isFinite(this.offsetValue) ? this.offsetValue : DEFAULT_OFFSET;
|
|
29
|
+
}
|
|
17
30
|
#onScroll = () => {
|
|
18
31
|
if (this.#frame !== null) return;
|
|
19
32
|
this.#frame = requestAnimationFrame(() => {
|
|
@@ -26,15 +39,25 @@ var SmartStickyHeaderController = class extends Controller {
|
|
|
26
39
|
* hold while focus *stays* inside is the `#apply` hide invariant.
|
|
27
40
|
*/
|
|
28
41
|
#onFocusin = () => this.#apply(false);
|
|
42
|
+
/** Validates `containerSelector` once so connect never parses a selector that throws. */
|
|
43
|
+
containerSelectorValueChanged() {
|
|
44
|
+
this.#containerSelector = this.#validSelector(this.containerSelectorValue);
|
|
45
|
+
}
|
|
46
|
+
/** Re-decides when application code (or a Turbo morph) changes `offset` at runtime. */
|
|
47
|
+
offsetValueChanged() {
|
|
48
|
+
if (this.#connected) this.#measure();
|
|
49
|
+
}
|
|
29
50
|
connect() {
|
|
30
51
|
this.#hidden = null;
|
|
31
52
|
this.#scrollerEl = this.#resolveScroller();
|
|
32
53
|
this.#lastY = this.#scrollY;
|
|
33
54
|
this.#scrollerEl.addEventListener("scroll", this.#onScroll, { passive: true });
|
|
34
55
|
this.element.addEventListener("focusin", this.#onFocusin);
|
|
35
|
-
this.#apply(false);
|
|
56
|
+
this.#apply(false, false);
|
|
57
|
+
this.#connected = true;
|
|
36
58
|
}
|
|
37
59
|
disconnect() {
|
|
60
|
+
this.#connected = false;
|
|
38
61
|
this.#scrollerEl.removeEventListener("scroll", this.#onScroll);
|
|
39
62
|
this.element.removeEventListener("focusin", this.#onFocusin);
|
|
40
63
|
if (this.#frame !== null) cancelAnimationFrame(this.#frame);
|
|
@@ -42,32 +65,51 @@ var SmartStickyHeaderController = class extends Controller {
|
|
|
42
65
|
}
|
|
43
66
|
/** Resolves the scroll source: the `containerSelector` match, else the window. */
|
|
44
67
|
#resolveScroller() {
|
|
45
|
-
if (this
|
|
46
|
-
const container = document.querySelector(this
|
|
68
|
+
if (this.#containerSelector) {
|
|
69
|
+
const container = document.querySelector(this.#containerSelector);
|
|
47
70
|
if (container) return container;
|
|
48
71
|
}
|
|
49
72
|
return window;
|
|
50
73
|
}
|
|
74
|
+
/** Returns `declared` when it parses as a selector, and `""` when it does not. */
|
|
75
|
+
#validSelector(declared) {
|
|
76
|
+
if (declared.length > 0) {
|
|
77
|
+
try {
|
|
78
|
+
this.element.matches(declared);
|
|
79
|
+
return declared;
|
|
80
|
+
} catch {
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return "";
|
|
84
|
+
}
|
|
51
85
|
get #scrollY() {
|
|
52
86
|
const scroller = this.#scrollerEl;
|
|
53
87
|
return scroller === window ? window.scrollY : scroller.scrollTop;
|
|
54
88
|
}
|
|
55
89
|
#measure() {
|
|
56
90
|
const y = this.#scrollY;
|
|
91
|
+
if (y <= this.#offset) {
|
|
92
|
+
this.#lastY = y;
|
|
93
|
+
this.#apply(false);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
57
96
|
const delta = y - this.#lastY;
|
|
58
97
|
if (Math.abs(delta) < this.toleranceValue) return;
|
|
59
98
|
this.#lastY = y;
|
|
60
|
-
|
|
61
|
-
else if (delta > 0) this.#apply(true);
|
|
62
|
-
else this.#apply(false);
|
|
99
|
+
this.#apply(delta > 0);
|
|
63
100
|
}
|
|
64
|
-
/**
|
|
65
|
-
|
|
101
|
+
/**
|
|
102
|
+
* Reflects the state onto the hook and emits `change` on transitions.
|
|
103
|
+
*
|
|
104
|
+
* @param notify - whether a transition announces itself. The reflection
|
|
105
|
+
* `connect()` performs is the current state, not a change.
|
|
106
|
+
*/
|
|
107
|
+
#apply(hidden, notify = true) {
|
|
66
108
|
if (hidden && this.element.contains(document.activeElement)) return;
|
|
67
109
|
if (hidden === this.#hidden) return;
|
|
68
110
|
this.#hidden = hidden;
|
|
69
111
|
this.element.setAttribute("data-header-hidden", hidden ? "true" : "false");
|
|
70
|
-
this.dispatch("change", { detail: { hidden } });
|
|
112
|
+
if (notify) this.dispatch("change", { detail: { hidden } });
|
|
71
113
|
}
|
|
72
114
|
};
|
|
73
115
|
|