stimeo-ui 0.11.0 → 0.13.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 +107 -0
- data/dist/cable/index.js +461 -154
- data/dist/controllers/calendar_controller.js +10 -2
- data/dist/controllers/carousel_controller.js +1 -1
- data/dist/controllers/command_palette_controller.js +2 -2
- data/dist/controllers/count_up_controller.js +74 -35
- data/dist/controllers/date_range_picker_controller.js +10 -10
- data/dist/controllers/drawer_controller.js +2 -2
- data/dist/controllers/intersection_controller.js +55 -8
- data/dist/controllers/lazy_frame_controller.js +81 -19
- data/dist/controllers/optimistic_controller.js +141 -20
- 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/controllers/toast_controller.js +1 -1
- data/dist/index.js +587 -183
- data/lib/stimeo/ui/version.rb +1 -1
- metadata +2 -2
|
@@ -1,32 +1,115 @@
|
|
|
1
1
|
import { Controller } from '@hotwired/stimulus';
|
|
2
2
|
|
|
3
3
|
// src/controllers/optimistic_controller.ts
|
|
4
|
-
|
|
4
|
+
|
|
5
|
+
// src/utils/detach_gate.ts
|
|
6
|
+
var DetachGate = class _DetachGate {
|
|
7
|
+
/** Set while a probe is queued, waiting for a reconnect to cancel it. */
|
|
8
|
+
#pending = false;
|
|
9
|
+
/**
|
|
10
|
+
* True while a probe is queued — the last disconnect was ambiguous and no
|
|
11
|
+
* reconnect has cancelled it yet. Read it from `connect()` to tell the
|
|
12
|
+
* reconnect half of an in-page move from a first connect: a controller whose
|
|
13
|
+
* initialisation restarts a measurement (a min-duration floor, an elapsed
|
|
14
|
+
* counter) must skip it for the move, where nothing actually restarted.
|
|
15
|
+
*/
|
|
16
|
+
get pending() {
|
|
17
|
+
return this.#pending;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* True when the disconnect is definitely a real detach — the element left
|
|
21
|
+
* the document, or `data-controller` no longer lists the identifier. False
|
|
22
|
+
* means ambiguous (in-page move or observed-root exit), NOT "alive".
|
|
23
|
+
*/
|
|
24
|
+
static isDetached(host) {
|
|
25
|
+
if (!host.element.isConnected) return true;
|
|
26
|
+
const tokens = (host.element.getAttribute("data-controller") ?? "").split(/\s+/);
|
|
27
|
+
return !tokens.includes(host.identifier);
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Call from `disconnect()`: runs `teardown` synchronously on a definite
|
|
31
|
+
* detach (fast path), otherwise defers it one microtask — a reconnect
|
|
32
|
+
* ({@link cancel} from `connect()`) keeps the state, no reconnect runs it.
|
|
33
|
+
* One microtask is the whole probe window: Stimulus reconnects a moved
|
|
34
|
+
* element within the same mutation batch, before the checkpoint drains.
|
|
35
|
+
*/
|
|
36
|
+
disconnected(host, teardown) {
|
|
37
|
+
if (_DetachGate.isDetached(host)) {
|
|
38
|
+
this.#pending = false;
|
|
39
|
+
teardown();
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
this.#pending = true;
|
|
43
|
+
queueMicrotask(() => {
|
|
44
|
+
if (!this.#pending) return;
|
|
45
|
+
this.#pending = false;
|
|
46
|
+
teardown();
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Disarms a pending probe. Call from `connect()` (the reconnect that proves
|
|
51
|
+
* an in-page move) and from the head of any teardown path not routed through
|
|
52
|
+
* {@link disconnected} (disabled-toggle, Escape), so an orphaned probe can
|
|
53
|
+
* never run the teardown a second time.
|
|
54
|
+
*/
|
|
55
|
+
cancel() {
|
|
56
|
+
this.#pending = false;
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// src/controllers/optimistic_controller.ts
|
|
61
|
+
var HIDDEN_MARKER = "data-optimistic-toggled";
|
|
62
|
+
var BUSY_MARKER = "data-optimistic-busy";
|
|
63
|
+
var AUTHORED_ABSENT = "absent";
|
|
64
|
+
var AUTHORED_VALUE = "value:";
|
|
65
|
+
var SYNTHETIC_SUBMISSION = {};
|
|
66
|
+
function submissionOf(event) {
|
|
67
|
+
const detail = event.detail;
|
|
68
|
+
return detail?.formSubmission ?? SYNTHETIC_SUBMISSION;
|
|
69
|
+
}
|
|
5
70
|
var OptimisticController = class extends Controller {
|
|
6
71
|
static targets = ["show", "hide"];
|
|
7
72
|
static events = ["commit", "rollback"];
|
|
73
|
+
/** The submission that owns the applied optimistic state, if any. */
|
|
74
|
+
#pending = null;
|
|
75
|
+
#gate = new DetachGate();
|
|
76
|
+
/** True between `connect()` and `disconnect()`, so a repeat connect rewinds nothing. */
|
|
77
|
+
#connected = false;
|
|
8
78
|
connect() {
|
|
9
|
-
|
|
79
|
+
const restored = !this.#connected && !this.#gate.pending;
|
|
80
|
+
this.#gate.cancel();
|
|
81
|
+
this.#connected = true;
|
|
82
|
+
if (restored && this.element.hasAttribute("data-optimistic")) this.#revert();
|
|
10
83
|
this.element.addEventListener("turbo:submit-start", this.#onSubmitStart);
|
|
11
84
|
this.element.addEventListener("turbo:submit-end", this.#onSubmitEnd);
|
|
12
85
|
}
|
|
13
86
|
disconnect() {
|
|
87
|
+
this.#connected = false;
|
|
14
88
|
this.element.removeEventListener("turbo:submit-start", this.#onSubmitStart);
|
|
15
89
|
this.element.removeEventListener("turbo:submit-end", this.#onSubmitEnd);
|
|
90
|
+
this.#gate.disconnected(this, () => {
|
|
91
|
+
this.#pending = null;
|
|
92
|
+
});
|
|
16
93
|
}
|
|
17
|
-
#onSubmitStart = () => {
|
|
94
|
+
#onSubmitStart = (event) => {
|
|
95
|
+
this.#pending = submissionOf(event);
|
|
96
|
+
if (this.element instanceof HTMLFormElement && !this.element.hasAttribute(BUSY_MARKER)) {
|
|
97
|
+
this.element.setAttribute(BUSY_MARKER, AUTHORED_ABSENT);
|
|
98
|
+
}
|
|
99
|
+
this.#write(this.element, "aria-busy", "true", BUSY_MARKER);
|
|
18
100
|
this.element.setAttribute("data-optimistic", "true");
|
|
19
|
-
this.
|
|
20
|
-
for (const target of this.
|
|
21
|
-
for (const target of this.hideTargets) this.#toggleHidden(target, true);
|
|
101
|
+
for (const target of this.showTargets) this.#setHidden(target, false);
|
|
102
|
+
for (const target of this.hideTargets) this.#setHidden(target, true);
|
|
22
103
|
};
|
|
23
104
|
#onSubmitEnd = (event) => {
|
|
105
|
+
if (submissionOf(event) !== this.#pending) return;
|
|
106
|
+
this.#pending = null;
|
|
24
107
|
const success = event.detail?.success === true;
|
|
25
|
-
this.element.removeAttribute("aria-busy");
|
|
26
108
|
if (success) {
|
|
27
109
|
this.element.removeAttribute("data-optimistic");
|
|
110
|
+
this.#restore(this.element, "aria-busy", BUSY_MARKER, "true");
|
|
28
111
|
for (const target of [...this.showTargets, ...this.hideTargets]) {
|
|
29
|
-
target.removeAttribute(
|
|
112
|
+
target.removeAttribute(HIDDEN_MARKER);
|
|
30
113
|
}
|
|
31
114
|
this.dispatch("commit");
|
|
32
115
|
} else {
|
|
@@ -34,21 +117,59 @@ var OptimisticController = class extends Controller {
|
|
|
34
117
|
this.dispatch("rollback");
|
|
35
118
|
}
|
|
36
119
|
};
|
|
37
|
-
/**
|
|
38
|
-
#
|
|
39
|
-
if (target.hidden === hidden) return;
|
|
40
|
-
target
|
|
41
|
-
|
|
120
|
+
/** Hides or reveals a target, recording the authored `hidden` on first write. */
|
|
121
|
+
#setHidden(target, hidden) {
|
|
122
|
+
if (target.hasAttribute("hidden") === hidden) return;
|
|
123
|
+
this.#write(target, "hidden", hidden ? "" : null, HIDDEN_MARKER);
|
|
124
|
+
}
|
|
125
|
+
/** Writes an attribute, recording what it displaced under `marker` once. */
|
|
126
|
+
#write(element, attribute, value, marker) {
|
|
127
|
+
if (!element.hasAttribute(marker)) {
|
|
128
|
+
const authored = element.getAttribute(attribute);
|
|
129
|
+
element.setAttribute(
|
|
130
|
+
marker,
|
|
131
|
+
authored === null ? AUTHORED_ABSENT : `${AUTHORED_VALUE}${authored}`
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
if (value === null) element.removeAttribute(attribute);
|
|
135
|
+
else element.setAttribute(attribute, value);
|
|
42
136
|
}
|
|
43
|
-
/**
|
|
137
|
+
/**
|
|
138
|
+
* Puts back the value `marker` recorded and drops the record.
|
|
139
|
+
*
|
|
140
|
+
* `written` is what this controller last put in the attribute. Anything else
|
|
141
|
+
* there now belongs to whoever wrote it — Turbo owns `aria-busy` on a form and
|
|
142
|
+
* clears it before the terminal arrives, and a consumer may have moved a face
|
|
143
|
+
* mid-flight — so the record is dropped without touching the attribute. A
|
|
144
|
+
* marker this build did not write names no authored value, so the attribute
|
|
145
|
+
* goes back to being absent.
|
|
146
|
+
*/
|
|
147
|
+
#restore(element, attribute, marker, written) {
|
|
148
|
+
const recorded = element.getAttribute(marker);
|
|
149
|
+
if (recorded === null) return;
|
|
150
|
+
element.removeAttribute(marker);
|
|
151
|
+
if (element.getAttribute(attribute) !== written) return;
|
|
152
|
+
if (recorded.startsWith(AUTHORED_VALUE)) {
|
|
153
|
+
element.setAttribute(attribute, recorded.slice(AUTHORED_VALUE.length));
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
element.removeAttribute(attribute);
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Puts back exactly the markup this controller displaced (record-owned only).
|
|
160
|
+
*
|
|
161
|
+
* A face can be registered on both sides, and the hide pass runs last, so what
|
|
162
|
+
* this controller wrote there is `hidden` — matching it against the reveal's
|
|
163
|
+
* absent value instead would read as someone else's write and leave the face
|
|
164
|
+
* hidden. Each target is therefore restored once, against the write it last
|
|
165
|
+
* received.
|
|
166
|
+
*/
|
|
44
167
|
#revert() {
|
|
45
168
|
this.element.removeAttribute("data-optimistic");
|
|
46
|
-
this.element
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
target.removeAttribute(TOGGLED_MARKER);
|
|
51
|
-
}
|
|
169
|
+
this.#restore(this.element, "aria-busy", BUSY_MARKER, "true");
|
|
170
|
+
const hidden = new Set(this.hideTargets);
|
|
171
|
+
for (const target of /* @__PURE__ */ new Set([...this.showTargets, ...this.hideTargets])) {
|
|
172
|
+
this.#restore(target, "hidden", HIDDEN_MARKER, hidden.has(target) ? "" : null);
|
|
52
173
|
}
|
|
53
174
|
}
|
|
54
175
|
};
|
|
@@ -255,6 +255,10 @@ var PointerDragController = class _PointerDragController extends Controller {
|
|
|
255
255
|
return;
|
|
256
256
|
}
|
|
257
257
|
if (!this.#keyboard) return;
|
|
258
|
+
if (event.key === "Home" || event.key === "End") {
|
|
259
|
+
event.preventDefault();
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
258
262
|
const step = this.#keyboardDelta(event.key);
|
|
259
263
|
if (!step) return;
|
|
260
264
|
event.preventDefault();
|
|
@@ -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
|
|