stimeo-ui 0.16.0 → 0.17.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 (115) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +31 -0
  3. data/README.md +8 -1
  4. data/dist/cable/index.js +363 -436
  5. data/dist/controllers/accordion_controller.js +166 -55
  6. data/dist/controllers/alert_dialog_controller.js +862 -221
  7. data/dist/controllers/announcer_controller.js +135 -198
  8. data/dist/controllers/aspect_ratio_controller.js +0 -10
  9. data/dist/controllers/auto_submit_controller.js +271 -127
  10. data/dist/controllers/avatar_controller.js +171 -105
  11. data/dist/controllers/breadcrumb_controller.js +0 -130
  12. data/dist/controllers/bulk_select_controller.js +262 -52
  13. data/dist/controllers/calendar_controller.js +175 -209
  14. data/dist/controllers/carousel_controller.js +461 -399
  15. data/dist/controllers/character_counter_controller.js +246 -156
  16. data/dist/controllers/checkbox_controller.js +32 -74
  17. data/dist/controllers/clipboard_controller.js +168 -147
  18. data/dist/controllers/collapsible_controller.js +301 -120
  19. data/dist/controllers/color_picker_controller.js +149 -139
  20. data/dist/controllers/combobox_controller.js +360 -133
  21. data/dist/controllers/command_palette_controller.js +970 -358
  22. data/dist/controllers/conditional_fields_controller.js +166 -124
  23. data/dist/controllers/confirm_controller.js +939 -248
  24. data/dist/controllers/context_menu_controller.js +256 -128
  25. data/dist/controllers/count_up_controller.js +73 -21
  26. data/dist/controllers/countdown_controller.js +183 -148
  27. data/dist/controllers/currency_input_controller.js +134 -158
  28. data/dist/controllers/data_grid_controller.js +114 -186
  29. data/dist/controllers/date_range_picker_controller.js +288 -206
  30. data/dist/controllers/dialog_controller.js +858 -223
  31. data/dist/controllers/direct_upload_controller.js +73 -153
  32. data/dist/controllers/dirty_form_controller.js +14 -48
  33. data/dist/controllers/dismissible_controller.js +175 -21
  34. data/dist/controllers/drawer_controller.js +933 -341
  35. data/dist/controllers/dropdown_controller.js +312 -82
  36. data/dist/controllers/editable_controller.js +158 -68
  37. data/dist/controllers/empty_state_controller.js +67 -53
  38. data/dist/controllers/file_dropzone_controller.js +311 -207
  39. data/dist/controllers/filter_controller.js +67 -39
  40. data/dist/controllers/flash_controller.js +172 -314
  41. data/dist/controllers/focus_controller.js +654 -240
  42. data/dist/controllers/form_field_controller.js +147 -132
  43. data/dist/controllers/form_validation_controller.js +13 -86
  44. data/dist/controllers/frame_loading_controller.js +264 -213
  45. data/dist/controllers/highlight_controller.js +107 -75
  46. data/dist/controllers/hover_card_controller.js +309 -118
  47. data/dist/controllers/idle_controller.js +267 -101
  48. data/dist/controllers/input_mask_controller.js +71 -72
  49. data/dist/controllers/intersection_controller.js +147 -106
  50. data/dist/controllers/lazy_frame_controller.js +9 -65
  51. data/dist/controllers/listbox_controller.js +306 -204
  52. data/dist/controllers/local_time_controller.js +54 -64
  53. data/dist/controllers/masonry_controller.js +70 -93
  54. data/dist/controllers/menu_controller.js +214 -156
  55. data/dist/controllers/menubar_controller.js +170 -329
  56. data/dist/controllers/meter_controller.js +151 -67
  57. data/dist/controllers/multi_select_controller.js +593 -358
  58. data/dist/controllers/navigation_menu_controller.js +124 -232
  59. data/dist/controllers/nested_form_controller.js +352 -182
  60. data/dist/controllers/network_status_controller.js +242 -54
  61. data/dist/controllers/number_input_controller.js +320 -263
  62. data/dist/controllers/optimistic_controller.js +16 -76
  63. data/dist/controllers/otp_controller.js +198 -193
  64. data/dist/controllers/overflow_indicator_controller.js +116 -113
  65. data/dist/controllers/overflow_menu_controller.js +343 -283
  66. data/dist/controllers/pagination_controller.js +295 -119
  67. data/dist/controllers/password_reveal_controller.js +389 -121
  68. data/dist/controllers/password_strength_controller.js +99 -153
  69. data/dist/controllers/persist_controller.js +237 -103
  70. data/dist/controllers/pointer_drag_controller.js +185 -165
  71. data/dist/controllers/popover_controller.js +236 -97
  72. data/dist/controllers/portal_controller.js +209 -76
  73. data/dist/controllers/preview_guard_controller.js +232 -116
  74. data/dist/controllers/progress_controller.js +125 -56
  75. data/dist/controllers/radio_group_controller.js +117 -137
  76. data/dist/controllers/range_slider_controller.js +192 -105
  77. data/dist/controllers/rating_controller.js +418 -207
  78. data/dist/controllers/read_more_controller.js +134 -63
  79. data/dist/controllers/reading_progress_controller.js +147 -135
  80. data/dist/controllers/relative_time_controller.js +145 -110
  81. data/dist/controllers/{reset_before_cache_controller.js → reset_on_restore_controller.js} +97 -38
  82. data/dist/controllers/resizable_controller.js +255 -139
  83. data/dist/controllers/roving_controller.js +39 -84
  84. data/dist/controllers/scroll_area_controller.js +428 -225
  85. data/dist/controllers/scroll_restore_controller.js +0 -53
  86. data/dist/controllers/scroll_visibility_controller.js +395 -145
  87. data/dist/controllers/scrollspy_controller.js +181 -242
  88. data/dist/controllers/separator_controller.js +267 -140
  89. data/dist/controllers/sidebar_controller.js +1023 -386
  90. data/dist/controllers/skeleton_controller.js +248 -97
  91. data/dist/controllers/slider_controller.js +159 -79
  92. data/dist/controllers/smart_sticky_header_controller.js +162 -63
  93. data/dist/controllers/sortable_controller.js +0 -112
  94. data/dist/controllers/spinner_controller.js +277 -169
  95. data/dist/controllers/step_indicator_controller.js +99 -46
  96. data/dist/controllers/stepper_controller.js +114 -73
  97. data/dist/controllers/stick_to_bottom_controller.js +262 -138
  98. data/dist/controllers/sticky_observer_controller.js +214 -49
  99. data/dist/controllers/submit_once_controller.js +294 -205
  100. data/dist/controllers/switch_controller.js +33 -60
  101. data/dist/controllers/tabs_controller.js +35 -29
  102. data/dist/controllers/tags_input_controller.js +136 -145
  103. data/dist/controllers/textarea_autosize_controller.js +71 -58
  104. data/dist/controllers/theme_controller.js +130 -112
  105. data/dist/controllers/time_picker_controller.js +146 -80
  106. data/dist/controllers/toast_controller.js +186 -297
  107. data/dist/controllers/toggle_group_controller.js +95 -136
  108. data/dist/controllers/toolbar_controller.js +68 -98
  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 +150 -345
  112. data/dist/index.js +6370 -9346
  113. data/dist/positioning/index.js +148 -47
  114. data/lib/stimeo/ui/version.rb +1 -1
  115. 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,142 @@ 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
