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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dbc740b1497e7c760dce2eb0207e1e513a64e944aef2d666b8785c1244bd4618
4
- data.tar.gz: e687fa19aae7ea8432130228a1bb0bfeb82868f56b18c8d823edb5d6dd1b77c7
3
+ metadata.gz: 7f691ab87c2a0b5092226d8d319ebff3c7b6d8a658b8a62c42017b7dc3e50807
4
+ data.tar.gz: e2aebaed7d717a580cbd9af42ae6149e7cda083fbbdd3465488525fdac8ee01e
5
5
  SHA512:
6
- metadata.gz: eeeef9df2dedeb5aa23da6de1c08bc6921f0c85554de9162999895d48ddd89950dfe5dfa32b895d3fd80a780470966a2ae9f9517e8743b6b7868c9a1e441b2da
7
- data.tar.gz: 3a40f5f32596616f06fa52a93a446ddf73ed25421c610c54209d200eb8c631d5b334e1f014852e8331e4fd9b09ad0933929ad1de54d36aaa4d14361e05a2bcc0
6
+ metadata.gz: 182402ce77c5e8a010e3c6f2229c8865f339ea20447907f776f7e557a3c52de7aa9b3eada614e9c64c2cb4d3a7850a1a1e5b3724e1f836ed666f6cfadf221787
7
+ data.tar.gz: 7061380101c2b4c7a1ad996e11ba1a6816ed276ba96944b55e1bd5d5efecefb8ee9607c466604f6ad97b7da990c86c1dbd5376256ef29d8c8c7d39dada7fd50b
data/CHANGELOG.md CHANGED
@@ -7,6 +7,75 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
  While the version is `0.x`, the public API (the `stimeo--*` data attributes) may
8
8
  change between releases.
9
9
 
10
+ ## [0.12.0] - 2026-09-06
11
+
12
+ Minor release with no new components. Six existing ones are reworked — count-up,
13
+ intersection, lazy-frame, reading-progress, smart-sticky-header, and sortable —
14
+ and the number reading count-up gained reaches live-counter too. Most of them
15
+ changed a contract, so read Removed and Changed before upgrading. The Inspector
16
+ manifest stays on schema v12.
17
+
18
+ ### Removed
19
+
20
+ - sortable: the `status` target, its `data-grabbed` / `data-moved` /
21
+ `data-dropped` / `data-canceled` templates, and the English fallback wording.
22
+ Seat a `stimeo--announcer` on the page and set `announceGrabbedText` /
23
+ `announceMovedText` / `announceDroppedText` / `announceCanceledText`
24
+ (`{name}` / `{position}` / `{total}`); an unset one announces nothing.
25
+ `stimeo check` stops asking for the live region and warns when no announcer
26
+ is seated.
27
+
28
+ ### Changed
29
+
30
+ - count-up: only the text node holding the number is animated, and while it
31
+ ticks it is wrapped in a `role="img"` element named with the authored text —
32
+ the host's own `aria-label` and `role` are never touched, and sibling markup
33
+ stays where it was.
34
+ - count-up and live-counter read a formatted number by one rule: the first
35
+ numeric token, group separators dropped, the fraction truncated, and a hyphen
36
+ a sign only where it opens the token — `"Sign-ups: 1,200"` reads 1200, not
37
+ -1200.
38
+ - intersection: `0` is always among the observed lines, so a non-zero
39
+ `threshold` also reports the element leaving for good (one more `change`;
40
+ `data-passed` and the ratio property follow the departure to its end), and an
41
+ exit across the start edge reports `position: "before"` while the element
42
+ still overlaps the root.
43
+ - lazy-frame: a re-fetch needs a genuine re-entry — the frame is seen inside the
44
+ observed area, leaves it, and comes back; where a connection or a
45
+ focus-started load first finds it is the baseline. A frame with `once` off is
46
+ armed again after a cache restore, and `load` names the URL that was fetched.
47
+ - reading-progress: an article with no layout box is not measured, the
48
+ article's own box is watched so late-settling content re-measures, and the
49
+ connect frame is the baseline — a restored scroll position does not fire
50
+ `complete`. Both faces of the custom property are leased: the element's copy
51
+ is returned on `disconnect()` too, an authored `:root` declaration comes back,
52
+ and a later writer is left alone.
53
+ - smart-sticky-header: `change` fires on transitions only (connecting is
54
+ silent), and the `offset` zone is decided ahead of the jitter guard.
55
+ - sortable: insertion is relative to the neighbouring item — the `list`-less
56
+ markup works and the last slot is after the last item — and a drag signal from
57
+ a pointer-drag nested inside an item no longer moves the card. The pickup slot
58
+ is remembered as neighbours, so rows inserted or removed mid-drag shift neither
59
+ the restore nor `from`; only laid-out siblings take part in pointer following;
60
+ and a session whose item leaves the item set ends instead of refusing every
61
+ later grab. pointer-drag consumes `Home` / `End` while grabbed.
62
+ - A declaration that cannot be read falls back to its default: count-up's
63
+ `duration` / `from`, smart-sticky-header's `offset`, and a `rootSelector` /
64
+ `containerSelector` that does not parse (intersection, sticky-observer,
65
+ smart-sticky-header) observes the viewport or the window. Runtime changes to
66
+ intersection's `threshold`, lazy-frame's `url` / `rootMargin`, and
67
+ smart-sticky-header's `offset` are followed.
68
+
69
+ ### Fixed
70
+
71
+ - lazy-frame: focus moving inside a loaded frame no longer re-fetches it, the
72
+ first intersection after a focus-started load is not a re-entry, and an empty
73
+ `url` is never written to `src`.
74
+ - intersection: with `once`, a handler calling `refresh()` from `enter` no longer
75
+ clears the one-shot marker, so a cache restore does not fire `enter` again.
76
+ - reading-progress: hiding a half-read article no longer publishes `1` and
77
+ `complete`.
78
+
10
79
  ## [0.11.0] - 2026-09-05
