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.
@@ -1,32 +1,115 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/optimistic_controller.ts
4
- var TOGGLED_MARKER = "data-optimistic-toggled";
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
- if (this.element.hasAttribute("data-optimistic")) this.#revert();
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.element.setAttribute("aria-busy", "true");
20
- for (const target of this.showTargets) this.#toggleHidden(target, false);
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(TOGGLED_MARKER);
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
- /** Sets `hidden` only when it actually changes, marker-tracking the mutation. */
38
- #toggleHidden(target, hidden) {
39
- if (target.hidden === hidden) return;
40
- target.hidden = hidden;
41
- target.setAttribute(TOGGLED_MARKER, "true");
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
- /** Reverts exactly the mutations this controller made (marker-owned only). */
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.removeAttribute("aria-busy");
47
- for (const target of [...this.showTargets, ...this.hideTargets]) {
48
- if (target.hasAttribute(TOGGLED_MARKER)) {
49
- target.hidden = !target.hidden;
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
- 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