stimeo-ui 0.10.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 +145 -0
- data/dist/cable/index.js +249 -45
- 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 +68 -18
- data/dist/controllers/portal_controller.js +32 -11
- data/dist/controllers/preview_guard_controller.js +180 -20
- data/dist/controllers/reading_progress_controller.js +177 -6
- data/dist/controllers/roving_controller.js +127 -14
- 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/stick_to_bottom_controller.js +149 -28
- data/dist/controllers/sticky_observer_controller.js +13 -2
- data/dist/index.js +934 -285
- data/lib/stimeo/ui/version.rb +1 -1
- metadata +2 -2
|
@@ -2,29 +2,58 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/count_up_controller.ts
|
|
4
4
|
|
|
5
|
+
// src/utils/authored_integer.ts
|
|
6
|
+
var TOKEN_BODY = /\d[\d.,'’’ _]*\d|\d/;
|
|
7
|
+
var SIGN_OPENER = /[\s([]/;
|
|
8
|
+
function authoredInteger(text) {
|
|
9
|
+
const firstDigit = /\d/.exec(text);
|
|
10
|
+
if (firstDigit === null) return null;
|
|
11
|
+
const at = firstDigit.index;
|
|
12
|
+
const signed = at > 0 && text[at - 1] === "-" && (at === 1 || SIGN_OPENER.test(text[at - 2]));
|
|
13
|
+
const body = TOKEN_BODY.exec(text.slice(at));
|
|
14
|
+
const runs = body[0].split(/\D+/);
|
|
15
|
+
const separators = body[0].match(/\D+/g) ?? [];
|
|
16
|
+
const lead = runs[0];
|
|
17
|
+
const heads = lead.length <= 3 && !lead.startsWith("0");
|
|
18
|
+
let digits = lead;
|
|
19
|
+
for (let i = 1; heads && i < runs.length; i += 1) {
|
|
20
|
+
if (separators[i - 1]?.length !== 1 || runs[i]?.length !== 3) break;
|
|
21
|
+
digits += runs[i];
|
|
22
|
+
}
|
|
23
|
+
const magnitude = Number.parseInt(digits, 10);
|
|
24
|
+
return signed ? -magnitude || 0 : magnitude;
|
|
25
|
+
}
|
|
26
|
+
|
|
5
27
|
// src/utils/reduced_motion.ts
|
|
6
28
|
function prefersReducedMotion() {
|
|
7
29
|
return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
8
30
|
}
|
|
9
31
|
|
|
10
32
|
// src/controllers/count_up_controller.ts
|
|
33
|
+
var DEFAULT_DURATION = 1200;
|
|
11
34
|
var CountUpController = class extends Controller {
|
|
12
35
|
static values = {
|
|
13
|
-
duration: { type: Number, default:
|
|
36
|
+
duration: { type: Number, default: DEFAULT_DURATION },
|
|
14
37
|
from: { type: Number, default: 0 },
|
|
15
38
|
once: { type: Boolean, default: true }
|
|
16
39
|
};
|
|
17
40
|
static actions = ["start"];
|
|
18
41
|
static events = ["end"];
|
|
19
42
|
#frame = null;
|
|
20
|
-
/** The
|
|
21
|
-
#
|
|
43
|
+
/** The animation length, with a declaration outside its domain read as the default. */
|
|
44
|
+
get #duration() {
|
|
45
|
+
return Number.isFinite(this.durationValue) && this.durationValue > 0 ? this.durationValue : DEFAULT_DURATION;
|
|
46
|
+
}
|
|
47
|
+
/** The starting value, with a declaration that is not a finite number read as zero. */
|
|
48
|
+
get #from() {
|
|
49
|
+
return Number.isFinite(this.fromValue) ? this.fromValue : 0;
|
|
50
|
+
}
|
|
22
51
|
connect() {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
52
|
+
const ticker = this.#ownedTicker();
|
|
53
|
+
if (ticker === null) return;
|
|
54
|
+
const authored = ticker.getAttribute("aria-label");
|
|
55
|
+
this.#unwrap(ticker, authored);
|
|
56
|
+
if (authored !== null) this.element.setAttribute("data-count-up-done", "true");
|
|
28
57
|
}
|
|
29
58
|
disconnect() {
|
|
30
59
|
if (this.#frame !== null) this.#settle();
|
|
@@ -36,27 +65,22 @@ var CountUpController = class extends Controller {
|
|
|
36
65
|
start() {
|
|
37
66
|
if (this.#frame !== null) return;
|
|
38
67
|
if (this.onceValue && this.element.hasAttribute("data-count-up-done")) return;
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
68
|
+
const node = this.#numericNode();
|
|
69
|
+
if (node === null) return;
|
|
70
|
+
const authored = node.data;
|
|
71
|
+
const target = authoredInteger(authored);
|
|
42
72
|
if (prefersReducedMotion()) {
|
|
43
73
|
this.element.setAttribute("data-count-up-done", "true");
|
|
44
74
|
this.dispatch("end", { detail: { value: target } });
|
|
45
75
|
return;
|
|
46
76
|
}
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
this.element.setAttribute("data-count-up-original-label", authored);
|
|
50
|
-
}
|
|
51
|
-
this.element.setAttribute("data-count-up-label", "true");
|
|
52
|
-
this.element.setAttribute("aria-label", this.#finalText);
|
|
77
|
+
const ticker = this.#wrap(node, authored);
|
|
78
|
+
const from = this.#from;
|
|
53
79
|
const started = performance.now();
|
|
54
80
|
const step = (now) => {
|
|
55
|
-
const t = Math.min((now - started) / this
|
|
81
|
+
const t = Math.min((now - started) / this.#duration, 1);
|
|
56
82
|
const eased = 1 - (1 - t) ** 3;
|
|
57
|
-
|
|
58
|
-
Math.round(this.fromValue + (target - this.fromValue) * eased)
|
|
59
|
-
);
|
|
83
|
+
ticker.textContent = String(Math.round(from + (target - from) * eased));
|
|
60
84
|
if (t < 1) {
|
|
61
85
|
this.#frame = requestAnimationFrame(step);
|
|
62
86
|
} else {
|
|
@@ -66,26 +90,41 @@ var CountUpController = class extends Controller {
|
|
|
66
90
|
};
|
|
67
91
|
this.#frame = requestAnimationFrame(step);
|
|
68
92
|
}
|
|
93
|
+
/** The first text node that displays a number, or null when the host has none. */
|
|
94
|
+
#numericNode() {
|
|
95
|
+
const walker = document.createTreeWalker(this.element, NodeFilter.SHOW_TEXT);
|
|
96
|
+
let node = walker.nextNode();
|
|
97
|
+
while (node !== null && authoredInteger(node.data) === null) {
|
|
98
|
+
node = walker.nextNode();
|
|
99
|
+
}
|
|
100
|
+
return node;
|
|
101
|
+
}
|
|
102
|
+
/** The wrapper this controller published, if one outlived its run. */
|
|
103
|
+
#ownedTicker() {
|
|
104
|
+
return this.element.querySelector("[data-count-up-label]");
|
|
105
|
+
}
|
|
106
|
+
/** Publishes the ticking number inside a named wrapper, replacing `node`. */
|
|
107
|
+
#wrap(node, authored) {
|
|
108
|
+
const ticker = document.createElement("span");
|
|
109
|
+
ticker.setAttribute("data-count-up-label", "true");
|
|
110
|
+
ticker.setAttribute("role", "img");
|
|
111
|
+
ticker.setAttribute("aria-label", authored);
|
|
112
|
+
node.replaceWith(ticker);
|
|
113
|
+
ticker.append(node);
|
|
114
|
+
return ticker;
|
|
115
|
+
}
|
|
116
|
+
/** Puts `text` back where the wrapper stood, leaving the rest of the host alone. */
|
|
117
|
+
#unwrap(ticker, text) {
|
|
118
|
+
ticker.replaceWith(document.createTextNode(text ?? ticker.textContent ?? ""));
|
|
119
|
+
}
|
|
69
120
|
/** Ends the run: cancels the frame and restores the authored presentation. */
|
|
70
121
|
#settle() {
|
|
71
122
|
if (this.#frame !== null) cancelAnimationFrame(this.#frame);
|
|
72
123
|
this.#frame = null;
|
|
73
|
-
|
|
74
|
-
this.#
|
|
124
|
+
const ticker = this.#ownedTicker();
|
|
125
|
+
if (ticker !== null) this.#unwrap(ticker, ticker.getAttribute("aria-label"));
|
|
75
126
|
this.element.setAttribute("data-count-up-done", "true");
|
|
76
127
|
}
|
|
77
|
-
/** Releases the marker-owned aria-label, restoring any parked authored value. */
|
|
78
|
-
#restoreLabel() {
|
|
79
|
-
if (!this.element.hasAttribute("data-count-up-label")) return;
|
|
80
|
-
const original = this.element.getAttribute("data-count-up-original-label");
|
|
81
|
-
if (original !== null) {
|
|
82
|
-
this.element.setAttribute("aria-label", original);
|
|
83
|
-
this.element.removeAttribute("data-count-up-original-label");
|
|
84
|
-
} else {
|
|
85
|
-
this.element.removeAttribute("aria-label");
|
|
86
|
-
}
|
|
87
|
-
this.element.removeAttribute("data-count-up-label");
|
|
88
|
-
}
|
|
89
128
|
};
|
|
90
129
|
|
|
91
130
|
export { CountUpController };
|
|
@@ -9,6 +9,14 @@ function isBeforeRootStart(entry) {
|
|
|
9
9
|
const rootTop = entry.rootBounds?.top ?? 0;
|
|
10
10
|
return rect.bottom <= rootTop;
|
|
11
11
|
}
|
|
12
|
+
function queryRoot(selector) {
|
|
13
|
+
if (!selector) return null;
|
|
14
|
+
try {
|
|
15
|
+
return document.querySelector(selector);
|
|
16
|
+
} catch {
|
|
17
|
+
}
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
12
20
|
var IntersectionWatcher = class {
|
|
13
21
|
#onEntries;
|
|
14
22
|
#observer = null;
|
|
@@ -30,7 +38,10 @@ var IntersectionWatcher = class {
|
|
|
30
38
|
* the watcher inert — without `IntersectionObserver` support (very old
|
|
31
39
|
* browsers; the caller's no-JS fallback stays in charge) or with no targets.
|
|
32
40
|
* If initial construction with the configured options fails, the watcher
|
|
33
|
-
* warns and retries once with the same root and platform defaults.
|
|
41
|
+
* warns and retries once with the same root and platform defaults. A
|
|
42
|
+
* `rootSelector` that does not parse resolves to the viewport (see
|
|
43
|
+
* {@link IntersectionWatchOptions.rootSelector}), so a typo never fails the
|
|
44
|
+
* call.
|
|
34
45
|
*
|
|
35
46
|
* @throws The fallback constructor error if both construction attempts fail,
|
|
36
47
|
* or whatever the platform throws from `observe()`. The exception is passed
|
|
@@ -43,7 +54,7 @@ var IntersectionWatcher = class {
|
|
|
43
54
|
if (typeof IntersectionObserver === "undefined") return false;
|
|
44
55
|
const list = Array.isArray(targets) ? targets : [targets];
|
|
45
56
|
if (list.length === 0) return false;
|
|
46
|
-
const root = "root" in options ? options.root ?? null :
|
|
57
|
+
const root = "root" in options ? options.root ?? null : queryRoot(options.rootSelector);
|
|
47
58
|
let observer = null;
|
|
48
59
|
try {
|
|
49
60
|
const onEntries = (entries) => {
|
|
@@ -136,6 +147,25 @@ var IntersectionController = class extends Controller {
|
|
|
136
147
|
}
|
|
137
148
|
connect() {
|
|
138
149
|
if (this.onceValue && this.element.getAttribute("data-intersecting") === "true") return;
|
|
150
|
+
this.#observe();
|
|
151
|
+
}
|
|
152
|
+
disconnect() {
|
|
153
|
+
this.#watcher.stop();
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Re-reads the visibility line and rebuilds the observer. Turbo 8 morphing
|
|
157
|
+
* rewrites the attribute in place without a reconnect, and the line is what
|
|
158
|
+
* the intersection callback compares every ratio against, so a value frozen at
|
|
159
|
+
* connect time would decide `data-intersecting` wrongly for the rest of the
|
|
160
|
+
* page's life. Nothing to rebuild before the first `connect()`; after a spent
|
|
161
|
+
* one-shot the watcher is deliberately stopped, and re-observing would deliver
|
|
162
|
+
* the current state and fire `enter` a second time.
|
|
163
|
+
*/
|
|
164
|
+
thresholdValueChanged() {
|
|
165
|
+
if (this.#watcher.active) this.#observe();
|
|
166
|
+
}
|
|
167
|
+
/** (Re)installs the observer from the current Values. */
|
|
168
|
+
#observe() {
|
|
139
169
|
this.#effectiveThreshold = this.#clampedThreshold();
|
|
140
170
|
this.#watcher.start(this.element, {
|
|
141
171
|
rootSelector: this.rootSelectorValue,
|
|
@@ -144,9 +174,6 @@ var IntersectionController = class extends Controller {
|
|
|
144
174
|
});
|
|
145
175
|
if (this.#watcher.usingPlatformDefaults) this.#effectiveThreshold = 0;
|
|
146
176
|
}
|
|
147
|
-
disconnect() {
|
|
148
|
-
this.#watcher.stop();
|
|
149
|
-
}
|
|
150
177
|
/**
|
|
151
178
|
* Re-delivers the current intersection state as a fresh transition. Bound via
|
|
152
179
|
* `data-action` (e.g. `my-feed:appended@window->stimeo--intersection#refresh`).
|
|
@@ -177,14 +204,29 @@ var IntersectionController = class extends Controller {
|
|
|
177
204
|
const previous = this.element.getAttribute("data-intersecting");
|
|
178
205
|
this.element.setAttribute("data-intersecting", intersecting ? "true" : "false");
|
|
179
206
|
if (intersecting && previous !== "true") {
|
|
180
|
-
this.dispatch("enter", { detail: { ratio } });
|
|
181
207
|
if (this.onceValue) this.#watcher.stop();
|
|
208
|
+
this.dispatch("enter", { detail: { ratio } });
|
|
182
209
|
} else if (!intersecting && previous === "true") {
|
|
183
210
|
this.dispatch("exit", {
|
|
184
|
-
detail: { ratio, position:
|
|
211
|
+
detail: { ratio, position: this.#leftViaStartEdge(entry) ? "before" : "after" }
|
|
185
212
|
});
|
|
186
213
|
}
|
|
187
214
|
}
|
|
215
|
+
/**
|
|
216
|
+
* Which edge the element left across, for the `exit` detail. A non-zero
|
|
217
|
+
* `threshold` withdraws visibility while the element still overlaps the root,
|
|
218
|
+
* so the leaving rect can straddle the start edge — the direction is the
|
|
219
|
+
* element's own top against that edge, not whether it has cleared the root
|
|
220
|
+
* entirely (that is what `passed` reports). An element with no layout box
|
|
221
|
+
* (`display: none`, a collapsed `<details>`) is reported with an empty rect
|
|
222
|
+
* that carries no position at all, so it is deliberately neither direction
|
|
223
|
+
* and takes the "still ahead" reading.
|
|
224
|
+
*/
|
|
225
|
+
#leftViaStartEdge(entry) {
|
|
226
|
+
const rect = entry.boundingClientRect;
|
|
227
|
+
if (rect.width === 0 && rect.height === 0) return false;
|
|
228
|
+
return rect.top < (entry.rootBounds?.top ?? 0);
|
|
229
|
+
}
|
|
188
230
|
/**
|
|
189
231
|
* Reflects the "scrolled past" state onto `data-passed` and fires `passed` on
|
|
190
232
|
* transitions — the line sticky headers and reading progress key off. Like
|
|
@@ -204,9 +246,14 @@ var IntersectionController = class extends Controller {
|
|
|
204
246
|
/**
|
|
205
247
|
* Observer thresholds: the `threshold` line itself, plus `ratioSteps` evenly
|
|
206
248
|
* spaced steps when fine-grained `change` ratios are wanted (progress bars).
|
|
249
|
+
*
|
|
250
|
+
* 0 is always observed. An observer notifies only at the lines it was given,
|
|
251
|
+
* so a non-zero `threshold` on its own delivers its last callback while the
|
|
252
|
+
* element is still partly visible: the element leaving for good would never be
|
|
253
|
+
* reported, freezing the ratio and `data-passed` mid-departure.
|
|
207
254
|
*/
|
|
208
255
|
#thresholds() {
|
|
209
|
-
const thresholds = /* @__PURE__ */ new Set([this.#clampedThreshold()]);
|
|
256
|
+
const thresholds = /* @__PURE__ */ new Set([0, this.#clampedThreshold()]);
|
|
210
257
|
if (this.ratioStepsValue > 0) {
|
|
211
258
|
for (let i = 0; i <= this.ratioStepsValue; i += 1) {
|
|
212
259
|
thresholds.add(i / this.ratioStepsValue);
|
|
@@ -3,6 +3,14 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
3
3
|
// src/controllers/lazy_frame_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) => {
|
|
@@ -106,47 +117,98 @@ var LazyFrameController = class extends Controller {
|
|
|
106
117
|
static events = ["load"];
|
|
107
118
|
/** Shared IO plumbing (support guard, active guard, teardown). */
|
|
108
119
|
#watcher = new IntersectionWatcher((entries) => this.#onIntersect(entries));
|
|
120
|
+
#connected = false;
|
|
109
121
|
#loaded = false;
|
|
110
|
-
/**
|
|
111
|
-
|
|
122
|
+
/**
|
|
123
|
+
* Whether the observer last saw the frame inside the observed area — `null`
|
|
124
|
+
* until it has reported. A re-entry needs a frame that was seen inside and
|
|
125
|
+
* then left, so an unknown position is never a departure.
|
|
126
|
+
*/
|
|
127
|
+
#inside = null;
|
|
128
|
+
/**
|
|
129
|
+
* Focus reaching the frame starts the FIRST load before it intersects (keyboard / AT).
|
|
130
|
+
* `#load` releases this listener, so focus never reaches an already-loaded frame.
|
|
131
|
+
*/
|
|
132
|
+
#onFocus = () => this.#load();
|
|
133
|
+
/**
|
|
134
|
+
* Follows the held URL at runtime: one that arrives arms the frame, one that is
|
|
135
|
+
* taken away disarms it, so an empty `url` holds no triggers either way.
|
|
136
|
+
*
|
|
137
|
+
* Stimulus runs value callbacks before `connect()`, so the connected guard keeps
|
|
138
|
+
* arming in one place. A frame that already loaded keeps its held URL for the next
|
|
139
|
+
* re-entry instead of fetching on the spot.
|
|
140
|
+
*/
|
|
141
|
+
urlValueChanged() {
|
|
142
|
+
if (!this.#connected || this.#loaded) return;
|
|
143
|
+
if (this.urlValue) this.#arm();
|
|
144
|
+
else this.#stopObserving();
|
|
145
|
+
}
|
|
146
|
+
/** Rebuilds the observer when the early-load margin changes at runtime. */
|
|
147
|
+
rootMarginValueChanged() {
|
|
148
|
+
if (this.#connected && this.#watcher.active) this.#observe();
|
|
149
|
+
}
|
|
112
150
|
connect() {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
this.element.addEventListener("focusin", this.#onFocus);
|
|
119
|
-
this.#watcher.start(this.element, { rootMargin: this.rootMarginValue });
|
|
151
|
+
this.#loaded = this.element.hasAttribute("data-lazy-loaded");
|
|
152
|
+
this.#inside = null;
|
|
153
|
+
this.#connected = true;
|
|
154
|
+
if (this.#loaded && this.onceValue) return;
|
|
155
|
+
this.#arm();
|
|
120
156
|
}
|
|
121
157
|
disconnect() {
|
|
158
|
+
this.#connected = false;
|
|
122
159
|
this.#stopObserving();
|
|
123
160
|
}
|
|
161
|
+
/** Attaches the triggers that still have a job to do. */
|
|
162
|
+
#arm() {
|
|
163
|
+
if (!this.urlValue) return;
|
|
164
|
+
if (!this.#loaded) this.element.addEventListener("focusin", this.#onFocus);
|
|
165
|
+
this.#observe();
|
|
166
|
+
}
|
|
167
|
+
#observe() {
|
|
168
|
+
this.#watcher.start(this.element, { rootMargin: this.rootMarginValue });
|
|
169
|
+
}
|
|
124
170
|
#onIntersect(entries) {
|
|
125
171
|
for (const entry of entries) {
|
|
126
172
|
if (entry.isIntersecting) {
|
|
127
|
-
this.#
|
|
128
|
-
|
|
173
|
+
const returning = this.#inside === false;
|
|
174
|
+
this.#inside = true;
|
|
175
|
+
this.#enter(returning);
|
|
176
|
+
} else if (this.#inside) {
|
|
177
|
+
this.#inside = false;
|
|
129
178
|
}
|
|
130
179
|
}
|
|
131
180
|
}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
181
|
+
/** Applies an intersecting entry: the first one loads, a return re-fetches. */
|
|
182
|
+
#enter(returning) {
|
|
183
|
+
if (!this.#loaded) {
|
|
184
|
+
this.#load();
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (this.onceValue || !returning) return;
|
|
188
|
+
this.#reload();
|
|
135
189
|
}
|
|
136
190
|
/** Starts the load by writing the held URL to `src`. */
|
|
137
191
|
#load() {
|
|
192
|
+
if (!this.urlValue) return;
|
|
138
193
|
this.#loaded = true;
|
|
139
194
|
this.element.setAttribute("src", this.urlValue);
|
|
140
195
|
this.element.setAttribute("data-lazy-loaded", "true");
|
|
141
196
|
this.dispatch("load", { detail: { url: this.urlValue } });
|
|
142
|
-
|
|
197
|
+
this.element.removeEventListener("focusin", this.#onFocus);
|
|
198
|
+
if (this.onceValue) this.#watcher.stop();
|
|
143
199
|
}
|
|
144
|
-
/** Re-entry while `once` is off:
|
|
200
|
+
/** Re-entry while `once` is off: fetch the held URL again. */
|
|
145
201
|
#reload() {
|
|
202
|
+
const src = this.element.getAttribute("src");
|
|
203
|
+
if (this.urlValue && this.urlValue !== src) {
|
|
204
|
+
this.element.setAttribute("src", this.urlValue);
|
|
205
|
+
this.dispatch("load", { detail: { url: this.urlValue } });
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
146
208
|
const frame = this.element;
|
|
147
209
|
if (typeof frame.reload !== "function") return;
|
|
148
210
|
frame.reload();
|
|
149
|
-
this.dispatch("load", { detail: { url:
|
|
211
|
+
this.dispatch("load", { detail: { url: src ?? "" } });
|
|
150
212
|
}
|
|
151
213
|
#stopObserving() {
|
|
152
214
|
this.#watcher.stop();
|
|
@@ -64,6 +64,7 @@ var DetachGate = class _DetachGate {
|
|
|
64
64
|
};
|
|
65
65
|
|
|
66
66
|
// src/controllers/pointer_drag_controller.ts
|
|
67
|
+
var NATIVE_KEY_OWNERS = "input, textarea, select, button, a[href], summary, [contenteditable]";
|
|
67
68
|
var PointerDragController = class _PointerDragController extends Controller {
|
|
68
69
|
static targets = ["handle"];
|
|
69
70
|
static values = {
|
|
@@ -73,6 +74,7 @@ var PointerDragController = class _PointerDragController extends Controller {
|
|
|
73
74
|
disabled: { type: Boolean, default: false },
|
|
74
75
|
follow: { type: Boolean, default: false }
|
|
75
76
|
};
|
|
77
|
+
static actions = ["reset"];
|
|
76
78
|
static events = ["start", "move", "end", "cancel"];
|
|
77
79
|
/** Marker attribute recording that this controller set the touch-action. */
|
|
78
80
|
static #TOUCH_ACTION_MARKER = "data-pointer-drag-touch-action";
|
|
@@ -106,23 +108,49 @@ var PointerDragController = class _PointerDragController extends Controller {
|
|
|
106
108
|
}
|
|
107
109
|
this.#teardown();
|
|
108
110
|
}
|
|
109
|
-
/**
|
|
111
|
+
/**
|
|
112
|
+
* Returns the element to its origin: drops the committed follow offset and the
|
|
113
|
+
* inline `translate` that carries it.
|
|
114
|
+
*
|
|
115
|
+
* In follow mode the inline `translate` belongs to this controller, and the
|
|
116
|
+
* committed offset lives in a field the DOM cannot reach — so a consumer that
|
|
117
|
+
* wants the element back at the start, or that has written a position of its
|
|
118
|
+
* own, needs this to make the two agree again. An in-flight drag is cancelled
|
|
119
|
+
* first, or its deltas would land on top of the offset just cleared.
|
|
120
|
+
*/
|
|
121
|
+
reset() {
|
|
122
|
+
const live = this.#pointer?.started ? this.#pointer.pointerType : this.#keyboard ? "keyboard" : null;
|
|
123
|
+
if (this.#pointer || this.#keyboard) this.#teardownSessions();
|
|
124
|
+
if (live) this.#dispatchCancel(live);
|
|
125
|
+
this.#followBase = { x: 0, y: 0 };
|
|
126
|
+
this.#applyFollow(0, 0);
|
|
127
|
+
}
|
|
110
128
|
handleTargetConnected(handle) {
|
|
129
|
+
if (handle !== this.element) this.#restoreHandle(this.element);
|
|
111
130
|
this.#prepareHandle(handle);
|
|
112
131
|
}
|
|
113
132
|
handleTargetDisconnected(handle) {
|
|
114
|
-
if (!this.element.contains(handle)) {
|
|
115
|
-
if (this.#pointer?.handle === handle) this.#endPointerSession();
|
|
116
|
-
if (this.#keyboard?.handle === handle) this.#clearKeyboardSession();
|
|
117
|
-
}
|
|
118
133
|
this.#restoreHandle(handle);
|
|
134
|
+
if (this.element.contains(handle)) return;
|
|
135
|
+
const interrupted = this.#pointer?.handle === handle && this.#pointer.started ? this.#pointer.pointerType : this.#keyboard?.handle === handle ? "keyboard" : null;
|
|
136
|
+
if (this.#pointer?.handle === handle) this.#endPointerSession();
|
|
137
|
+
if (this.#keyboard?.handle === handle) this.#clearKeyboardSession();
|
|
138
|
+
if (!this.hasHandleTarget) this.#prepareHandle(this.element);
|
|
139
|
+
if (interrupted) this.#dispatchCancel(interrupted);
|
|
119
140
|
}
|
|
120
|
-
/**
|
|
121
|
-
|
|
141
|
+
/**
|
|
142
|
+
* Re-derives the handles' touch-action when the axis changes.
|
|
143
|
+
*
|
|
144
|
+
* Two layers, like the `tabindex` loan: the marker says the value was once
|
|
145
|
+
* ours, and the current inline value says it still is. A consumer that wrote
|
|
146
|
+
* its own `touch-action` after connect keeps it.
|
|
147
|
+
*/
|
|
148
|
+
axisValueChanged(_value, previousValue) {
|
|
149
|
+
const lent = this.#touchActionFor(previousValue ?? this.axisValue);
|
|
122
150
|
for (const handle of this.#handles()) {
|
|
123
|
-
if (handle.hasAttribute(_PointerDragController.#TOUCH_ACTION_MARKER))
|
|
124
|
-
|
|
125
|
-
|
|
151
|
+
if (!handle.hasAttribute(_PointerDragController.#TOUCH_ACTION_MARKER)) continue;
|
|
152
|
+
if (handle.style.touchAction !== lent) continue;
|
|
153
|
+
handle.style.touchAction = this.#touchActionForAxis();
|
|
126
154
|
}
|
|
127
155
|
}
|
|
128
156
|
/** Cancels any in-flight session when the controller is disabled mid-drag. */
|
|
@@ -203,9 +231,10 @@ var PointerDragController = class _PointerDragController extends Controller {
|
|
|
203
231
|
const handle = this.#handleFor(event.target);
|
|
204
232
|
if (!handle) return;
|
|
205
233
|
if (event.defaultPrevented) return;
|
|
234
|
+
if (event.isComposing) return;
|
|
235
|
+
if (this.#ownsNativeKeys(event.target, handle)) return;
|
|
206
236
|
if (isReservedArrowChord(event)) return;
|
|
207
237
|
if (event.key === "Escape") {
|
|
208
|
-
if (event.isComposing) return;
|
|
209
238
|
if (this.#pointer?.started) {
|
|
210
239
|
const { pointerType } = this.#pointer;
|
|
211
240
|
this.#teardownSessions();
|
|
@@ -226,6 +255,10 @@ var PointerDragController = class _PointerDragController extends Controller {
|
|
|
226
255
|
return;
|
|
227
256
|
}
|
|
228
257
|
if (!this.#keyboard) return;
|
|
258
|
+
if (event.key === "Home" || event.key === "End") {
|
|
259
|
+
event.preventDefault();
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
229
262
|
const step = this.#keyboardDelta(event.key);
|
|
230
263
|
if (!step) return;
|
|
231
264
|
event.preventDefault();
|
|
@@ -314,6 +347,7 @@ var PointerDragController = class _PointerDragController extends Controller {
|
|
|
314
347
|
const interrupted = this.#pointer?.started ? this.#pointer.pointerType : this.#keyboard ? "keyboard" : null;
|
|
315
348
|
this.#teardownSessions();
|
|
316
349
|
for (const handle of this.#handles()) this.#restoreHandle(handle);
|
|
350
|
+
this.#followReset();
|
|
317
351
|
if (interrupted && this.element.isConnected) this.#dispatchCancel(interrupted);
|
|
318
352
|
}
|
|
319
353
|
/**
|
|
@@ -383,8 +417,12 @@ var PointerDragController = class _PointerDragController extends Controller {
|
|
|
383
417
|
}
|
|
384
418
|
/** `touch-action` that lets the page keep panning on the locked axis only. */
|
|
385
419
|
#touchActionForAxis() {
|
|
386
|
-
|
|
387
|
-
|
|
420
|
+
return this.#touchActionFor(this.axisValue);
|
|
421
|
+
}
|
|
422
|
+
/** The `touch-action` an `axis` declaration lends, for any axis value. */
|
|
423
|
+
#touchActionFor(axis) {
|
|
424
|
+
if (axis === "x") return "pan-y";
|
|
425
|
+
if (axis === "y") return "pan-x";
|
|
388
426
|
return "none";
|
|
389
427
|
}
|
|
390
428
|
/**
|
|
@@ -395,8 +433,10 @@ var PointerDragController = class _PointerDragController extends Controller {
|
|
|
395
433
|
* are marker-owned so `#restoreHandle` reverts them symmetrically on teardown.
|
|
396
434
|
*/
|
|
397
435
|
#prepareHandle(handle) {
|
|
398
|
-
|
|
399
|
-
|
|
436
|
+
const lent = this.#touchActionForAxis();
|
|
437
|
+
const marked = handle.hasAttribute(_PointerDragController.#TOUCH_ACTION_MARKER);
|
|
438
|
+
if (handle.style.touchAction === "" || marked && handle.style.touchAction === lent) {
|
|
439
|
+
handle.style.touchAction = lent;
|
|
400
440
|
handle.setAttribute(_PointerDragController.#TOUCH_ACTION_MARKER, "true");
|
|
401
441
|
}
|
|
402
442
|
if (handle.tabIndex < 0 && !handle.hasAttribute("tabindex")) {
|
|
@@ -404,17 +444,27 @@ var PointerDragController = class _PointerDragController extends Controller {
|
|
|
404
444
|
handle.setAttribute(_PointerDragController.#TABINDEX_MARKER, "true");
|
|
405
445
|
}
|
|
406
446
|
}
|
|
447
|
+
/** Whether the key belongs to a native control or editing surface in the handle. */
|
|
448
|
+
#ownsNativeKeys(target, handle) {
|
|
449
|
+
const owner = target?.closest(NATIVE_KEY_OWNERS) ?? null;
|
|
450
|
+
return owner !== null && owner !== handle && handle.contains(owner);
|
|
451
|
+
}
|
|
407
452
|
/** Reverts the marker-owned touch-action + tabindex (authored values untouched). */
|
|
408
453
|
#restoreHandle(handle) {
|
|
409
454
|
if (handle.hasAttribute(_PointerDragController.#TOUCH_ACTION_MARKER)) {
|
|
410
|
-
handle.style.touchAction
|
|
455
|
+
if (handle.style.touchAction === this.#touchActionForAxis()) {
|
|
456
|
+
handle.style.touchAction = "";
|
|
457
|
+
}
|
|
411
458
|
handle.removeAttribute(_PointerDragController.#TOUCH_ACTION_MARKER);
|
|
412
459
|
}
|
|
413
460
|
if (handle.hasAttribute(_PointerDragController.#TABINDEX_MARKER)) {
|
|
414
|
-
handle.
|
|
415
|
-
if (
|
|
461
|
+
const ours = handle.getAttribute("tabindex") === "0";
|
|
462
|
+
if (ours && document.activeElement !== handle) {
|
|
416
463
|
handle.removeAttribute("tabindex");
|
|
464
|
+
handle.removeAttribute(_PointerDragController.#TABINDEX_MARKER);
|
|
465
|
+
return;
|
|
417
466
|
}
|
|
467
|
+
if (!ours) handle.removeAttribute(_PointerDragController.#TABINDEX_MARKER);
|
|
418
468
|
}
|
|
419
469
|
}
|
|
420
470
|
};
|
|
@@ -69,15 +69,42 @@ var PortalController = class extends Controller {
|
|
|
69
69
|
static events = ["mount", "unmount"];
|
|
70
70
|
/** Decides whether a `disconnect()` is an in-page move or a real detach. */
|
|
71
71
|
#gate = new DetachGate();
|
|
72
|
+
/** The `to` declaration after validation; the default when it cannot be parsed. */
|
|
73
|
+
#toSelector = "body";
|
|
74
|
+
/**
|
|
75
|
+
* Validates the destination declaration once, keeping only a selector the engine can
|
|
76
|
+
* read.
|
|
77
|
+
*
|
|
78
|
+
* A selector reads back as an ordinary string, so a malformed one survives until it is
|
|
79
|
+
* handed to the DOM — where it would take the whole teleport down and leave the part
|
|
80
|
+
* silently doing nothing. Falling back to the default puts the node somewhere visible
|
|
81
|
+
* instead, which reads as a mistake and can be traced back to the declaration.
|
|
82
|
+
*/
|
|
83
|
+
toValueChanged() {
|
|
84
|
+
const selector = this.toValue;
|
|
85
|
+
if (selector.length > 0) {
|
|
86
|
+
try {
|
|
87
|
+
this.element.matches(selector);
|
|
88
|
+
this.#toSelector = selector;
|
|
89
|
+
return;
|
|
90
|
+
} catch {
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
this.#toSelector = "body";
|
|
94
|
+
}
|
|
72
95
|
connect() {
|
|
73
96
|
this.#gate.cancel();
|
|
74
|
-
|
|
97
|
+
const existing = portalState.get(this.element);
|
|
98
|
+
if (existing) {
|
|
99
|
+
existing.owner = this;
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
75
102
|
const node = this.hasContentTarget ? this.contentTarget : this.element;
|
|
76
103
|
const destination = this.#destination();
|
|
77
104
|
if (!destination || destination === node || node.contains(destination)) return;
|
|
78
105
|
const placeholder = document.createComment("stimeo--portal");
|
|
79
106
|
node.parentNode?.insertBefore(placeholder, node);
|
|
80
|
-
portalState.set(this.element, { node, placeholder });
|
|
107
|
+
portalState.set(this.element, { node, placeholder, owner: this });
|
|
81
108
|
if (this.positionValue === "prepend") {
|
|
82
109
|
destination.prepend(node);
|
|
83
110
|
} else {
|
|
@@ -96,7 +123,7 @@ var PortalController = class extends Controller {
|
|
|
96
123
|
}
|
|
97
124
|
this.#gate.disconnected(this, () => {
|
|
98
125
|
const current = portalState.get(this.element);
|
|
99
|
-
if (current) this.#restore(current);
|
|
126
|
+
if (current?.owner === this) this.#restore(current);
|
|
100
127
|
});
|
|
101
128
|
}
|
|
102
129
|
/** Returns the node to its placeholder (or removes it) and clears the bookkeeping. */
|
|
@@ -112,15 +139,9 @@ var PortalController = class extends Controller {
|
|
|
112
139
|
placeholder.remove();
|
|
113
140
|
this.dispatch("unmount", { detail: {} });
|
|
114
141
|
}
|
|
115
|
-
/** Resolves the destination
|
|
142
|
+
/** Resolves the destination from the validated `to` selector. */
|
|
116
143
|
#destination() {
|
|
117
|
-
|
|
118
|
-
if (!selector) return null;
|
|
119
|
-
try {
|
|
120
|
-
return document.querySelector(selector);
|
|
121
|
-
} catch {
|
|
122
|
-
return null;
|
|
123
|
-
}
|
|
144
|
+
return document.querySelector(this.#toSelector);
|
|
124
145
|
}
|
|
125
146
|
};
|
|
126
147
|
|