11
80
 
12
81
  Minor release with no new components. Eight existing ones are reworked — the three
@@ -930,6 +999,7 @@ Initial public alpha: 101 behavior-only, accessible Stimulus controllers driven
930
999
  by `data-*` attributes, shipping no CSS. Published to npm (with provenance) and
931
1000
  RubyGems.
932
1001
 
1002
+ [0.12.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.12.0
933
1003
  [0.11.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.11.0
934
1004
  [0.10.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.10.0
935
1005
  [0.9.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.9.0
data/dist/cable/index.js CHANGED
@@ -2,6 +2,28 @@ import { Controller } from '@hotwired/stimulus';
2
2
  import { createConsumer } from '@rails/actioncable';
3
3
 
4
4
  // src/cable/live_counter_controller.ts
5
+
6
+ // src/utils/authored_integer.ts
7
+ var TOKEN_BODY = /\d[\d.,'’’    _]*\d|\d/;
8
+ var SIGN_OPENER = /[\s([]/;
9
+ function authoredInteger(text) {
10
+ const firstDigit = /\d/.exec(text);
11
+ if (firstDigit === null) return null;
12
+ const at = firstDigit.index;
13
+ const signed = at > 0 && text[at - 1] === "-" && (at === 1 || SIGN_OPENER.test(text[at - 2]));
14
+ const body = TOKEN_BODY.exec(text.slice(at));
15
+ const runs = body[0].split(/\D+/);
16
+ const separators = body[0].match(/\D+/g) ?? [];
17
+ const lead = runs[0];
18
+ const heads = lead.length <= 3 && !lead.startsWith("0");
19
+ let digits = lead;
20
+ for (let i = 1; heads && i < runs.length; i += 1) {
21
+ if (separators[i - 1]?.length !== 1 || runs[i]?.length !== 3) break;
22
+ digits += runs[i];
23
+ }
24
+ const magnitude = Number.parseInt(digits, 10);
25
+ return signed ? -magnitude || 0 : magnitude;
26
+ }
5
27
  function parseSubscriptionParams(raw) {
6
28
  try {
7
29
  const parsed = JSON.parse(raw);
@@ -229,8 +251,7 @@ var LiveCounterController = class extends Controller {
229
251
  }
230
252
  /** The current count, parsed from the DOM (the single source of truth). */
231
253
  get #current() {
232
- const parsed = Number.parseInt((this.#display.textContent ?? "").replace(/[^0-9-]/g, ""), 10);
233
- return Number.isNaN(parsed) ? 0 : parsed;
254
+ return authoredInteger(this.#display.textContent ?? "") ?? 0;
234
255
  }
235
256
  #write(count) {
236
257
  if (count === this.#current) return;
@@ -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: 1200 },
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 authored final text, restored verbatim when the run settles. */
21
- #finalText = "";
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
- if (this.element.hasAttribute("data-count-up-label")) {
24
- this.element.textContent = this.element.getAttribute("aria-label") ?? this.element.textContent;
25
- this.#restoreLabel();
26
- this.element.setAttribute("data-count-up-done", "true");
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
- this.#finalText = this.element.textContent ?? "";
40
- const target = Number.parseInt(this.#finalText.replace(/[^0-9-]/g, ""), 10);
41
- if (Number.isNaN(target)) return;
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 authored = this.element.getAttribute("aria-label");
48
- if (authored !== null) {
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.durationValue, 1);
81
+ const t = Math.min((now - started) / this.#duration, 1);
56
82
  const eased = 1 - (1 - t) ** 3;
57
- this.element.textContent = String(
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
- this.element.textContent = this.#finalText;
74
- this.#restoreLabel();
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 : options.rootSelector ? document.querySelector(options.rootSelector) : 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: isBeforeRootStart(entry) ? "before" : "after" }
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 : options.rootSelector ? document.querySelector(options.rootSelector) : 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
- /** Focus reaching the frame triggers the load before it intersects (keyboard / AT). */
111
- #onFocus = () => this.#trigger();
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
- if (this.element.hasAttribute("data-lazy-loaded")) {
114
- this.#loaded = true;
115
- return;
116
- }
117
- if (!this.urlValue) return;
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.#trigger();
128
- return;
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
- #trigger() {
133
- if (!this.#loaded) this.#load();
134
- else if (!this.onceValue) this.#reload();
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
- if (this.onceValue) this.#stopObserving();
197
+ this.element.removeEventListener("focusin", this.#onFocus);
198
+ if (this.onceValue) this.#watcher.stop();
143
199
  }
144
- /** Re-entry while `once` is off: ask Turbo to reload the frame's current `src`. */
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: this.urlValue } });
211
+ this.dispatch("load", { detail: { url: src ?? "" } });
150
212
  }
151
213
  #stopObserving() {
152
214
  this.#watcher.stop();
@@ -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();