stimeo-ui 0.15.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 +209 -0
  3. data/README.md +128 -1
  4. data/dist/cable/index.js +392 -417
  5. data/dist/controllers/accordion_controller.js +228 -28
  6. data/dist/controllers/alert_dialog_controller.js +862 -221
  7. data/dist/controllers/announcer_controller.js +135 -191
  8. data/dist/controllers/aspect_ratio_controller.js +0 -10
  9. data/dist/controllers/auto_submit_controller.js +295 -110
  10. data/dist/controllers/avatar_controller.js +172 -106
  11. data/dist/controllers/breadcrumb_controller.js +0 -129
  12. data/dist/controllers/bulk_select_controller.js +264 -53
  13. data/dist/controllers/calendar_controller.js +346 -180
  14. data/dist/controllers/carousel_controller.js +587 -300
  15. data/dist/controllers/character_counter_controller.js +271 -143
  16. data/dist/controllers/checkbox_controller.js +77 -75
  17. data/dist/controllers/clipboard_controller.js +187 -108
  18. data/dist/controllers/collapsible_controller.js +374 -108
  19. data/dist/controllers/color_picker_controller.js +183 -127
  20. data/dist/controllers/combobox_controller.js +426 -109
  21. data/dist/controllers/command_palette_controller.js +994 -350
  22. data/dist/controllers/conditional_fields_controller.js +212 -125
  23. data/dist/controllers/confirm_controller.js +939 -245
  24. data/dist/controllers/context_menu_controller.js +275 -127
  25. data/dist/controllers/count_up_controller.js +73 -21
  26. data/dist/controllers/countdown_controller.js +235 -97
  27. data/dist/controllers/currency_input_controller.js +241 -118
  28. data/dist/controllers/data_grid_controller.js +229 -135
  29. data/dist/controllers/date_range_picker_controller.js +359 -165
  30. data/dist/controllers/dialog_controller.js +887 -225
  31. data/dist/controllers/direct_upload_controller.js +80 -142
  32. data/dist/controllers/dirty_form_controller.js +26 -47
  33. data/dist/controllers/dismissible_controller.js +175 -20
  34. data/dist/controllers/drawer_controller.js +963 -336
  35. data/dist/controllers/dropdown_controller.js +342 -85
  36. data/dist/controllers/editable_controller.js +182 -58
  37. data/dist/controllers/empty_state_controller.js +67 -53
  38. data/dist/controllers/file_dropzone_controller.js +426 -229
  39. data/dist/controllers/filter_controller.js +78 -36
  40. data/dist/controllers/flash_controller.js +328 -211
  41. data/dist/controllers/focus_controller.js +654 -239
  42. data/dist/controllers/form_field_controller.js +153 -136
  43. data/dist/controllers/form_validation_controller.js +29 -96
  44. data/dist/controllers/frame_loading_controller.js +263 -198
  45. data/dist/controllers/highlight_controller.js +119 -70
  46. data/dist/controllers/hover_card_controller.js +335 -118
  47. data/dist/controllers/idle_controller.js +316 -65
  48. data/dist/controllers/input_mask_controller.js +116 -61
  49. data/dist/controllers/intersection_controller.js +147 -103
  50. data/dist/controllers/lazy_frame_controller.js +9 -56
  51. data/dist/controllers/listbox_controller.js +413 -153
  52. data/dist/controllers/local_time_controller.js +54 -64
  53. data/dist/controllers/masonry_controller.js +70 -95
  54. data/dist/controllers/menu_controller.js +254 -167
  55. data/dist/controllers/menubar_controller.js +206 -331
  56. data/dist/controllers/meter_controller.js +159 -71
  57. data/dist/controllers/multi_select_controller.js +797 -388
  58. data/dist/controllers/navigation_menu_controller.js +156 -231
  59. data/dist/controllers/nested_form_controller.js +373 -174
  60. data/dist/controllers/network_status_controller.js +241 -45
  61. data/dist/controllers/number_input_controller.js +387 -227
  62. data/dist/controllers/optimistic_controller.js +28 -75
  63. data/dist/controllers/otp_controller.js +344 -196
  64. data/dist/controllers/overflow_indicator_controller.js +167 -93
  65. data/dist/controllers/overflow_menu_controller.js +408 -277
  66. data/dist/controllers/pagination_controller.js +342 -120
  67. data/dist/controllers/password_reveal_controller.js +424 -99
  68. data/dist/controllers/password_strength_controller.js +99 -153
  69. data/dist/controllers/persist_controller.js +252 -96
  70. data/dist/controllers/pointer_drag_controller.js +263 -164
  71. data/dist/controllers/popover_controller.js +267 -94
  72. data/dist/controllers/portal_controller.js +209 -72
  73. data/dist/controllers/preview_guard_controller.js +243 -112
  74. data/dist/controllers/progress_controller.js +132 -59
  75. data/dist/controllers/radio_group_controller.js +138 -133
  76. data/dist/controllers/range_slider_controller.js +245 -112
  77. data/dist/controllers/rating_controller.js +429 -194
  78. data/dist/controllers/read_more_controller.js +185 -47
  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 +294 -118
  83. data/dist/controllers/roving_controller.js +39 -84
  84. data/dist/controllers/scroll_area_controller.js +428 -221
  85. data/dist/controllers/scroll_restore_controller.js +0 -53
  86. data/dist/controllers/scroll_visibility_controller.js +395 -146
  87. data/dist/controllers/scrollspy_controller.js +181 -236
  88. data/dist/controllers/separator_controller.js +288 -132
  89. data/dist/controllers/sidebar_controller.js +1071 -375
  90. data/dist/controllers/skeleton_controller.js +248 -92
  91. data/dist/controllers/slider_controller.js +211 -96
  92. data/dist/controllers/smart_sticky_header_controller.js +163 -54
  93. data/dist/controllers/sortable_controller.js +13 -110
  94. data/dist/controllers/spinner_controller.js +277 -161
  95. data/dist/controllers/step_indicator_controller.js +107 -53
  96. data/dist/controllers/stepper_controller.js +171 -48
  97. data/dist/controllers/stick_to_bottom_controller.js +310 -116
  98. data/dist/controllers/sticky_observer_controller.js +214 -49
  99. data/dist/controllers/submit_once_controller.js +294 -192
  100. data/dist/controllers/switch_controller.js +107 -43
  101. data/dist/controllers/tabs_controller.js +50 -25
  102. data/dist/controllers/tags_input_controller.js +290 -149
  103. data/dist/controllers/textarea_autosize_controller.js +71 -58
  104. data/dist/controllers/theme_controller.js +148 -80
  105. data/dist/controllers/time_picker_controller.js +162 -81
  106. data/dist/controllers/toast_controller.js +365 -193
  107. data/dist/controllers/toggle_group_controller.js +194 -99
  108. data/dist/controllers/toolbar_controller.js +92 -90
  109. data/dist/controllers/tooltip_controller.js +185 -125
  110. data/dist/controllers/transition_controller.js +123 -115
  111. data/dist/controllers/tree_view_controller.js +248 -290
  112. data/dist/index.js +8649 -8572
  113. data/dist/positioning/index.js +148 -45
  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,40 +55,313 @@ 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/controllers/stick_to_bottom_controller.ts
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
+ );
185
+ #whenTrue;
186
+ #whenFalse;
187
+ #record;
188
+ constructor(options, owner) {
189
+ this.#whenTrue = options.whenTrue;
190
+ this.#whenFalse = options.whenFalse ?? null;
191
+ this.#record = `data-${owner}-hidden-region`;
192
+ _StateRegions.#registry.names.add(this.#record);
193
+ }
194
+ reflect(host, isTrue) {
195
+ const shown = this.#inside(host, this.#whenTrue());
196
+ if (!this.#whenFalse) {
197
+ for (const region of shown) this.#write(region, !isTrue);
198
+ return;
199
+ }
200
+ const hiddenSide = this.#inside(host, this.#whenFalse());
201
+ if (shown.length === 0 || hiddenSide.length === 0) {
202
+ for (const region of shown) this.#give(region);
203
+ for (const region of hiddenSide) this.#give(region);
204
+ return;
205
+ }
206
+ for (const region of shown) this.#write(region, !isTrue);
207
+ for (const region of hiddenSide) this.#write(region, isTrue);
208
+ }
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
+ }
232
+ #write(region, 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);
239
+ }
240
+ #give(region) {
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
+ }
319
+ }
320
+ #inside(host, regions) {
321
+ return regions.filter((region) => host.contains(region));
322
+ }
323
+ };
324
+
93
325
  var DEFAULT_THRESHOLD = 80;
