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.
@@ -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
- window.addEventListener("resize", this.#onScroll, { passive: true });
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
- window.removeEventListener("resize", this.#onScroll);
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.style.setProperty(PROGRESS_PROPERTY, value);
41
- document.documentElement.style.setProperty(PROGRESS_PROPERTY, value);
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 : 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) => {
@@ -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: 80 },
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.containerSelectorValue) {
46
- const container = document.querySelector(this.containerSelectorValue);
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
- if (y <= this.offsetValue) this.#apply(false);
61
- else if (delta > 0) this.#apply(true);
62
- else this.#apply(false);
99
+ this.#apply(delta > 0);
63
100
  }
64
- /** Reflects the state onto the hook and emits `change` on transitions. */
65
- #apply(hidden) {
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