stimeo-ui 0.16.0 → 0.18.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.
Files changed (117) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +136 -0
  3. data/README.md +27 -11
  4. data/dist/cable/index.js +384 -449
  5. data/dist/controllers/accordion_controller.js +174 -55
  6. data/dist/controllers/alert_dialog_controller.js +903 -235
  7. data/dist/controllers/announcer_controller.js +238 -205
  8. data/dist/controllers/aspect_ratio_controller.js +0 -10
  9. data/dist/controllers/auto_submit_controller.js +414 -145
  10. data/dist/controllers/avatar_controller.js +185 -107
  11. data/dist/controllers/breadcrumb_controller.js +0 -130
  12. data/dist/controllers/bulk_select_controller.js +282 -58
  13. data/dist/controllers/calendar_controller.js +228 -260
  14. data/dist/controllers/carousel_controller.js +536 -436
  15. data/dist/controllers/character_counter_controller.js +493 -207
  16. data/dist/controllers/checkbox_controller.js +38 -74
  17. data/dist/controllers/clipboard_controller.js +261 -149
  18. data/dist/controllers/collapsible_controller.js +304 -120
  19. data/dist/controllers/color_picker_controller.js +178 -146
  20. data/dist/controllers/combobox_controller.js +522 -156
  21. data/dist/controllers/command_palette_controller.js +1156 -395
  22. data/dist/controllers/conditional_fields_controller.js +300 -171
  23. data/dist/controllers/confirm_controller.js +980 -262
  24. data/dist/controllers/context_menu_controller.js +346 -153
  25. data/dist/controllers/count_up_controller.js +73 -21
  26. data/dist/controllers/countdown_controller.js +281 -158
  27. data/dist/controllers/currency_input_controller.js +575 -225
  28. data/dist/controllers/data_grid_controller.js +248 -214
  29. data/dist/controllers/date_range_picker_controller.js +338 -235
  30. data/dist/controllers/dialog_controller.js +899 -237
  31. data/dist/controllers/direct_upload_controller.js +97 -167
  32. data/dist/controllers/dirty_form_controller.js +72 -69
  33. data/dist/controllers/dismissible_controller.js +196 -24
  34. data/dist/controllers/drawer_controller.js +974 -355
  35. data/dist/controllers/dropdown_controller.js +339 -96
  36. data/dist/controllers/editable_controller.js +315 -88
  37. data/dist/controllers/empty_state_controller.js +79 -58
  38. data/dist/controllers/file_dropzone_controller.js +322 -211
  39. data/dist/controllers/filter_controller.js +77 -42
  40. data/dist/controllers/flash_controller.js +238 -347
  41. data/dist/controllers/focus_controller.js +679 -240
  42. data/dist/controllers/form_field_controller.js +156 -132
  43. data/dist/controllers/form_validation_controller.js +31 -97
  44. data/dist/controllers/frame_loading_controller.js +275 -217
  45. data/dist/controllers/highlight_controller.js +107 -75
  46. data/dist/controllers/hover_card_controller.js +327 -130
  47. data/dist/controllers/idle_controller.js +271 -101
  48. data/dist/controllers/input_mask_controller.js +483 -97
  49. data/dist/controllers/intersection_controller.js +150 -106
  50. data/dist/controllers/lazy_frame_controller.js +24 -68
  51. data/dist/controllers/listbox_controller.js +523 -240
  52. data/dist/controllers/local_time_controller.js +102 -77
  53. data/dist/controllers/masonry_controller.js +80 -96
  54. data/dist/controllers/menu_controller.js +302 -188
  55. data/dist/controllers/menubar_controller.js +232 -345
  56. data/dist/controllers/meter_controller.js +417 -87
  57. data/dist/controllers/multi_select_controller.js +785 -393
  58. data/dist/controllers/navigation_menu_controller.js +190 -250
  59. data/dist/controllers/nested_form_controller.js +396 -192
  60. data/dist/controllers/network_status_controller.js +268 -62
  61. data/dist/controllers/number_input_controller.js +610 -303
  62. data/dist/controllers/optimistic_controller.js +45 -87
  63. data/dist/controllers/otp_controller.js +658 -255
  64. data/dist/controllers/overflow_indicator_controller.js +122 -113
  65. data/dist/controllers/overflow_menu_controller.js +470 -295
  66. data/dist/controllers/pagination_controller.js +332 -123
  67. data/dist/controllers/password_reveal_controller.js +392 -121
  68. data/dist/controllers/password_strength_controller.js +111 -158
  69. data/dist/controllers/persist_controller.js +165 -111
  70. data/dist/controllers/pointer_drag_controller.js +434 -188
  71. data/dist/controllers/popover_controller.js +269 -113
  72. data/dist/controllers/portal_controller.js +215 -76
  73. data/dist/controllers/preview_guard_controller.js +243 -116
  74. data/dist/controllers/progress_controller.js +391 -78
  75. data/dist/controllers/radio_group_controller.js +278 -211
  76. data/dist/controllers/range_slider_controller.js +220 -113
  77. data/dist/controllers/rating_controller.js +458 -211
  78. data/dist/controllers/read_more_controller.js +138 -63
  79. data/dist/controllers/reading_progress_controller.js +166 -137
  80. data/dist/controllers/relative_time_controller.js +211 -112
  81. data/dist/controllers/{reset_before_cache_controller.js → reset_on_restore_controller.js} +103 -38
  82. data/dist/controllers/resizable_controller.js +299 -150
  83. data/dist/controllers/roving_controller.js +49 -88
  84. data/dist/controllers/scroll_area_controller.js +490 -246
  85. data/dist/controllers/scroll_restore_controller.js +14 -55
  86. data/dist/controllers/scroll_visibility_controller.js +432 -149
  87. data/dist/controllers/scrollspy_controller.js +193 -248
  88. data/dist/controllers/separator_controller.js +285 -142
  89. data/dist/controllers/sidebar_controller.js +1069 -404
  90. data/dist/controllers/skeleton_controller.js +259 -101
  91. data/dist/controllers/slider_controller.js +187 -86
  92. data/dist/controllers/smart_sticky_header_controller.js +173 -66
  93. data/dist/controllers/sortable_controller.js +26 -125
  94. data/dist/controllers/spinner_controller.js +288 -173
  95. data/dist/controllers/step_indicator_controller.js +105 -46
  96. data/dist/controllers/stepper_controller.js +120 -73
  97. data/dist/controllers/stick_to_bottom_controller.js +275 -140
  98. data/dist/controllers/sticky_observer_controller.js +220 -49
  99. data/dist/controllers/submit_once_controller.js +376 -227
  100. data/dist/controllers/switch_controller.js +201 -84
  101. data/dist/controllers/tabs_controller.js +41 -30
  102. data/dist/controllers/tags_input_controller.js +293 -173
  103. data/dist/controllers/textarea_autosize_controller.js +108 -74
  104. data/dist/controllers/theme_controller.js +140 -116
  105. data/dist/controllers/time_picker_controller.js +331 -127
  106. data/dist/controllers/toast_controller.js +244 -324
  107. data/dist/controllers/toggle_group_controller.js +252 -191
  108. data/dist/controllers/toolbar_controller.js +219 -120
  109. data/dist/controllers/tooltip_controller.js +158 -124
  110. data/dist/controllers/transition_controller.js +123 -119
  111. data/dist/controllers/tree_view_controller.js +283 -369
  112. data/dist/index.js +8573 -10410
  113. data/dist/positioning/index.js +154 -47
  114. data/lib/generators/stimeo/install/install_generator.rb +7 -2
  115. data/lib/generators/stimeo/install/templates/stimeo.js +19 -6
  116. data/lib/stimeo/ui/version.rb +1 -1
  117. metadata +3 -3