94
326
  var countElements = (nodes) => {
95
327
  let n = 0;
96
328
  for (const node of nodes) if (node.nodeType === Node.ELEMENT_NODE) n += 1;
97
329
  return n;
98
330
  };
99
- var StickToBottomController = class extends Controller {
100
- static targets = ["content"];
331
+ var StickToBottomController = class _StickToBottomController extends Controller {
332
+ #numbers = new NumberValueReader();
333
+ static targets = ["content", "hasNew"];
101
334
  static values = {
102
335
  threshold: { type: Number, default: DEFAULT_THRESHOLD },
103
336
  behavior: { type: String, default: "auto" },
104
337
  pinOnConnect: { type: Boolean, default: false }
105
338
  };
339
+ static valueConstraints = {
340
+ threshold: NUMBER_BOUNDS.nonNegative
341
+ };
106
342
  static actions = ["scrollToBottom"];
107
343
  static events = ["pin", "new"];
108
344
  #observer = null;
109
- /** The element the append observer currently holds, so a swap can be detected. */
110
345
  #watched = null;
111
- /** Watches for the box a container connected without one is still waiting on. */
112
346
  #layout = new LayoutObserver(() => this.#onLaidOut());
113
347
  #awaitingLayout = false;
114
348
  #connected = false;
115
349
  #pinned = false;
350
+ #hasNew = false;
351
+ #hasNewRegion = new StateRegions(
352
+ { whenTrue: () => this.hasNewTargets },
353
+ this.identifier
354
+ );
116
355
  #onScroll = () => this.#updatePinned();
356
+ #morphRender = new MorphRenderWatcher(() => {
357
+ this.#updatePinned();
358
+ this.#setHasNew(this.#hasNew);
359
+ });
117
360
  connect() {
361
+ this.#morphRender.observe(this.element);
118
362
  this.#connected = true;
119
363
  if (this.pinOnConnectValue && this.#measurable()) this.#scrollToBottom("instant");
120
- this.element.removeAttribute("data-has-new");
364
+ this.#setHasNew(false);
121
365
  this.#pinned = this.#isPinned();
122
366
  this.#reflectPinned();
123
367
  this.element.addEventListener("scroll", this.#onScroll, { passive: true });
@@ -125,44 +369,31 @@ var StickToBottomController = class extends Controller {
125
369
  if (!this.#measurable()) this.#waitForLayout();
126
370
  }
127
371
  disconnect() {
372
+ this.#morphRender.disconnect();
128
373
  this.#connected = false;
129
374
  this.element.removeEventListener("scroll", this.#onScroll);
130
375
  this.#stopWatching();
131
376
  this.#stopWaitingForLayout();
132
377
  }
133
- /** Moves the append watch onto a `content` target that arrived at runtime. */
134
378
  contentTargetConnected() {
135
379
  this.#syncWatched();
136
380
  }
137
- /** Moves the append watch off a `content` target that left, back onto the container. */
138
381
  contentTargetDisconnected() {
139
382
  this.#syncWatched();
140
383
  }
141
- /**
142
- * Re-derives pinned when the distance that counts as the bottom is changed at runtime
143
- * (a morph that swaps the attribute on a retained element).
144
- */
384
+ hasNewTargetConnected() {
385
+ if (!this.#connected) return;
386
+ this.#hasNewRegion.reflect(this.element, this.#hasNew);
387
+ }
145
388
  thresholdValueChanged() {
146
389
  if (!this.#connected) return;
147
390
  this.#updatePinned();
148
391
  }
149
- /**
150
- * Jumps to the bottom and re-pins (wired to a "new messages" button).
151
- *
152
- * The has-new flag clears on request — the user has acknowledged the arrival — while
153
- * pinned is read back from where the scroll landed: a jump that arrives by the time
154
- * this returns pins immediately, an animated one settles from its own scroll events,
155
- * and a jump the engine cannot honor leaves the container unpinned, so the next append
156
- * flags it again instead of being swallowed by a pinned state that does not hold.
157
- *
158
- * Which of those happens is not this method's to decide — see {@link behaviorValue}.
159
- */
160
392
  scrollToBottom() {
161
393
  this.#scrollToBottom();
162
- this.element.removeAttribute("data-has-new");
394
+ this.#setHasNew(false);
163
395
  this.#updatePinned();
164
396
  }
165
- /** Follows appended children while pinned; otherwise flags new content. */
166
397
  #onMutations(mutations) {
167
398
  let added = 0;
168
399
  for (const mutation of mutations) added += countElements(mutation.addedNodes);
@@ -170,86 +401,59 @@ var StickToBottomController = class extends Controller {
170
401
  if (this.#pinned) {
171
402
  this.#scrollToBottom();
172
403
  } else {
173
- this.element.setAttribute("data-has-new", "true");
404
+ this.#setHasNew(true);
174
405
  this.dispatch("new", { detail: { count: added } });
175
406
  }
176
407
  }
177
- /**
178
- * Recomputes pinned from the scroll position and reflects it on a transition.
179
- *
180
- * @stimeoRenderRoot
181
- */
182
408
  #updatePinned() {
183
409
  const pinned = this.#isPinned();
184
- if (pinned === this.#pinned) return;
410
+ const changed = pinned !== this.#pinned;
185
411
  this.#pinned = pinned;
186
412
  this.#reflectPinned();
187
- this.dispatch("pin", { detail: { pinned } });
413
+ if (changed) this.dispatch("pin", { detail: { pinned } });
188
414
  }
189
- /** Mirrors the current `#pinned` onto the state hooks (clearing has-new once pinned). */
190
415
  #reflectPinned() {
191
416
  if (this.#pinned) {
192
417
  this.element.setAttribute("data-pinned", "true");
193
- this.element.removeAttribute("data-has-new");
418
+ this.#setHasNew(false);
194
419
  } else {
195
420
  this.element.removeAttribute("data-pinned");
196
421
  }
197
422
  }
198
- /** Whether the container currently sits within `threshold` of its bottom. */
423
+ #setHasNew(hasNew) {
424
+ this.#hasNew = hasNew;
425
+ if (hasNew) {
426
+ this.element.setAttribute("data-has-new", "true");
427
+ } else {
428
+ this.element.removeAttribute("data-has-new");
429
+ }
430
+ this.#hasNewRegion.reflect(this.element, hasNew);
431
+ }
199
432
  #isPinned() {
200
433
  if (!this.#measurable()) return false;
201
434
  const el = this.element;
202
435
  return el.scrollHeight - el.clientHeight - el.scrollTop <= this.#threshold;
203
436
  }
204
- /**
205
- * The distance from the bottom that counts as pinned: a finite, non-negative number of
206
- * pixels. Anything else names no distance the container can be at, and settles the
207
- * comparison the same way at every scroll position, so it falls back to the default.
208
- * `Number` reads `"abc"` as `NaN` and every comparison against it is false; a negative
209
- * distance sits below the closest the container ever gets; `Infinity` is never
210
- * exceeded. The first two stop following and flag every append as new, and the last
211
- * never stops following — it takes the reading position the flag exists to protect.
212
- * Zero is a real declaration: it pins at the exact bottom only.
213
- */
214
437
  get #threshold() {
215
- const declared = this.thresholdValue;
216
- return Number.isFinite(declared) && declared >= 0 ? declared : DEFAULT_THRESHOLD;
217
- }
218
- /**
219
- * Whether the container has a box to scroll and to measure. One that is not rendered
220
- * (inside a closed panel) reports every metric as 0, which reads as "already at the
221
- * bottom" — a position describing no layout the user will ever see.
222
- */
438
+ return this.#safeThreshold;
439
+ }
223
440
  #measurable() {
224
441
  return this.element.clientHeight > 0;
225
442
  }
226
- /**
227
- * Holds the pinned decision until the container is laid out — otherwise the panel opens
228
- * at the top while the state claims the bottom, and the appends that arrived meanwhile
229
- * were followed into a box that could not move rather than flagged.
230
- */
231
443
  #waitForLayout() {
232
444
  this.#awaitingLayout = true;
233
445
  this.#layout.observe(this.element);
234
446
  }
235
- /** Runs the held decision once the container has the box it was waiting for. */
236
447
  #onLaidOut() {
237
448
  if (!this.#awaitingLayout || !this.#measurable()) return;
238
449
  this.#stopWaitingForLayout();
239
450
  if (this.pinOnConnectValue) this.#scrollToBottom("instant");
240
451
  this.#updatePinned();
241
452
  }
242
- /** Releases the layout watch, whether or not the held decision ever ran. */
243
453
  #stopWaitingForLayout() {
244
454
  this.#awaitingLayout = false;
245
455
  this.#layout.disconnect();
246
456
  }