+ schedule() {
86
+ if (!this.#active || this.#queued) return;
87
+ this.#queued = true;
88
+ const generation = this.#generation;
89
+ queueMicrotask(() => {
90
+ if (generation !== this.#generation || !this.#queued || !this.#active) return;
91
+ this.#queued = false;
92
+ this.#run();
93
+ });
94
+ }
95
+ };
96
+
97
+ var MorphRenderWatcher = class {
98
+ #listeners = new ListenerSet();
99
+ #pass;
100
+ #element = null;
101
+ #onMorph = () => this.#pass.schedule();
102
+ constructor(run) {
103
+ this.#pass = new MicrotaskCoalescer(run);
104
+ }
105
+ observe(element) {
106
+ if (this.#element === element) return;
107
+ this.disconnect();
108
+ this.#element = element;
109
+ this.#pass.activate();
110
+ this.#listeners.add(element, "turbo:morph-element", this.#onMorph);
111
+ }
112
+ schedule() {
113
+ this.#pass.schedule();
114
+ }
115
+ disconnect() {
116
+ this.#listeners.dispose();
117
+ this.#pass.cancel();
118
+ this.#element = null;
119
+ }
120
+ };
121
+
122
+ var NUMBER_BOUNDS = {
123
+ nonNegative: { finite: true, min: 0 }};
124
+ function matchesNumberBounds(value, bounds) {
125
+ if (!Number.isFinite(value)) {
126
+ const direction = value === Infinity ? "positive" : value === -Infinity ? "negative" : null;
127
+ if (direction === null) return false;
128
+ if (bounds.allowInfinity !== "both" && bounds.allowInfinity !== direction) return false;
129
+ }
130
+ if (bounds.min !== void 0 && value < bounds.min) return false;
131
+ if (bounds.max !== void 0 && value > bounds.max) return false;
132
+ if (bounds.exclusiveMin !== void 0 && value <= bounds.exclusiveMin) return false;
133
+ if (bounds.integer && !Number.isInteger(value)) return false;
134
+ if (bounds.allowedValues !== void 0 && !bounds.allowedValues.includes(value)) return false;
135
+ return true;
136
+ }
137
+
138
+ function readNumber(raw, fallback, bounds) {
139
+ return matchesNumberBounds(raw, bounds) ? raw : fallback;
140
+ }
141
+
142
+ var NumberValueReader = class {
143
+ #lastRejected = /* @__PURE__ */ new Map();
144
+ read(owner, name, raw, fallback, bounds) {
145
+ const resolved = readNumber(raw, fallback, bounds);
146
+ if (matchesNumberBounds(raw, bounds)) {
147
+ this.#lastRejected.delete(name);
148
+ return resolved;
149
+ }
150
+ const attribute = `data-${owner.identifier}-${name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}-value`;
151
+ const literal = owner.element.getAttribute(attribute);
152
+ if (literal === null) {
153
+ this.#lastRejected.delete(name);
154
+ return resolved;
155
+ }
156
+ if (this.#lastRejected.get(name) !== literal) {
157
+ this.#lastRejected.set(name, literal);
158
+ console.warn(
159
+ `Stimeo UI: "${owner.identifier}" has an invalid number Value "${name}" declaration ${JSON.stringify(literal)}; using ${fallback}.`
160
+ );
161
+ }
162
+ return resolved;
163
+ }
164
+ };
165
+
88
166
  function prefersReducedMotion() {
89
167
  return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
90
168
  }
91
169
 
92
- // src/utils/state_regions.ts
93
- var StateRegions = class {
170
+ function sharedRegistry(key, create) {
171
+ const symbol = Symbol.for(key);
172
+ const scope = globalThis;
173
+ const existing = scope[symbol];
174
+ if (existing !== void 0) return existing;
175
+ const registry = create();
176
+ Object.defineProperty(globalThis, symbol, { value: registry });
177
+ return registry;
178
+ }
179
+
180
+ var StateRegions = class _StateRegions {
181
+ static #registry = sharedRegistry(
182
+ "stimeo-ui.state-regions.registry.v1",
183
+ () => ({ stacks: /* @__PURE__ */ new WeakMap(), names: /* @__PURE__ */ new Set() })
184
+ );
94
185
  #whenTrue;
95
186
  #whenFalse;
96
- #taken = /* @__PURE__ */ new WeakSet();
97
- constructor(options) {
187
+ #record;
188
+ constructor(options, owner) {
98
189
  this.#whenTrue = options.whenTrue;
99
190
  this.#whenFalse = options.whenFalse ?? null;
191
+ this.#record = `data-${owner}-hidden-region`;
192
+ _StateRegions.#registry.names.add(this.#record);
100
193
  }
101
- /** Shows the regions inside `host` that belong to `isTrue` and hides the others. */
102
194
  reflect(host, isTrue) {
103
195
  const shown = this.#inside(host, this.#whenTrue());
104
196
  if (!this.#whenFalse) {
@@ -114,51 +206,159 @@ var StateRegions = class {
114
206
  for (const region of shown) this.#write(region, !isTrue);
115
207
  for (const region of hiddenSide) this.#write(region, isTrue);
116
208
  }
117
- /** Writes `hidden` where it moves, noting which regions it takes out of view. */
209
+ release(host) {
210
+ for (const region of [host, ...host.querySelectorAll("*")]) {
211
+ if (!(region instanceof HTMLElement)) continue;
212
+ const stack = this.#held(region);
213
+ const hold = stack && this.#take(stack);
214
+ if (!stack || !hold) continue;
215
+ const owns = region.hidden === hold.written && !this.#covered(region, stack, hold);
216
+ const index = stack.indexOf(hold);
217
+ stack.splice(index, 1);
218
+ const above = stack[index];
219
+ if (above) above.linked &&= hold.linked;
220
+ if (owns) {
221
+ const below = hold.linked ? stack[index - 1] : void 0;
222
+ if (below) {
223
+ if (region.hidden !== below.written) region.hidden = below.written;
224
+ } else if (region.getAttribute("hidden") !== hold.authored) {
225
+ if (hold.authored === null) region.removeAttribute("hidden");
226
+ else region.setAttribute("hidden", hold.authored);
227
+ }
228
+ }
229
+ this.#mark(region, stack);
230
+ }
231
+ }
118
232
  #write(region, hidden) {
119
- if (hidden) this.#taken.add(region);
120
- else this.#taken.delete(region);
121
- if (region.hidden !== hidden) region.hidden = hidden;
233
+ const stack = this.#stack(region);
234
+ const hold = this.#take(stack) ?? this.#acquire(region, stack);
235
+ const shows = !this.#covered(region, stack, hold);
236
+ hold.written = hidden;
237
+ if (shows && region.hidden !== hidden) region.hidden = hidden;
238
+ this.#mark(region, stack);
122
239
  }
123
- /** Returns a region this instance hid; one it never hid keeps what it carries. */
124
240
  #give(region) {
125
- if (this.#taken.delete(region)) region.hidden = false;
241
+ const stack = this.#held(region);
242
+ if (stack && this.#take(stack)?.written) this.#write(region, false);
243
+ }
244
+ #held(region) {
245
+ return _StateRegions.#registry.stacks.has(region) || region.hasAttribute(this.#record) ? this.#stack(region) : void 0;
246
+ }
247
+ #stack(region) {
248
+ const stacks = _StateRegions.#registry.stacks;
249
+ const stack = stacks.get(region) ?? this.#seed(region);
250
+ stacks.set(region, stack);
251
+ return stack;
252
+ }
253
+ #seed(region) {
254
+ const found = [];
255
+ for (const record of _StateRegions.#registry.names) {
256
+ const read = this.#recorded(region, record);
257
+ if (read) found.push({ ...read, record });
258
+ }
259
+ const depth = ({ beneath }) => beneath.filter((name) => found.some((other) => other.record === name)).length;
260
+ return found.sort((one, other) => depth(one) - depth(other)).map(({ record, authored, written, linked }, index) => ({
261
+ instance: null,
262
+ record,
263
+ authored,
264
+ written,
265
+ linked: linked && index > 0
266
+ }));
267
+ }
268
+ #take(stack) {
269
+ const hold = stack.find((held) => held.instance === this) ?? stack.find((held) => held.instance === null && held.record === this.#record);
270
+ if (hold) hold.instance = this;
271
+ return hold;
272
+ }
273
+ #covered(region, stack, hold) {
274
+ return stack.slice(stack.indexOf(hold) + 1).some((above) => above.written === region.hidden);
275
+ }
276
+ #acquire(region, stack) {
277
+ const top = stack.at(-1);
278
+ const over = top && region.hidden === top.written ? top : void 0;
279
+ const hold = {
280
+ instance: this,
281
+ record: this.#record,
282
+ authored: over ? over.authored : region.getAttribute("hidden"),
283
+ written: false,
284
+ linked: over !== void 0
285
+ };
286
+ stack.push(hold);
287
+ return hold;
288
+ }
289
+ #mark(region, stack) {
290
+ const records = /* @__PURE__ */ new Map([[this.#record, null]]);
291
+ stack.forEach((hold, index) => {
292
+ const names = stack.slice(0, index).map((under) => under.record);
293
+ const beneath = [...new Set(names.reverse())].filter((name) => name !== hold.record);
294
+ const marker = hold.linked || beneath.length === 0 ? [] : [false];
295
+ records.set(
296
+ hold.record,
297
+ region.getAttribute("hidden") === hold.authored && beneath.length === 0 && index === stack.length - 1 ? null : JSON.stringify([hold.authored, hold.written, ...marker, ...beneath])
298
+ );
299
+ });
300
+ for (const [name, recorded] of records) {
301
+ if (region.getAttribute(name) === recorded) continue;
302
+ if (recorded === null) region.removeAttribute(name);
303
+ else region.setAttribute(name, recorded);
304
+ }
305
+ }
306
+ #recorded(region, name) {
307
+ try {
308
+ const parsed = JSON.parse(region.getAttribute(name) ?? "null");
309
+ if (!Array.isArray(parsed)) return null;
310
+ const [authored, written, ...rest] = parsed;
311
+ if (authored !== null && typeof authored !== "string") return null;
312
+ const linked = rest[0] !== false;
313
+ const beneath = linked ? rest : rest.slice(1);
314
+ if (!beneath.every((under) => typeof under === "string")) return null;
315
+ return typeof written === "boolean" ? { authored, written, linked, beneath } : null;
316
+ } catch {
317
+ return null;
318
+ }
126
319
  }
127
- /** The declared regions that sit within `host`, which may be the host itself. */
128
320
  #inside(host, regions) {
129
321
  return regions.filter((region) => host.contains(region));
130
322
  }
131
323
  };
