stimeo-ui 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,26 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/reset_before_cache_controller.ts
4
+ var STATELESS_INPUT_TYPES = /* @__PURE__ */ new Set(["hidden", "submit", "reset", "button", "image"]);
5
+ function restoreField(element) {
6
+ if (element instanceof HTMLTextAreaElement) {
7
+ element.value = element.defaultValue;
8
+ return;
9
+ }
10
+ if (element instanceof HTMLSelectElement) {
11
+ for (const option of element.options) option.selected = option.hasAttribute("selected");
12
+ return;
13
+ }
14
+ if (element instanceof HTMLInputElement) {
15
+ if (element.type === "checkbox" || element.type === "radio") {
16
+ element.checked = element.defaultChecked;
17
+ } else if (element.type === "file") {
18
+ element.value = "";
19
+ } else if (!STATELESS_INPUT_TYPES.has(element.type)) {
20
+ element.value = element.defaultValue;
21
+ }
22
+ }
23
+ }
4
24
  var ResetBeforeCacheController = class extends Controller {
5
25
  static values = {
6
26
  scope: { type: String, default: "" },
@@ -8,8 +28,32 @@ var ResetBeforeCacheController = class extends Controller {
8
28
  };
9
29
  static actions = ["reset"];
10
30
  static events = ["reset", "request"];
31
+ /** The `scope` declaration after validation; empty when it cannot be parsed. */
32
+ #scopeSelector = "";
11
33
  /** Runs the reset just before Turbo caches the snapshot. */
12
34
  #onBeforeCache = () => this.reset();
35
+ /**
36
+ * Validates the scope declaration once, keeping only a selector the engine can
37
+ * read.
38
+ *
39
+ * A selector reads back as an ordinary string, so a malformed one survives
40
+ * until it is handed to the DOM — and this part runs from a single listener
41
+ * whose whole job is to keep a cached page from freezing. Falling back to the
42
+ * default keeps that job running with a visible, findable result instead of
43
+ * silently taking the sweep down.
44
+ */
45
+ scopeValueChanged() {
46
+ const selector = this.scopeValue;
47
+ if (selector.length > 0) {
48
+ try {
49
+ this.element.matches(selector);
50
+ this.#scopeSelector = selector;
51
+ return;
52
+ } catch {
53
+ }
54
+ }
55
+ this.#scopeSelector = "";
56
+ }
13
57
  connect() {
14
58
  document.addEventListener("turbo:before-cache", this.#onBeforeCache);
15
59
  }
@@ -20,6 +64,10 @@ var ResetBeforeCacheController = class extends Controller {
20
64
  * Resets transient UI within scope to its initial state. Asks controllers to
21
65
  * close (via `request`) first, then applies the declarative `data-reset-*` cleanup,
22
66
  * and finally emits `reset`. Safe to call any number of times (idempotent).
67
+ *
68
+ * `dispatchReset` decides only whether the `request` ask goes out; the cleanup
69
+ * and the closing `reset` run either way. Both the listener and this action
70
+ * reach the same sweep, so `reset` is emitted for a manual call too.
23
71
  */
24
72
  reset() {
25
73
  const root = this.#scopeRoot();
@@ -38,9 +86,7 @@ var ResetBeforeCacheController = class extends Controller {
38
86
  if (element instanceof HTMLFormElement) element.reset();
39
87
  }
40
88
  for (const element of root.querySelectorAll("[data-reset-value]")) {
41
- if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement || element instanceof HTMLSelectElement) {
42
- element.value = "";
43
- }
89
+ restoreField(element);
44
90
  }
45
91
  for (const element of root.querySelectorAll("[data-reset-hidden]")) {
46
92
  element.hidden = true;
@@ -52,8 +98,8 @@ var ResetBeforeCacheController = class extends Controller {
52
98
  }
53
99
  /** The scan root: a `scope` descendant when set, else the controller element. */
54
100
  #scopeRoot() {
55
- if (!this.scopeValue) return this.element;
56
- return this.element.querySelector(this.scopeValue) ?? this.element;
101
+ if (!this.#scopeSelector) return this.element;
102
+ return this.element.querySelector(this.#scopeSelector) ?? this.element;
57
103
  }
58
104
  };
59
105
 
@@ -8,6 +8,39 @@ function isReservedArrowChord(event, allow = []) {
8
8
  return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
9
9
  }
10
10
 
11
+ // src/utils/microtask_coalescer.ts
12
+ var MicrotaskCoalescer = class {
13
+ #run;
14
+ #queued = false;
15
+ #active = false;
16
+ #generation = 0;
17
+ /** @param run - the single reconciliation pass, invoked at most once per batch. */
18
+ constructor(run) {
19
+ this.#run = run;
20
+ }
21
+ /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
22
+ activate() {
23
+ this.#active = true;
24
+ }
25
+ /** Closes the window and drops any pending pass; call from `disconnect()`. */
26
+ cancel() {
27
+ this.#active = false;
28
+ this.#queued = false;
29
+ this.#generation += 1;
30
+ }
31
+ /** Requests one pass after the batch settles. Idempotent; inert outside the window. */
32
+ schedule() {
33
+ if (!this.#active || this.#queued) return;
34
+ this.#queued = true;
35
+ const generation = this.#generation;
36
+ queueMicrotask(() => {
37
+ if (generation !== this.#generation || !this.#queued || !this.#active) return;
38
+ this.#queued = false;
39
+ this.#run();
40
+ });
41
+ }
42
+ };
43
+
11
44
  // src/utils/before_cache_reset.ts
12
45
  var BeforeCacheReset = class _BeforeCacheReset {
13
46
  /** Every subscribed instance, iterated by the one shared document listener. */
@@ -69,26 +102,34 @@ var TabindexLoan = class {
69
102
  };
70
103
 
71
104
  // src/controllers/resizable_controller.ts
105
+ var DEFAULT_MIN = 0;
106
+ var DEFAULT_MAX = 100;
107
+ var DEFAULT_VALUE = 50;
108
+ var DEFAULT_STEP = 1;
72
109
  var ResizableController = class extends Controller {
73
110
  static targets = ["primary", "secondary", "separator"];
74
111
  static values = {
75
- min: { type: Number, default: 0 },
76
- max: { type: Number, default: 100 },
77
- step: { type: Number, default: 1 },
78
- value: { type: Number, default: 50 }
112
+ min: { type: Number, default: DEFAULT_MIN },
113
+ max: { type: Number, default: DEFAULT_MAX },
114
+ step: { type: Number, default: DEFAULT_STEP },
115
+ value: { type: Number, default: DEFAULT_VALUE }
79
116
  };
80
117
  static actions = ["onKeydown", "onPointerDown", "toggle"];
81
118
  static events = ["change"];
82
- /** The value held before the current collapse, restored when toggling back. */
83
- #valueBeforeCollapse = 50;
119
+ /** Where the divider sat before the current collapse; `null` when unknown. */
120
+ #valueBeforeCollapse = null;
84
121
  /** Aborts in-progress pointer-drag listeners when the drag ends or on teardown. */
85
122
  #dragAbort = null;
86
123
  /** `tabindex` lent to a pane so `F6` can put focus on it; panes carry none. */
87
124
  #paneTabindex = new TabindexLoan();
88
125
  /** Releases the pane cycle listener bound in {@link connect}. */
89
126
  #cycleAbort = null;
127
+ /** Folds the range and position inputs of one morph batch into a single paint. */
128
+ #repaint = new MicrotaskCoalescer(() => this.#render());
90
129
  connect() {
91
- this.#clampAndSync();
130
+ this.element.removeAttribute("data-dragging");
131
+ this.#repaint.activate();
132
+ this.#render();
92
133
  this.#cycleAbort = new AbortController();
93
134
  this.element.addEventListener("keydown", this.#onCycleKeydown, {
94
135
  signal: this.#cycleAbort.signal
@@ -100,17 +141,19 @@ var ResizableController = class extends Controller {
100
141
  this.#dragAbort = null;
101
142
  this.#cycleAbort?.abort();
102
143
  this.#cycleAbort = null;
144
+ this.#repaint.cancel();
103
145
  this.#paneTabindex.returnAll();
146
+ this.element.removeAttribute("data-dragging");
104
147
  }
105
148
  /** Moves focus to the next pane on `F6`, wrapping; entering at the first one. */
106
149
  #onCycleKeydown = (event) => {
107
150
  if (event.key !== "F6") return;
151
+ if (event.isComposing) return;
108
152
  if (event.defaultPrevented) return;
109
153
  if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return;
110
154
  const panes = [];
111
155
  if (this.hasPrimaryTarget) panes.push(this.primaryTarget);
112
156
  if (this.hasSecondaryTarget) panes.push(this.secondaryTarget);
113
- if (panes.length === 0) return;
114
157
  const target = event.target;
115
158
  const current = panes.findIndex((pane) => target instanceof Node && pane.contains(target));
116
159
  const next = panes[(current + 1) % panes.length];
@@ -119,12 +162,21 @@ var ResizableController = class extends Controller {
119
162
  this.#paneTabindex.lend(next);
120
163
  next.focus();
121
164
  };
122
- /**
123
- * Stimulus lifecycle callback when the valueValue changes.
124
- * Keeps CSS fractions and ARIA status completely aligned.
125
- */
165
+ /** Re-renders when application code (or a Turbo morph) changes `value` at runtime. */
126
166
  valueValueChanged() {
127
- this.#clampAndSync();
167
+ this.#repaint.schedule();
168
+ }
169
+ /** Re-renders when application code (or a Turbo morph) changes `min` at runtime. */
170
+ minValueChanged() {
171
+ this.#repaint.schedule();
172
+ }
173
+ /** Re-renders when application code (or a Turbo morph) changes `max` at runtime. */
174
+ maxValueChanged() {
175
+ this.#repaint.schedule();
176
+ }
177
+ /** Re-publishes range and position onto a separator swapped in after connect. */
178
+ separatorTargetConnected() {
179
+ this.#repaint.schedule();
128
180
  }
129
181
  /** Starts active pointer drag tracking and locks capture. */
130
182
  onPointerDown(event) {
@@ -145,44 +197,44 @@ var ResizableController = class extends Controller {
145
197
  onKeydown(event) {
146
198
  if (isReservedArrowChord(event)) return;
147
199
  if (!this.hasSeparatorTarget) return;
148
- const orientation = this.separatorTarget.getAttribute("aria-orientation") || "vertical";
149
- const isVertical = orientation === "vertical";
200
+ const { min, max } = this.#range;
201
+ const isVertical = this.#isVertical;
150
202
  let handled = true;
151
- let nextValue = this.valueValue;
203
+ let nextValue = this.#position;
152
204
  switch (event.key) {
153
205
  case "ArrowLeft":
154
206
  if (isVertical) {
155
- nextValue -= this.stepValue;
207
+ nextValue -= this.#step;
156
208
  } else {
157
209
  handled = false;
158
210
  }
159
211
  break;
160
212
  case "ArrowRight":
161
213
  if (isVertical) {
162
- nextValue += this.stepValue;
214
+ nextValue += this.#step;
163
215
  } else {
164
216
  handled = false;
165
217
  }
166
218
  break;
167
219
  case "ArrowUp":
168
220
  if (!isVertical) {
169
- nextValue -= this.stepValue;
221
+ nextValue -= this.#step;
170
222
  } else {
171
223
  handled = false;
172
224
  }
173
225
  break;
174
226
  case "ArrowDown":
175
227
  if (!isVertical) {
176
- nextValue += this.stepValue;
228
+ nextValue += this.#step;
177
229
  } else {
178
230
  handled = false;
179
231
  }
180
232
  break;
181
233
  case "Home":
182
- nextValue = this.minValue;
234
+ nextValue = min;
183
235
  break;
184
236
  case "End":
185
- nextValue = this.maxValue;
237
+ nextValue = max;
186
238
  break;
187
239
  case "Enter":
188
240
  event.preventDefault();
@@ -194,65 +246,86 @@ var ResizableController = class extends Controller {
194
246
  }
195
247
  if (handled) {
196
248
  event.preventDefault();
197
- this.valueValue = Math.max(this.minValue, Math.min(nextValue, this.maxValue));
198
- this.#clampAndSync();
249
+ this.valueValue = Math.max(min, Math.min(nextValue, max));
250
+ this.#render();
199
251
  this.#dispatchChange();
200
252
  }
201
253
  }
202
- /** Double-click or Enter to collapse/restore pane to min/max levels. */
254
+ /** Double-click or Enter to collapse the primary pane, or put it back. */
203
255
  toggle() {
204
- const threshold = this.minValue + (this.maxValue - this.minValue) / 2;
205
- if (this.valueValue > this.minValue) {
206
- this.#valueBeforeCollapse = this.valueValue;
207
- this.valueValue = this.minValue;
256
+ const { min, max } = this.#range;
257
+ if (this.#position > min) {
258
+ this.#valueBeforeCollapse = this.#position;
259
+ this.valueValue = min;
208
260
  } else {
209
- this.valueValue = this.#valueBeforeCollapse >= threshold ? this.#valueBeforeCollapse : this.maxValue;
261
+ this.valueValue = this.#valueBeforeCollapse ?? max;
262
+ this.#valueBeforeCollapse = null;
210
263
  }
211
- this.#clampAndSync();
264
+ this.#render();
212
265
  this.#dispatchChange();
213
266
  }
214
267
  #onPointerMove = (event) => {
215
268
  if (!this.hasSeparatorTarget) return;
216
269
  const rect = this.element.getBoundingClientRect();
217
- const orientation = this.separatorTarget.getAttribute("aria-orientation") || "vertical";
218
- const isVertical = orientation === "vertical";
219
- let fraction = 0.5;
220
- if (isVertical) {
221
- fraction = (event.clientX - rect.left) / rect.width;
222
- } else {
223
- fraction = (event.clientY - rect.top) / rect.height;
224
- }
225
- fraction = Math.max(0, Math.min(fraction, 1));
226
- const percent = Math.round(fraction * 100);
227
- this.valueValue = Math.max(this.minValue, Math.min(percent, this.maxValue));
228
- this.#clampAndSync();
270
+ const raw = this.#isVertical ? (event.clientX - rect.left) / rect.width : (event.clientY - rect.top) / rect.height;
271
+ const fraction = Number.isFinite(raw) ? Math.max(0, Math.min(raw, 1)) : 0;
272
+ const { min, max } = this.#range;
273
+ this.valueValue = Math.max(min, Math.min(Math.round(fraction * 100), max));
274
+ this.#render();
229
275
  };
230
276
  #onPointerUp = (event) => {
231
- if (!this.hasSeparatorTarget) return;
232
- const separator = this.separatorTarget;
233
- separator.releasePointerCapture(event.pointerId);
234
277
  this.element.removeAttribute("data-dragging");
235
278
  this.#dragAbort?.abort();
236
279
  this.#dragAbort = null;
280
+ if (this.hasSeparatorTarget) {
281
+ this.separatorTarget.releasePointerCapture(event.pointerId);
282
+ }
237
283
  this.#dispatchChange();
238
284
  };
239
- #clampAndSync() {
240
- const clamped = Math.max(this.minValue, Math.min(this.valueValue, this.maxValue));
241
- if (this.valueValue !== clamped) {
242
- this.valueValue = clamped;
285
+ /**
286
+ * Publishes the position: the fraction consumer CSS multiplies a pane by, and
287
+ * the range assistive tech reads off the separator. The clamped position is
288
+ * written back to the Value so it, ARIA, CSS, and dispatched events agree.
289
+ *
290
+ * @stimeoRenderRoot
291
+ */
292
+ #render() {
293
+ const { min, max } = this.#range;
294
+ const position = this.#position;
295
+ if (this.valueValue !== position) {
296
+ this.valueValue = position;
243
297
  }
244
- const fraction = clamped / 100;
245
- this.element.style.setProperty("--stimeo--resizable-fraction", String(fraction));
298
+ this.element.style.setProperty("--stimeo--resizable-fraction", String(position / 100));
246
299
  if (this.hasSeparatorTarget) {
247
- this.separatorTarget.setAttribute("aria-valuenow", String(clamped));
248
- this.separatorTarget.setAttribute("aria-valuemin", String(this.minValue));
249
- this.separatorTarget.setAttribute("aria-valuemax", String(this.maxValue));
300
+ this.separatorTarget.setAttribute("aria-valuenow", String(position));
301
+ this.separatorTarget.setAttribute("aria-valuemin", String(min));
302
+ this.separatorTarget.setAttribute("aria-valuemax", String(max));
250
303
  }
251
304
  }
252
305
  #dispatchChange() {
253
306
  const fraction = this.valueValue / 100;
254
307
  this.dispatch("change", { detail: { value: this.valueValue, fraction } });
255
308
  }
309
+ /** The declared range after validation; an unreadable bound uses its default. */
310
+ get #range() {
311
+ const min = Number.isFinite(this.minValue) ? this.minValue : DEFAULT_MIN;
312
+ const max = Number.isFinite(this.maxValue) ? this.maxValue : DEFAULT_MAX;
313
+ return { min, max: Math.max(min, max) };
314
+ }
315
+ /** The declared position, clamped into the validated range. */
316
+ get #position() {
317
+ const { min, max } = this.#range;
318
+ const value = Number.isFinite(this.valueValue) ? this.valueValue : DEFAULT_VALUE;
319
+ return Math.max(min, Math.min(value, max));
320
+ }
321
+ /** The keyboard increment; a non-positive or unreadable one uses the default. */
322
+ get #step() {
323
+ return Number.isFinite(this.stepValue) && this.stepValue > 0 ? this.stepValue : DEFAULT_STEP;
324
+ }
325
+ /** Whether the divider runs vertically; a separator that does not say is horizontal. */
326
+ get #isVertical() {
327
+ return this.hasSeparatorTarget && this.separatorTarget.getAttribute("aria-orientation") === "vertical";
328
+ }
256
329
  };
257
330
 
258
331
  export { ResizableController };
@@ -222,7 +222,7 @@ var SpinnerController = class extends Controller {
222
222
  timeout: { type: Number, default: 0 }
223
223
  };
224
224
  static actions = ["start", "stop"];
225
- static events = ["hide", "show", "timeout"];
225
+ static events = ["hide", "show", "timeout", "reconcile"];
226
226
  #timers = new SafeTimeout();
227
227
  #floor = new MinDurationFloor(this.#timers);
228
228
  #gate = new DetachGate();
@@ -326,16 +326,18 @@ var SpinnerController = class extends Controller {
326
326
  * Returns the loading state to idle for the snapshot Turbo is about to take,
327
327
  * so a page reached with the Back button is not restored mid-load with a
328
328
  * spinner nothing can stop. State only: `data-state`, the indicator's `hidden`,
329
- * and `aria-busy`. No `hide` is dispatched — the load was never observed to
330
- * finish, and a snapshot rewind is not a lifecycle event the consumer can act
331
- * on. The live page keeps its timers, so a navigation that never completes
332
- * leaves the running cycle intact.
329
+ * and `aria-busy`. No `hide` is dispatched — that would claim the load finished.
330
+ * The rewind reports itself as `reconcile` instead, so a consumer painting from
331
+ * `show` can drop it before the snapshot is taken. The live page keeps its
332
+ * timers, so a navigation that never completes leaves the running cycle intact.
333
333
  */
334
334
  #rewindForCache() {
335
+ const running = this.element.getAttribute("data-state") !== "idle";
335
336
  this.#cancelTimeout();
336
337
  this.#setBusy(false);
337
338
  if (this.hasIndicatorTarget) this.indicatorTarget.hidden = true;
338
339
  this.element.setAttribute("data-state", "idle");
340
+ if (running) this.dispatch("reconcile", { detail: {} });
339
341
  }
340
342
  /** Reflects busy state onto the controlled region (if present). */
341
343
  #setBusy(busy) {
@@ -203,7 +203,7 @@ var SubmitOnceController = class extends Controller {
203
203
  restoreFocus: { type: Boolean, default: false }
204
204
  };
205
205
  static actions = ["cancel", "finish", "start"];
206
- static events = ["start", "end"];
206
+ static events = ["start", "end", "reconcile"];
207
207
  #timers = new SafeTimeout();
208
208
  #gate = new DetachGate();
209
209
  #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
@@ -419,7 +419,9 @@ var SubmitOnceController = class extends Controller {
419
419
  }
420
420
  /** Keeps Turbo's cached snapshot idle, without events, announcements, or focus moves. */
421
421
  #rewindForCache() {
422
+ const forms = [...this.#sessions.keys()];
422
423
  this.#abandonSessions();
424
+ if (forms.length > 0) this.dispatch("reconcile", { detail: { forms } });
423
425
  }
424
426
  /** Tears down a true detach; an in-page move is canceled by the next `connect()`. */
425
427
  #teardown() {
@@ -1,5 +1,63 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
 
3
+ // src/controllers/textarea_autosize_controller.ts
4
+
5
+ // src/utils/layout_observer.ts
6
+ var LayoutObserver = class {
7
+ #callback;
8
+ #resizeObserverFactory;
9
+ #resizeObserver = null;
10
+ #observingViewport = false;
11
+ /** Stable bound handler so add/removeEventListener target the same reference. */
12
+ #handleViewportResize = () => {
13
+ this.#callback();
14
+ };
15
+ constructor(callback, options = {}) {
16
+ this.#callback = callback;
17
+ this.#resizeObserverFactory = options.resizeObserverFactory ?? (typeof ResizeObserver === "undefined" ? null : (cb) => new ResizeObserver(cb));
18
+ }
19
+ /**
20
+ * Starts observing an element's size. Repeated calls observe additional
21
+ * elements through the same shared observer. No-ops when no
22
+ * `ResizeObserver` implementation is available.
23
+ */
24
+ observe(element) {
25
+ if (!this.#resizeObserverFactory) return;
26
+ if (!this.#resizeObserver) {
27
+ this.#resizeObserver = this.#resizeObserverFactory(() => {
28
+ this.#callback();
29
+ });
30
+ }
31
+ this.#resizeObserver.observe(element);
32
+ }
33
+ /** Stops observing a single element while leaving any others in place. */
34
+ unobserve(element) {
35
+ this.#resizeObserver?.unobserve(element);
36
+ }
37
+ /** Starts observing viewport resizes. Idempotent: the listener is added once. */
38
+ observeViewport() {
39
+ if (this.#observingViewport) return;
40
+ this.#observingViewport = true;
41
+ window.addEventListener("resize", this.#handleViewportResize);
42
+ }
43
+ /** Stops observing viewport resizes without affecting element observation. */
44
+ unobserveViewport() {
45
+ if (!this.#observingViewport) return;
46
+ this.#observingViewport = false;
47
+ window.removeEventListener("resize", this.#handleViewportResize);
48
+ }
49
+ /**
50
+ * Releases every observation: disconnects the {@link ResizeObserver} and
51
+ * removes the viewport listener. Safe to call multiple times. Call this from a
52
+ * controller's `disconnect()`.
53
+ */
54
+ disconnect() {
55
+ this.#resizeObserver?.disconnect();
56
+ this.#resizeObserver = null;
57
+ this.unobserveViewport();
58
+ }
59
+ };
60
+
3
61
  // src/controllers/textarea_autosize_controller.ts
4
62
  function px(value) {
5
63
  const n = Number.parseFloat(value);
@@ -13,18 +71,69 @@ var TextareaAutosizeController = class extends Controller {
13
71
  static actions = ["resize"];
14
72
  static events = ["resize"];
15
73
  #lastHeight = -1;
16
- #onInput = () => {
74
+ #lastWidth = -1;
75
+ #started = false;
76
+ #pending = false;
77
+ #fonts = null;
78
+ /**
79
+ * Content triggers arriving while the box is collapsed (`display: none`
80
+ * ancestors) are deferred: `scrollHeight` is 0 there, so measuring would
81
+ * clamp the kept height to the `minRows` floor and dispatch a bogus
82
+ * `resize`. The next report from a rendered box flushes the deferral.
83
+ */
84
+ #remeasure = () => {
85
+ if (this.element.clientWidth === 0) {
86
+ this.#pending = true;
87
+ return;
88
+ }
17
89
  this.resize();
18
90
  };
91
+ /**
92
+ * Re-measures when the content width changed (re-wrapping, including a
93
+ * horizontal padding change under `border-box`), the applied inline height
94
+ * was stripped (a Turbo morph syncing attributes from server HTML that has no
95
+ * `style`), or a content trigger was deferred while the box was collapsed.
96
+ * This instance's own height writes match none of these signals, so they
97
+ * cannot re-enter; an authored inline height (e.g. a user dragging the native
98
+ * resize handle) is left alone until the next content trigger. Reports from a
99
+ * still-collapsed box are skipped (nothing is measurable there).
100
+ */
101
+ #layout = new LayoutObserver(() => {
102
+ if (this.element.clientWidth === 0) return;
103
+ const width = this.#contentWidth();
104
+ const stripped = this.element.style.height === "";
105
+ if (width === this.#lastWidth && !stripped && !this.#pending) return;
106
+ this.#lastWidth = width;
107
+ this.resize();
108
+ });
19
109
  connect() {
20
- this.element.addEventListener("input", this.#onInput);
110
+ this.#lastHeight = Math.round(px(this.element.style.height));
111
+ this.#lastWidth = this.#contentWidth();
112
+ this.element.addEventListener("input", this.#remeasure);
113
+ this.element.addEventListener("change", this.#remeasure);
114
+ this.#layout.observe(this.element);
115
+ this.#bindFonts();
116
+ this.#started = true;
21
117
  this.resize();
22
118
  }
23
119
  disconnect() {
24
- this.element.removeEventListener("input", this.#onInput);
120
+ this.#started = false;
121
+ this.element.removeEventListener("input", this.#remeasure);
122
+ this.element.removeEventListener("change", this.#remeasure);
123
+ this.#layout.disconnect();
124
+ this.#unbindFonts();
125
+ }
126
+ /** Re-clamps when application code or a Turbo morph changes `minRows`. */
127
+ minRowsValueChanged() {
128
+ if (this.#started) this.#remeasure();
129
+ }
130
+ /** Re-clamps when application code or a Turbo morph changes `maxRows`. */
131
+ maxRowsValueChanged() {
132
+ if (this.#started) this.#remeasure();
25
133
  }
26
134
  /** Re-measures the content and applies the clamped height. */
27
135
  resize() {
136
+ this.#pending = false;
28
137
  const el = this.element;
29
138
  const style = window.getComputedStyle(el);
30
139
  const lineHeight = this.#lineHeight(style);
@@ -58,6 +167,11 @@ var TextareaAutosizeController = class extends Controller {
58
167
  this.dispatch("resize", { detail: { height, rows } });
59
168
  }
60
169
  }
170
+ /** Content-box width — the wrapping input, unaffected by this instance's height writes. */
171
+ #contentWidth() {
172
+ const style = window.getComputedStyle(this.element);
173
+ return Math.max(0, this.element.clientWidth - px(style.paddingLeft) - px(style.paddingRight));
174
+ }
61
175
  /** Resolved line height, falling back to ~1.2× font-size when `normal`. */
62
176
  #lineHeight(style) {
63
177
  const lh = px(style.lineHeight);
@@ -65,6 +179,20 @@ var TextareaAutosizeController = class extends Controller {
65
179
  const fontSize = px(style.fontSize);
66
180
  return fontSize > 0 ? fontSize * 1.2 : 16;
67
181
  }
182
+ /** Subscribes to font completion when the current engine exposes `document.fonts`. */
183
+ #bindFonts() {
184
+ const fonts = this.element.ownerDocument.fonts;
185
+ if (!fonts) return;
186
+ this.#fonts = fonts;
187
+ fonts.addEventListener("loadingdone", this.#remeasure);
188
+ fonts.addEventListener("loadingerror", this.#remeasure);
189
+ }
190
+ /** Removes the font completion subscriptions. */
191
+ #unbindFonts() {
192
+ this.#fonts?.removeEventListener("loadingdone", this.#remeasure);
193
+ this.#fonts?.removeEventListener("loadingerror", this.#remeasure);
194
+ this.#fonts = null;
195
+ }
68
196
  };
69
197
 
70
198
  export { TextareaAutosizeController };