247
- /**
248
- * Scrolls to the bottom, clamped by the engine to the maximum scroll offset — which is
249
- * 0 for a container tall enough to hold its whole content, so the jump moves nothing
250
- * there. `behavior` defaults to the configured follow behavior; pass `"instant"` for a
251
- * jump that must not animate.
252
- */
253
457
  #scrollToBottom(behavior = this.#behavior()) {
254
458
  const top = this.element.scrollHeight;
255
459
  if (typeof this.element.scrollTo === "function") {
@@ -258,17 +462,6 @@ var StickToBottomController = class extends Controller {
258
462
  this.element.scrollTop = top;
259
463
  }
260
464
  }
261
- /**
262
- * Points the append watch at the current `content` target, or at the container when
263
- * there is none. Re-resolved whenever that target changes, so a swap does not leave the
264
- * observer holding a detached node whose appends nobody sees.
265
- *
266
- * Stimulus runs the target callbacks outside the connected window too — before
267
- * `connect()` for a target already in the DOM, and after `disconnect()` while the
268
- * element is torn down — where this would arm an observer nothing releases. Re-syncing
269
- * to the target already held is left alone, so an arrival still in flight is not
270
- * dropped with the observer that was about to deliver it.
271
- */
272
465
  #syncWatched() {
273
466
  if (!this.#connected) return;
274
467
  const next = this.hasContentTarget ? this.contentTarget : this.element;
@@ -279,24 +472,25 @@ var StickToBottomController = class extends Controller {
279
472
  this.#observer = new MutationObserver((mutations) => this.#onMutations(mutations));
280
473
  this.#observer.observe(next, { childList: true });
281
474
  }
282
- /** Releases the append watch and the element it held. */
283
475
  #stopWatching() {
284
476
  this.#observer?.disconnect();
285
477
  this.#observer = null;
286
478
  this.#watched = null;
287
479
  }
288
- /**
289
- * The behavior a follow-scroll runs with. `"auto"` is **not** a request to arrive at
290
- * once: it hands the decision to the element's computed `scroll-behavior`, so a
291
- * consumer stylesheet saying `smooth` animates these scrolls too. Only `"instant"`
292
- * overrides that CSS, which is why reduced motion and the `pinOnConnect` jump name it.
293
- */
294
480
  #behavior() {
295
481
  if (prefersReducedMotion()) return "instant";
296
482
  return this.behaviorValue === "smooth" ? "smooth" : "auto";
297
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
+ }
298
493
  };
299
494
 
300
495
  export { StickToBottomController };
301
- //# sourceMappingURL=stick_to_bottom_controller.js.map
302
496
  //# sourceMappingURL=stick_to_bottom_controller.js.map