@@ -1,19 +1,14 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
 
3
- // src/controllers/stick_to_bottom_controller.ts
4
-
5
- // src/utils/layout_observer.ts
6
3
  var LayoutObserver = class {
7
4
  #callback;
8
5
  #resizeObserverFactory;
9
6
  #resizeObserver = null;
10
7
  #observingViewport = false;
11
8
  #loadContainer = null;
12
- /** Stable bound handler so add/removeEventListener target the same reference. */
13
9
  #handleViewportResize = () => {
14
10
  this.#callback();
15
11
  };
16
- /** Stable bound handler for the capture-phase `load`; see {@link observeDescendantLoads}. */
17
12
  #handleDescendantLoad = () => {
18
13
  this.#callback();
19
14
  };
@@ -21,11 +16,6 @@ var LayoutObserver = class {
21
16
  this.#callback = callback;
22
17
  this.#resizeObserverFactory = options.resizeObserverFactory ?? (typeof ResizeObserver === "undefined" ? null : (cb) => new ResizeObserver(cb));
23
18
  }
24
- /**
25
- * Starts observing an element's size. Repeated calls observe additional
26
- * elements through the same shared observer. No-ops when no
27
- * `ResizeObserver` implementation is available.
28
- */
29
19
  observe(element) {
30
20
  if (!this.#resizeObserverFactory) return;
31
21
  if (!this.#resizeObserver) {
@@ -35,47 +25,28 @@ var LayoutObserver = class {
35
25
  }
36
26
  this.#resizeObserver.observe(element);
37
27
  }
38
- /** Stops observing a single element while leaving any others in place. */
39
28
  unobserve(element) {
40
29
  this.#resizeObserver?.unobserve(element);
41
30
  }
42
- /** Starts observing viewport resizes. Idempotent: the listener is added once. */
43
31
  observeViewport() {
44
32
  if (this.#observingViewport) return;
45
33
  this.#observingViewport = true;
46
34
  window.addEventListener("resize", this.#handleViewportResize);
47
35
  }
48
- /** Stops observing viewport resizes without affecting element observation. */
49
36
  unobserveViewport() {
50
37
  if (!this.#observingViewport) return;
51
38
  this.#observingViewport = false;
52
39
  window.removeEventListener("resize", this.#handleViewportResize);
53
40
  }
54
- /**
55
- * Starts reporting a `load` from anywhere inside `container` — an image or a
56
- * frame settling changes the box it sits in, and it measures as zero high until
57
- * then. `load` does not bubble, so the subscription is a capture-phase listener
58
- * on the container itself and nothing the caller spells.
59
- *
60
- * **One container at a time.** A further call moves the observation, so a widget
61
- * whose content element is swapped at runtime releases the element it let go by
62
- * naming the new one — there is no second place for the release to drift from.
63
- */
64
41
  observeDescendantLoads(container) {
65
42
  this.unobserveDescendantLoads();
66
43
  this.#loadContainer = container;
67
44
  container.addEventListener("load", this.#handleDescendantLoad, true);
68
45
  }
69
- /** Stops reporting descendant loads without affecting element or viewport observation. */
70
46
  unobserveDescendantLoads() {
71
47
  this.#loadContainer?.removeEventListener("load", this.#handleDescendantLoad, true);
72
48
  this.#loadContainer = null;
73
49
  }
74
- /**
75
- * Releases every observation: disconnects the {@link ResizeObserver} and removes
76
- * the viewport and descendant-load listeners. Safe to call multiple times. Call
77
- * this from a controller's `disconnect()`.
78
- */
79
50
  disconnect() {
80
51
  this.#resizeObserver?.disconnect();
81
52
  this.#resizeObserver = null;
@@ -84,21 +55,155 @@ var LayoutObserver = class {
84
55
  }
85
56
  };
86
57
 
87
- // src/utils/reduced_motion.ts
58
+ var ListenerSet = class {
59
+ #abort = new AbortController();
60
+ add(target, type, handler, options) {
61
+ target.addEventListener(type, handler, { ...options, signal: this.#abort.signal });
62
+ }
63
+ dispose() {
64
+ this.#abort.abort();
65
+ this.#abort = new AbortController();
66
+ }
67
+ };
68
+
69
+ var MicrotaskCoalescer = class {
70
+ #run;
71
+ #queued = false;
72
+ #active = false;
73
+ #generation = 0;
74
+ constructor(run) {
75
+ this.#run = run;
76
+ }
77
+ activate() {
78
+ this.#active = true;
79
+ }
80
+ cancel() {
81
+ this.#active = false;
82
+ this.#queued = false;
83
+ this.#generation += 1;
84
+ }
85
+ get pending() {
86
+ return this.#queued;
87
+ }
88
+ schedule() {
89
+ if (!this.#active || this.#queued) return;
90
+ this.#queued = true;
91
+ const generation = this.#generation;
92
+ queueMicrotask(() => {
93
+ if (generation !== this.#generation || !this.#queued || !this.#active) return;
94
+ this.#queued = false;
95
+ this.#run();
96
+ });
97
+ }
98
+ };
99
+
100
+ var MorphRenderWatcher = class {
101
+ #listeners = new ListenerSet();
102
+ #pass;
103
+ #element = null;
104
+ #onMorph = () => this.#pass.schedule();
105
+ constructor(run) {
106
+ this.#pass = new MicrotaskCoalescer(run);
107
+ }
108
+ observe(element) {
109
+ if (this.#element === element) return;
110
+ this.disconnect();
111
+ this.#element = element;
112
+ this.#pass.activate();
113
+ this.#listeners.add(element, "turbo:morph-element", this.#onMorph);
114
+ }
115
+ schedule() {
116
+ this.#pass.schedule();
117
+ }
118
+ get pending() {
119
+ return this.#pass.pending;
120
+ }
121
+ disconnect() {
122
+ this.#listeners.dispose();
123
+ this.#pass.cancel();
124
+ this.#element = null;
125
+ }
126
+ };
127
+
128
+ var NUMBER_BOUNDS = {
129
+ nonNegative: { finite: true, min: 0 }};
130
+ function matchesNumberBounds(value, bounds) {
131
+ if (!Number.isFinite(value)) {
132
+ const direction = value === Infinity ? "positive" : value === -Infinity ? "negative" : null;
133
+ if (direction === null) return false;
134
+ if (bounds.allowInfinity !== "both" && bounds.allowInfinity !== direction) return false;
135
+ }
136
+ if (bounds.min !== void 0 && value < bounds.min) return false;
137
+ if (bounds.max !== void 0 && value > bounds.max) return false;
138
+ if (bounds.exclusiveMin !== void 0 && value <= bounds.exclusiveMin) return false;
139
+ if (bounds.integer && !Number.isInteger(value)) return false;
140
+ if (bounds.allowedValues !== void 0 && !bounds.allowedValues.includes(value)) return false;
141
+ return true;
142
+ }
143
+
144
+ function readNumber(raw, fallback, bounds) {
145
+ return matchesNumberBounds(raw, bounds) ? raw : fallback;
146
+ }
147
+
148
+ var NumberValueReader = class {
149
+ #lastRejected = /* @__PURE__ */ new Map();
150
+ read(owner, name, raw, fallback, bounds) {
151
+ const resolved = readNumber(raw, fallback, bounds);
152
+ if (matchesNumberBounds(raw, bounds)) {
153
+ this.#lastRejected.delete(name);
154
+ return resolved;
155
+ }
156
+ const attribute = `data-${owner.identifier}-${name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}-value`;
157
+ const literal = owner.element.getAttribute(attribute);
158
+ if (literal === null) {
159
+ this.#lastRejected.delete(name);
160
+ return resolved;
161
+ }
162
+ if (this.#lastRejected.get(name) !== literal) {
163
+ this.#lastRejected.set(name, literal);
164
+ console.warn(
165
+ `Stimeo UI: "${owner.identifier}" has an invalid number Value "${name}" declaration ${JSON.stringify(literal)}; using ${fallback}.`
166
+ );
167
+ }
168
+ return resolved;
169
+ }
170
+ };
171
+
88
172
  function prefersReducedMotion() {
89
173
  return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
90
174
  }
91
175
 
92
- // src/utils/state_regions.ts
93
- var StateRegions = class {
176
+ function setAttributeIfChanged(element, name, value, beforeWrite) {
177
+ if (element.getAttribute(name) === value) return false;
178
+ if (value === null) element.removeAttribute(name);
179
+ else element.setAttribute(name, value);
180
+ return true;
181
+ }
182
+
183
+ function sharedRegistry(key, create) {
184
+ const symbol = Symbol.for(key);
185
+ const scope = globalThis;
186
+ const existing = scope[symbol];
187
+ if (existing !== void 0) return existing;
188
+ const registry = create();
189
+ Object.defineProperty(globalThis, symbol, { value: registry });
190
+ return registry;
191
+ }
192
+
193
+ var StateRegions = class _StateRegions {
194
+ static #registry = sharedRegistry(
195
+ "stimeo-ui.state-regions.registry.v1",
196
+ () => ({ stacks: /* @__PURE__ */ new WeakMap(), names: /* @__PURE__ */ new Set() })
197
+ );
94
198
  #whenTrue;
95
199
  #whenFalse;
96
- #taken = /* @__PURE__ */ new WeakSet();
97
- constructor(options) {
200
+ #record;
201
+ constructor(options, owner) {
98
202
  this.#whenTrue = options.whenTrue;
99
203
  this.#whenFalse = options.whenFalse ?? null;
204
+ this.#record = `data-${owner}-hidden-region`;
205
+ _StateRegions.#registry.names.add(this.#record);
100
206
  }
101
- /** Shows the regions inside `host` that belong to `isTrue` and hides the others. */
102
207
  reflect(host, isTrue) {
103
208
  const shown = this.#inside(host, this.#whenTrue());
104
209
  if (!this.#whenFalse) {
@@ -114,112 +219,192 @@ var StateRegions = class {
114
219
  for (const region of shown) this.#write(region, !isTrue);
115
220
  for (const region of hiddenSide) this.#write(region, isTrue);
116
221
  }
117
- /** Writes `hidden` where it moves, noting which regions it takes out of view. */
222
+ release(host) {
223
+ for (const region of [host, ...host.querySelectorAll("*")]) {
224
+ if (!(region instanceof HTMLElement)) continue;
225
+ const stack = this.#held(region);
226
+ const hold = stack && this.#take(stack);
227
+ if (!stack || !hold) continue;
228
+ const owns = region.hidden === hold.written && !this.#covered(region, stack, hold);
229
+ const index = stack.indexOf(hold);
230
+ stack.splice(index, 1);
231
+ const above = stack[index];
232
+ if (above) above.linked &&= hold.linked;
233
+ if (owns) {
234
+ const below = hold.linked ? stack[index - 1] : void 0;
235
+ if (below) {
236
+ if (region.hidden !== below.written) region.hidden = below.written;
237
+ } else {
238
+ setAttributeIfChanged(region, "hidden", hold.authored);
239
+ }
240
+ }
241
+ this.#mark(region, stack);
242
+ }
243
+ }
118
244
  #write(region, hidden) {
119
- if (hidden) this.#taken.add(region);
120
- else this.#taken.delete(region);
121
- if (region.hidden !== hidden) region.hidden = hidden;
245
+ const stack = this.#stack(region);
246
+ const hold = this.#take(stack) ?? this.#acquire(region, stack);
247
+ const shows = !this.#covered(region, stack, hold);
248
+ hold.written = hidden;
249
+ if (shows && region.hidden !== hidden) region.hidden = hidden;
250
+ this.#mark(region, stack);
122
251
  }
123
- /** Returns a region this instance hid; one it never hid keeps what it carries. */
124
252
  #give(region) {
125
- if (this.#taken.delete(region)) region.hidden = false;
253
+ const stack = this.#held(region);
254
+ if (stack && this.#take(stack)?.written) this.#write(region, false);
255
+ }
256
+ #held(region) {
257
+ return _StateRegions.#registry.stacks.has(region) || region.hasAttribute(this.#record) ? this.#stack(region) : void 0;
258
+ }
259
+ #stack(region) {
260
+ const stacks = _StateRegions.#registry.stacks;
261
+ const stack = stacks.get(region) ?? this.#seed(region);
262
+ stacks.set(region, stack);
263
+ return stack;
264
+ }
265
+ #seed(region) {
266
+ const found = [];
267
+ for (const record of _StateRegions.#registry.names) {
268
+ const read = this.#recorded(region, record);
269
+ if (read) found.push({ ...read, record });
270
+ }
271
+ const depth = ({ beneath }) => beneath.filter((name) => found.some((other) => other.record === name)).length;
272
+ return found.sort((one, other) => depth(one) - depth(other)).map(({ record, authored, written, linked }, index) => ({
273
+ instance: null,
274
+ record,
275
+ authored,
276
+ written,
277
+ linked: linked && index > 0
278
+ }));
279
+ }
280
+ #take(stack) {
281
+ const hold = stack.find((held) => held.instance === this) ?? stack.find((held) => held.instance === null && held.record === this.#record);
282
+ if (hold) hold.instance = this;
283
+ return hold;
284
+ }
285
+ #covered(region, stack, hold) {
286
+ return stack.slice(stack.indexOf(hold) + 1).some((above) => above.written === region.hidden);
287
+ }
288
+ #acquire(region, stack) {
289
+ const top = stack.at(-1);
290
+ const over = top && region.hidden === top.written ? top : void 0;
291
+ const hold = {
292
+ instance: this,
293
+ record: this.#record,
294
+ authored: over ? over.authored : region.getAttribute("hidden"),
295
+ written: false,
296
+ linked: over !== void 0
297
+ };
298
+ stack.push(hold);
299
+ return hold;
300
+ }
301
+ #mark(region, stack) {
302
+ const records = /* @__PURE__ */ new Map([[this.#record, null]]);
303
+ stack.forEach((hold, index) => {
304
+ const names = stack.slice(0, index).map((under) => under.record);
305
+ const beneath = [...new Set(names.reverse())].filter((name) => name !== hold.record);
306
+ const marker = hold.linked || beneath.length === 0 ? [] : [false];
307
+ records.set(
308
+ hold.record,
309
+ region.getAttribute("hidden") === hold.authored && beneath.length === 0 && index === stack.length - 1 ? null : JSON.stringify([hold.authored, hold.written, ...marker, ...beneath])
310
+ );
311
+ });
312
+ for (const [name, recorded] of records) {
313
+ setAttributeIfChanged(region, name, recorded);
314
+ }
315
+ }
316
+ #recorded(region, name) {
317
+ try {
318
+ const parsed = JSON.parse(region.getAttribute(name) ?? "null");
319
+ if (!Array.isArray(parsed)) return null;
320
+ const [authored, written, ...rest] = parsed;
321
+ if (authored !== null && typeof authored !== "string") return null;
322
+ const linked = rest[0] !== false;
323
+ const beneath = linked ? rest : rest.slice(1);
324
+ if (!beneath.every((under) => typeof under === "string")) return null;
325
+ return typeof written === "boolean" ? { authored, written, linked, beneath } : null;
326
+ } catch {
327
+ return null;
328
+ }
126
329
  }
127
- /** The declared regions that sit within `host`, which may be the host itself. */
128
330
  #inside(host, regions) {
129
331
  return regions.filter((region) => host.contains(region));
130
332
  }
131
333
  };
132
334
 
133
- // src/controllers/stick_to_bottom_controller.ts
134
335
  var DEFAULT_THRESHOLD = 80;
135
336
  var countElements = (nodes) => {
136
337
  let n = 0;
137
338
  for (const node of nodes) if (node.nodeType === Node.ELEMENT_NODE) n += 1;
138
339
  return n;
139
340
  };
140
- var StickToBottomController = class extends Controller {
341
+ var StickToBottomController = class _StickToBottomController extends Controller {
342
+ #numbers = new NumberValueReader();
141
343
  static targets = ["content", "hasNew"];
142
344
  static values = {
143
345
  threshold: { type: Number, default: DEFAULT_THRESHOLD },
144
346
  behavior: { type: String, default: "auto" },
145
347
  pinOnConnect: { type: Boolean, default: false }
146
348
  };
349
+ static valueConstraints = {
350
+ threshold: NUMBER_BOUNDS.nonNegative
351
+ };
147
352
  static actions = ["scrollToBottom"];
148
353
  static events = ["pin", "new"];
149
354
  #observer = null;
150
- /** The element the append observer currently holds, so a swap can be detected. */
151
355
  #watched = null;
152
- /** Watches for the box a container connected without one is still waiting on. */
153
356
  #layout = new LayoutObserver(() => this.#onLaidOut());
154
357
  #awaitingLayout = false;
155
358
  #connected = false;
156
359
  #pinned = false;
157
360
  #hasNew = false;
158
- /** Owns `hidden` on the regions declared for the has-new state. */
159
- #hasNewRegion = new StateRegions({ whenTrue: () => this.hasNewTargets });
361
+ #hasNewRegion = new StateRegions(
362
+ { whenTrue: () => this.hasNewTargets },
363
+ this.identifier
364
+ );
160
365
  #onScroll = () => this.#updatePinned();
366
+ #morphRender = new MorphRenderWatcher(() => {
367
+ this.#updatePinned();
368
+ this.#setHasNew(this.#hasNew);
369
+ });
370
+ #listeners = new ListenerSet();
161
371
  connect() {
372
+ this.#morphRender.observe(this.element);
162
373
  this.#connected = true;
163
374
  if (this.pinOnConnectValue && this.#measurable()) this.#scrollToBottom("instant");
164
375
  this.#setHasNew(false);
165
376
  this.#pinned = this.#isPinned();
166
377
  this.#reflectPinned();
167
- this.element.addEventListener("scroll", this.#onScroll, { passive: true });
378
+ this.#listeners.add(this.element, "scroll", this.#onScroll, { passive: true });
168
379
  this.#syncWatched();
169
380
  if (!this.#measurable()) this.#waitForLayout();
170
381
  }
171
382
  disconnect() {
383
+ this.#morphRender.disconnect();
172
384
  this.#connected = false;
173
- this.element.removeEventListener("scroll", this.#onScroll);
385
+ this.#listeners.dispose();
174
386
  this.#stopWatching();
175
387
  this.#stopWaitingForLayout();
176
388
  }
177
- /** Moves the append watch onto a `content` target that arrived at runtime. */
178
389
  contentTargetConnected() {
179
390
  this.#syncWatched();
180
391
  }
181
- /** Moves the append watch off a `content` target that left, back onto the container. */
182
392
  contentTargetDisconnected() {
183
393
  this.#syncWatched();
184
394
  }
185
- /**
186
- * Settles a has-new region that arrived at runtime on the side the flag is currently
187
- * on, the way a region present at connect is settled by the connect-time normalization.
188
- *
189
- * Stimulus attaches targets before `connect()`, and a container that leaves the document
190
- * and comes back keeps its controller, so in that window the flag still reads the
191
- * arrival of the connection that ended. A region attached there is left to the
192
- * connect-time normalization, which drops the flag and settles the region in one write.
193
- */
194
395
  hasNewTargetConnected() {
195
396
  if (!this.#connected) return;
196
397
  this.#hasNewRegion.reflect(this.element, this.#hasNew);
197
398
  }
198
- /**
199
- * Re-derives pinned when the distance that counts as the bottom is changed at runtime
200
- * (a morph that swaps the attribute on a retained element).
201
- */
202
399
  thresholdValueChanged() {
203
400
  if (!this.#connected) return;
204
401
  this.#updatePinned();
205
402
  }
206
- /**
207
- * Jumps to the bottom and re-pins (wired to a "new messages" button).
208
- *
209
- * The has-new flag clears on request — the user has acknowledged the arrival — while
210
- * pinned is read back from where the scroll landed: a jump that arrives by the time
211
- * this returns pins immediately, an animated one settles from its own scroll events,
212
- * and a jump the engine cannot honor leaves the container unpinned, so the next append
213
- * flags it again instead of being swallowed by a pinned state that does not hold.
214
- *
215
- * Which of those happens is not this method's to decide — see {@link behaviorValue}.
216
- */
217
403
  scrollToBottom() {
218
404
  this.#scrollToBottom();
219
405
  this.#setHasNew(false);
220
406
  this.#updatePinned();
221
407
  }
222
- /** Follows appended children while pinned; otherwise flags new content. */
223
408
  #onMutations(mutations) {
224
409
  let added = 0;
225
410
  for (const mutation of mutations) added += countElements(mutation.addedNodes);
@@ -231,19 +416,13 @@ var StickToBottomController = class extends Controller {
231
416
  this.dispatch("new", { detail: { count: added } });
232
417
  }
233
418
  }
234
- /**
235
- * Recomputes pinned from the scroll position and reflects it on a transition.
236
- *
237
- * @stimeoRenderRoot
238
- */
239
419
  #updatePinned() {
240
420
  const pinned = this.#isPinned();
241
- if (pinned === this.#pinned) return;
421
+ const changed = pinned !== this.#pinned;
242
422
  this.#pinned = pinned;
243
423
  this.#reflectPinned();
244
- this.dispatch("pin", { detail: { pinned } });
424
+ if (changed) this.dispatch("pin", { detail: { pinned } });
245
425
  }
246
- /** Mirrors the current `#pinned` onto the state hooks (clearing has-new once pinned). */
247
426
  #reflectPinned() {
248
427
  if (this.#pinned) {
249
428
  this.element.setAttribute("data-pinned", "true");
@@ -252,10 +431,6 @@ var StickToBottomController = class extends Controller {
252
431
  this.element.removeAttribute("data-pinned");
253
432
  }
254
433
  }
255
- /**
256
- * Mirrors whether content has arrived that the container did not follow onto the state
257
- * hook and onto the regions declared for that state.
258
- */
259
434
  #setHasNew(hasNew) {
260
435
  this.#hasNew = hasNew;
261
436
  if (hasNew) {
@@ -265,61 +440,31 @@ var StickToBottomController = class extends Controller {
265
440
  }
266
441
  this.#hasNewRegion.reflect(this.element, hasNew);
267
442
  }
268
- /** Whether the container currently sits within `threshold` of its bottom. */
269
443
  #isPinned() {
270
444
  if (!this.#measurable()) return false;
271
445
  const el = this.element;
272
446
  return el.scrollHeight - el.clientHeight - el.scrollTop <= this.#threshold;
273
447
  }
274
- /**
275
- * The distance from the bottom that counts as pinned: a finite, non-negative number of
276
- * pixels. Anything else names no distance the container can be at, and settles the
277
- * comparison the same way at every scroll position, so it falls back to the default.
278
- * `Number` reads `"abc"` as `NaN` and every comparison against it is false; a negative
279
- * distance sits below the closest the container ever gets; `Infinity` is never
280
- * exceeded. The first two stop following and flag every append as new, and the last
281
- * never stops following — it takes the reading position the flag exists to protect.
282
- * Zero is a real declaration: it pins at the exact bottom only.
283
- */
284
448
  get #threshold() {
285
- const declared = this.thresholdValue;
286
- return Number.isFinite(declared) && declared >= 0 ? declared : DEFAULT_THRESHOLD;
287
- }
288
- /**
289
- * Whether the container has a box to scroll and to measure. One that is not rendered
290
- * (inside a closed panel) reports every metric as 0, which reads as "already at the
291
- * bottom" — a position describing no layout the user will ever see.
292
- */
449
+ return this.#safeThreshold;
450
+ }
293
451
  #measurable() {
294
452
  return this.element.clientHeight > 0;
295
453
  }
296
- /**
297
- * Holds the pinned decision until the container is laid out — otherwise the panel opens
298
- * at the top while the state claims the bottom, and the appends that arrived meanwhile
299
- * were followed into a box that could not move rather than flagged.
300
- */
301
454
  #waitForLayout() {
302
455
  this.#awaitingLayout = true;
303
456
  this.#layout.observe(this.element);
304
457
  }
305
- /** Runs the held decision once the container has the box it was waiting for. */
306
458
  #onLaidOut() {
307
459
  if (!this.#awaitingLayout || !this.#measurable()) return;
308
460
  this.#stopWaitingForLayout();
309
461
  if (this.pinOnConnectValue) this.#scrollToBottom("instant");
310
462
  this.#updatePinned();
311
463
  }
312
- /** Releases the layout watch, whether or not the held decision ever ran. */
313
464
  #stopWaitingForLayout() {
314
465
  this.#awaitingLayout = false;
315
466
  this.#layout.disconnect();
316
467
  }
317
- /**
318
- * Scrolls to the bottom, clamped by the engine to the maximum scroll offset — which is
319
- * 0 for a container tall enough to hold its whole content, so the jump moves nothing
320
- * there. `behavior` defaults to the configured follow behavior; pass `"instant"` for a
321
- * jump that must not animate.
322
- */
323
468
  #scrollToBottom(behavior = this.#behavior()) {
324
469
  const top = this.element.scrollHeight;
325
470
  if (typeof this.element.scrollTo === "function") {
@@ -328,17 +473,6 @@ var StickToBottomController = class extends Controller {
328
473
  this.element.scrollTop = top;
329
474
  }
330
475
  }
331
- /**
332
- * Points the append watch at the current `content` target, or at the container when
333
- * there is none. Re-resolved whenever that target changes, so a swap does not leave the
334
- * observer holding a detached node whose appends nobody sees.
335
- *
336
- * Stimulus runs the target callbacks outside the connected window too — before
337
- * `connect()` for a target already in the DOM, and after `disconnect()` while the
338
- * element is torn down — where this would arm an observer nothing releases. Re-syncing
339
- * to the target already held is left alone, so an arrival still in flight is not
340
- * dropped with the observer that was about to deliver it.
341
- */
342
476
  #syncWatched() {
343
477
  if (!this.#connected) return;
344
478
  const next = this.hasContentTarget ? this.contentTarget : this.element;
@@ -349,24 +483,25 @@ var StickToBottomController = class extends Controller {
349
483
  this.#observer = new MutationObserver((mutations) => this.#onMutations(mutations));
350
484
  this.#observer.observe(next, { childList: true });
351
485
  }
352
- /** Releases the append watch and the element it held. */
353
486
  #stopWatching() {
354
487
  this.#observer?.disconnect();
355
488
  this.#observer = null;
356
489
  this.#watched = null;
357
490
  }
358
- /**
359
- * The behavior a follow-scroll runs with. `"auto"` is **not** a request to arrive at
360
- * once: it hands the decision to the element's computed `scroll-behavior`, so a
361
- * consumer stylesheet saying `smooth` animates these scrolls too. Only `"instant"`
362
- * overrides that CSS, which is why reduced motion and the `pinOnConnect` jump name it.
363
- */
364
491
  #behavior() {
365
492
  if (prefersReducedMotion()) return "instant";
366
493
  return this.behaviorValue === "smooth" ? "smooth" : "auto";
367
494
  }
495
+ get #safeThreshold() {
496
+ return this.#numbers.read(
497
+ this,
498
+ "threshold",
499
+ this.thresholdValue,
500
+ _StickToBottomController.values.threshold.default,
501
+ _StickToBottomController.valueConstraints.threshold
502
+ );
503
+ }
368
504
  };
369
505
 
370
506
  export { StickToBottomController };
371
- //# sourceMappingURL=stick_to_bottom_controller.js.map
372
507
  //# sourceMappingURL=stick_to_bottom_controller.js.map