132
324
 
133
- // src/controllers/stick_to_bottom_controller.ts
134
325
  var DEFAULT_THRESHOLD = 80;
135
326
  var countElements = (nodes) => {
136
327
  let n = 0;
137
328
  for (const node of nodes) if (node.nodeType === Node.ELEMENT_NODE) n += 1;
138
329
  return n;
139
330
  };
140
- var StickToBottomController = class extends Controller {
331
+ var StickToBottomController = class _StickToBottomController extends Controller {
332
+ #numbers = new NumberValueReader();
141
333
  static targets = ["content", "hasNew"];
142
334
  static values = {
143
335
  threshold: { type: Number, default: DEFAULT_THRESHOLD },
144
336
  behavior: { type: String, default: "auto" },
145
337
  pinOnConnect: { type: Boolean, default: false }
146
338
  };
339
+ static valueConstraints = {
340
+ threshold: NUMBER_BOUNDS.nonNegative
341
+ };
147
342
  static actions = ["scrollToBottom"];
148
343
  static events = ["pin", "new"];
149
344
  #observer = null;
150
- /** The element the append observer currently holds, so a swap can be detected. */
151
345
  #watched = null;
152
- /** Watches for the box a container connected without one is still waiting on. */
153
346
  #layout = new LayoutObserver(() => this.#onLaidOut());
154
347
  #awaitingLayout = false;
155
348
  #connected = false;
156
349
  #pinned = false;
157
350
  #hasNew = false;
158
- /** Owns `hidden` on the regions declared for the has-new state. */
159
- #hasNewRegion = new StateRegions({ whenTrue: () => this.hasNewTargets });
351
+ #hasNewRegion = new StateRegions(
352
+ { whenTrue: () => this.hasNewTargets },
353
+ this.identifier
354
+ );
160
355
  #onScroll = () => this.#updatePinned();
356
+ #morphRender = new MorphRenderWatcher(() => {
357
+ this.#updatePinned();
358
+ this.#setHasNew(this.#hasNew);
359
+ });
161
360
  connect() {
361
+ this.#morphRender.observe(this.element);
162
362
  this.#connected = true;
163
363
  if (this.pinOnConnectValue && this.#measurable()) this.#scrollToBottom("instant");
164
364
  this.#setHasNew(false);
@@ -169,57 +369,31 @@ var StickToBottomController = class extends Controller {
169
369
  if (!this.#measurable()) this.#waitForLayout();
170
370
  }
171
371
  disconnect() {
372
+ this.#morphRender.disconnect();
172
373
  this.#connected = false;
173
374
  this.element.removeEventListener("scroll", this.#onScroll);
174
375
  this.#stopWatching();
175
376
  this.#stopWaitingForLayout();
176
377
  }
177
- /** Moves the append watch onto a `content` target that arrived at runtime. */
178
378
  contentTargetConnected() {
179
379
  this.#syncWatched();
180
380
  }
181
- /** Moves the append watch off a `content` target that left, back onto the container. */
182
381
  contentTargetDisconnected() {
183
382
  this.#syncWatched();
184
383
  }
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
384
  hasNewTargetConnected() {
195
385
  if (!this.#connected) return;
196
386
  this.#hasNewRegion.reflect(this.element, this.#hasNew);
197
387
  }
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
388
  thresholdValueChanged() {
203
389
  if (!this.#connected) return;
204
390
  this.#updatePinned();
205
391
  }
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
392
  scrollToBottom() {
218
393
  this.#scrollToBottom();
219
394
  this.#setHasNew(false);
220
395
  this.#updatePinned();
221
396
  }
222
- /** Follows appended children while pinned; otherwise flags new content. */
223
397
  #onMutations(mutations) {
224
398
  let added = 0;
225
399
  for (const mutation of mutations) added += countElements(mutation.addedNodes);
@@ -231,19 +405,13 @@ var StickToBottomController = class extends Controller {
231
405
  this.dispatch("new", { detail: { count: added } });
232
406
  }
233
407
  }
234
- /**
235
- * Recomputes pinned from the scroll position and reflects it on a transition.
236
- *
237
- * @stimeoRenderRoot
238
- */
239
408
  #updatePinned() {
240
409
  const pinned = this.#isPinned();
241
- if (pinned === this.#pinned) return;
410
+ const changed = pinned !== this.#pinned;
242
411
  this.#pinned = pinned;
243
412
  this.#reflectPinned();
244
- this.dispatch("pin", { detail: { pinned } });
413
+ if (changed) this.dispatch("pin", { detail: { pinned } });
245
414
  }
246
- /** Mirrors the current `#pinned` onto the state hooks (clearing has-new once pinned). */
247
415
  #reflectPinned() {
248
416
  if (this.#pinned) {
249
417
  this.element.setAttribute("data-pinned", "true");
@@ -252,10 +420,6 @@ var StickToBottomController = class extends Controller {
252
420
  this.element.removeAttribute("data-pinned");
253
421
  }
254
422
  }
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
423
  #setHasNew(hasNew) {
260
424
  this.#hasNew = hasNew;
261
425
  if (hasNew) {
@@ -265,61 +429,31 @@ var StickToBottomController = class extends Controller {
265
429
  }
266
430
  this.#hasNewRegion.reflect(this.element, hasNew);
267
431
  }
268
- /** Whether the container currently sits within `threshold` of its bottom. */
269
432
  #isPinned() {
270
433
  if (!this.#measurable()) return false;
271
434
  const el = this.element;
272
435
  return el.scrollHeight - el.clientHeight - el.scrollTop <= this.#threshold;
273
436
  }
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
437
  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
- */
438
+ return this.#safeThreshold;
439
+ }
293
440
  #measurable() {
294
441
  return this.element.clientHeight > 0;
295
442
  }
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
443
  #waitForLayout() {
302
444
  this.#awaitingLayout = true;
303
445
  this.#layout.observe(this.element);
304
446
  }
305
- /** Runs the held decision once the container has the box it was waiting for. */
306
447
  #onLaidOut() {
307
448
  if (!this.#awaitingLayout || !this.#measurable()) return;
308
449
  this.#stopWaitingForLayout();
309
450
  if (this.pinOnConnectValue) this.#scrollToBottom("instant");
310
451
  this.#updatePinned();
311
452
  }
312
- /** Releases the layout watch, whether or not the held decision ever ran. */
313
453
  #stopWaitingForLayout() {
314
454
  this.#awaitingLayout = false;
315
455
  this.#layout.disconnect();
316
456
  }
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
457
  #scrollToBottom(behavior = this.#behavior()) {
324
458
  const top = this.element.scrollHeight;
325
459
  if (typeof this.element.scrollTo === "function") {
@@ -328,17 +462,6 @@ var StickToBottomController = class extends Controller {
328
462
  this.element.scrollTop = top;
329
463
  }
330
464
  }
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
465
  #syncWatched() {
343
466
  if (!this.#connected) return;
344
467
  const next = this.hasContentTarget ? this.contentTarget : this.element;
@@ -349,24 +472,25 @@ var StickToBottomController = class extends Controller {
349
472
  this.#observer = new MutationObserver((mutations) => this.#onMutations(mutations));
350
473
  this.#observer.observe(next, { childList: true });
351
474
  }
352
- /** Releases the append watch and the element it held. */
353
475
  #stopWatching() {
354
476
  this.#observer?.disconnect();
355
477
  this.#observer = null;
356
478
  this.#watched = null;
357
479
  }
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
480
  #behavior() {
365
481
  if (prefersReducedMotion()) return "instant";
366
482
  return this.behaviorValue === "smooth" ? "smooth" : "auto";
367
483
  }
484
+ get #safeThreshold() {
485
+ return this.#numbers.read(
486
+ this,
487
+ "threshold",
488
+ this.thresholdValue,
489
+ _StickToBottomController.values.threshold.default,
490
+ _StickToBottomController.valueConstraints.threshold
491
+ );
492
+ }
368
493
  };
369
494
 
370
495
  export { StickToBottomController };
371
- //# sourceMappingURL=stick_to_bottom_controller.js.map
372
496
  //# sourceMappingURL=stick_to_bottom_controller.js.map