stimeo-ui 0.8.0 → 0.10.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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +163 -0
  3. data/dist/controllers/alert_dialog_controller.js +17 -7
  4. data/dist/controllers/announcer_controller.js +1 -1
  5. data/dist/controllers/bulk_select_controller.js +139 -28
  6. data/dist/controllers/clipboard_controller.js +102 -20
  7. data/dist/controllers/collapsible_controller.js +1 -1
  8. data/dist/controllers/color_picker_controller.js +180 -43
  9. data/dist/controllers/command_palette_controller.js +15 -5
  10. data/dist/controllers/confirm_controller.js +15 -5
  11. data/dist/controllers/countdown_controller.js +1 -1
  12. data/dist/controllers/data_grid_controller.js +151 -25
  13. data/dist/controllers/dialog_controller.js +15 -5
  14. data/dist/controllers/direct_upload_controller.js +1 -5
  15. data/dist/controllers/drawer_controller.js +17 -7
  16. data/dist/controllers/editable_controller.js +83 -30
  17. data/dist/controllers/filter_controller.js +32 -1
  18. data/dist/controllers/focus_controller.js +45 -7
  19. data/dist/controllers/form_field_controller.js +1 -1
  20. data/dist/controllers/masonry_controller.js +129 -17
  21. data/dist/controllers/menubar_controller.js +2 -2
  22. data/dist/controllers/multi_select_controller.js +1 -1
  23. data/dist/controllers/otp_controller.js +29 -16
  24. data/dist/controllers/overflow_menu_controller.js +2 -2
  25. data/dist/controllers/password_reveal_controller.js +102 -3
  26. data/dist/controllers/password_strength_controller.js +287 -42
  27. data/dist/controllers/reset_before_cache_controller.js +51 -5
  28. data/dist/controllers/resizable_controller.js +128 -55
  29. data/dist/controllers/scroll_restore_controller.js +127 -27
  30. data/dist/controllers/scroll_visibility_controller.js +167 -14
  31. data/dist/controllers/sidebar_controller.js +15 -5
  32. data/dist/controllers/step_indicator_controller.js +0 -5
  33. data/dist/controllers/theme_controller.js +151 -40
  34. data/dist/controllers/toast_controller.js +2 -2
  35. data/dist/controllers/transition_controller.js +79 -15
  36. data/dist/index.js +1612 -529
  37. data/dist/positioning/index.js +84 -25
  38. data/lib/stimeo/ui/version.rb +1 -1
  39. metadata +2 -2
@@ -310,10 +310,6 @@ var DirectUploadController = class extends Controller {
310
310
  this.#rows.delete(id);
311
311
  }
312
312
  }
313
- /**
314
- * Returns the widget to its pre-upload state just before Turbo caches the
315
- * page, so the snapshot never replays rows for uploads that cannot resume.
316
- */
317
313
  /**
318
314
  * Rewinds for the snapshot and reports what that discarded. An upload in flight
319
315
  * cannot survive the navigation, so a consumer mirroring the rows would keep a
@@ -352,7 +348,7 @@ var DirectUploadController = class extends Controller {
352
348
  #detail(event) {
353
349
  return event.detail ?? {};
354
350
  }
355
- /** The file name every ActiveStorage `direct-upload:*` event carries. */
351
+ /** The event's file name, or `""` when it carries none. */
356
352
  #name(detail) {
357
353
  return detail.file?.name ?? "";
358
354
  }
@@ -315,15 +315,25 @@ var FocusTrap = class {
315
315
  * content cannot be focused or reached by assistive technology, honoring the
316
316
  * `aria-modal="true"` contract. An element that was *already* `inert` is left
317
317
  * untracked so `#releaseBackground` does not wrongly clear it.
318
+ *
319
+ * The walk climbs from the container to `body` and inerts each ancestor's other
320
+ * children. Scanning only `body`'s children would skip the branch the container
321
+ * sits in — everything beside it inside that branch is background too, and a
322
+ * nested container is the ordinary case.
318
323
  */
319
324
  #isolateBackground() {
320
325
  const container = this.#getContainer();
321
326
  this.#inertedSiblings = [];
322
- for (const sibling of Array.from(document.body.children)) {
323
- if (!(sibling instanceof HTMLElement)) continue;
324
- if (sibling.contains(container) || sibling.inert) continue;
325
- sibling.inert = true;
326
- this.#inertedSiblings.push(sibling);
327
+ for (let node = container; node !== document.body; ) {
328
+ const parent = node.parentElement;
329
+ if (!parent) break;
330
+ for (const sibling of Array.from(parent.children)) {
331
+ if (!(sibling instanceof HTMLElement)) continue;
332
+ if (sibling === node || sibling.inert) continue;
333
+ sibling.inert = true;
334
+ this.#inertedSiblings.push(sibling);
335
+ }
336
+ node = parent;
327
337
  }
328
338
  }
329
339
  /** Reverts the `inert` flags applied by `#isolateBackground`. */
@@ -576,8 +586,8 @@ var DrawerController = class extends Controller {
576
586
  * rather than being re-derived from the declarative `open` Value (which would
577
587
  * close a user-opened drawer). The `open` Value only seeds a genuinely fresh
578
588
  * render. We normalize to a clean closed baseline first so {@link open} runs its
579
- * full reveal + trap activation — the {@link FocusTrap} is a fresh instance
580
- * after a reconnect and must be re-activated.
589
+ * full reveal + trap activation — the {@link FocusTrap} is inactive after a
590
+ * disconnect and must be re-activated.
581
591
  */
582
592
  connect() {
583
593
  this.#connected = true;
@@ -55,33 +55,64 @@ var EditableController = class extends Controller {
55
55
  static values = {
56
56
  submitOnBlur: { type: Boolean, default: true }
57
57
  };
58
- static actions = ["edit", "onBlur", "onDisplayKeydown", "onKeydown"];
58
+ static actions = ["cancel", "edit", "onDisplayKeydown", "onKeydown", "revert", "save"];
59
59
  static events = ["cancel", "change"];
60
60
  /** The value captured when edit mode began, used to detect real changes. */
61
61
  #previousValue = "";
62
+ /**
63
+ * The value the last save replaced, or `null` when there is nothing to undo.
64
+ * Cleared on connect: a value from before a page restore is unrecoverable, so
65
+ * `revert()` must not resurrect one.
66
+ */
67
+ #revertValue = null;
62
68
  /**
63
69
  * Owns IME lifecycle state for the edit surface, so a keydown that belongs to
64
70
  * a composition (cancel or confirm) is never treated as an edit command.
65
71
  */
66
72
  #composition = new CompositionTracker();
73
+ /**
74
+ * Watches focus leaving the editor from wherever it currently sits.
75
+ *
76
+ * `focusout` bubbles where `blur` does not, so one listener on the root sees
77
+ * every departure — including one from a Save or Cancel button the consumer
78
+ * placed beside the input. Binding the input alone would make the promise
79
+ * "saves wherever focus moved" true only for focus that leaves the input
80
+ * itself, and tabbing straight past an inner button would strand the editor
81
+ * open.
82
+ */
83
+ #onFocusOut = (event) => {
84
+ const from = event.target;
85
+ if (this.hasDisplayTarget && from instanceof Node && this.displayTarget.contains(from)) return;
86
+ const next = event.relatedTarget;
87
+ if (next instanceof Node && this.element.contains(next)) return;
88
+ if (this.submitOnBlurValue) this.#commit(false);
89
+ };
67
90
  /** Establishes the initial display mode (display shown, input hidden). */
68
91
  connect() {
69
92
  if (this.hasInputTarget) this.#composition.observe(this.inputTarget);
93
+ this.element.addEventListener("focusout", this.#onFocusOut);
94
+ this.#revertValue = null;
70
95
  this.#setMode("display");
71
96
  }
72
- /** Releases the composition listeners so nothing outlives the element. */
97
+ /** Releases the composition and focus listeners so nothing outlives the element. */
73
98
  disconnect() {
74
99
  this.#composition.disconnect();
100
+ this.element.removeEventListener("focusout", this.#onFocusOut);
75
101
  }
76
102
  /** Tracks an input added initially or after connect (e.g. a Turbo swap). */
77
103
  inputTargetConnected(input) {
78
104
  this.#composition.observe(input);
105
+ this.#applyMode();
79
106
  }
80
107
  /** Removes composition listeners when the active input is replaced or removed. */
81
108
  inputTargetDisconnected(input) {
82
109
  this.#composition.unobserve(input);
83
110
  }
84
- /** Enters edit mode: seeds the input from the display text, focuses, selects. */
111
+ /** Re-hides or re-shows a display element that arrived after the mode was set. */
112
+ displayTargetConnected() {
113
+ this.#applyMode();
114
+ }
115
+ /** Enters edit mode: seeds the input from the declared value, focuses, selects. */
85
116
  edit() {
86
117
  if (this.#isEditing || !this.hasInputTarget || !this.hasDisplayTarget) return;
87
118
  this.#previousValue = this.#currentValue;
@@ -90,6 +121,27 @@ var EditableController = class extends Controller {
90
121
  this.inputTarget.focus();
91
122
  this.inputTarget.select();
92
123
  }
124
+ /** Commits the edit and returns focus to the display element. */
125
+ save() {
126
+ this.#commit(true);
127
+ }
128
+ /** Discards edits, returns to display mode, and dispatches `cancel`. */
129
+ cancel() {
130
+ if (!this.#isEditing) return;
131
+ this.#setMode("display");
132
+ if (this.hasDisplayTarget) this.displayTarget.focus();
133
+ this.dispatch("cancel", { detail: {} });
134
+ }
135
+ /**
136
+ * Puts back the value the last save replaced — for a consumer whose server
137
+ * rejected it. Silent by design: a `change` here would re-enter the same
138
+ * handler that asked for the undo. One save, one undo.
139
+ */
140
+ revert() {
141
+ if (this.#revertValue === null || this.#isEditing) return;
142
+ this.#writeValue(this.#revertValue);
143
+ this.#revertValue = null;
144
+ }
93
145
  /** Adds `F2` as an editing entry point alongside the button's native activation. */
94
146
  onDisplayKeydown(event) {
95
147
  if (event.key === "F2") {
@@ -103,56 +155,57 @@ var EditableController = class extends Controller {
103
155
  if (event.key === "Escape") {
104
156
  if (event.defaultPrevented) return;
105
157
  event.preventDefault();
106
- this.#cancel();
158
+ this.cancel();
107
159
  return;
108
160
  }
109
161
  if (event.key === "Enter") {
110
162
  if (event.defaultPrevented) return;
111
163
  if (this.#isMultiline && !(event.ctrlKey || event.metaKey)) return;
112
164
  event.preventDefault();
113
- this.#save(true);
165
+ this.#commit(true);
114
166
  }
115
167
  }
116
- /** Saves on blur when `submitOnBlur` is set; otherwise keeps editing. */
117
- onBlur() {
118
- if (!this.#isEditing) return;
119
- if (this.submitOnBlurValue) this.#save(false);
120
- }
121
168
  /**
122
- * Returns to display mode, reflecting the input into the display text and
123
- * dispatching `change` when the value differs from where editing began.
169
+ * Returns to display mode, storing the input's value and dispatching `change`
170
+ * when it differs from where editing began.
124
171
  *
125
172
  * @param restoreFocus - Move focus back to the display element (explicit
126
- * keyboard commit) rather than honoring the user's new focus target (blur).
173
+ * commit) rather than honoring the user's new focus target (blur).
127
174
  */
128
- #save(restoreFocus) {
129
- if (!this.#isEditing) return;
130
- const value = this.inputTarget.value;
175
+ #commit(restoreFocus) {
176
+ if (!this.#isEditing || !this.hasInputTarget) return;
177
+ const value = this.inputTarget.value.trim();
131
178
  const previous = this.#previousValue;
132
- this.displayTarget.textContent = value;
179
+ this.#writeValue(value);
133
180
  this.#setMode("display");
134
- if (restoreFocus) this.displayTarget.focus();
181
+ if (restoreFocus && this.hasDisplayTarget) this.displayTarget.focus();
135
182
  if (value !== previous) {
183
+ this.#revertValue = previous;
136
184
  this.dispatch("change", { detail: { value, previous } });
137
185
  }
138
186
  }
139
- /** Discards edits, returns to display mode, and dispatches `cancel`. */
140
- #cancel() {
141
- if (!this.#isEditing) return;
142
- this.#setMode("display");
143
- this.displayTarget.focus();
144
- this.dispatch("cancel", { detail: {} });
187
+ /** Stores `value` wherever the display element declares it. */
188
+ #writeValue(value) {
189
+ if (!this.hasDisplayTarget) return;
190
+ const display = this.displayTarget;
191
+ if (display.hasAttribute("data-value")) display.dataset.value = value;
192
+ else display.textContent = value;
145
193
  }
146
- /** Toggles the `data-mode` flag and the `hidden` state of both elements. */
147
- #setMode(mode) {
148
- this.element.dataset.mode = mode;
149
- const editing = mode === "editing";
194
+ /** Derives both elements' visibility from the mode currently in the DOM. */
195
+ #applyMode() {
196
+ const editing = this.#isEditing;
150
197
  if (this.hasDisplayTarget) this.displayTarget.hidden = editing;
151
198
  if (this.hasInputTarget) this.inputTarget.hidden = !editing;
152
199
  }
153
- /** Current display text, trimmed — the value shown when not editing. */
200
+ /** Records the mode, then brings both elements in line with it. */
201
+ #setMode(mode) {
202
+ this.element.dataset.mode = mode;
203
+ this.#applyMode();
204
+ }
205
+ /** The value the display element declares, trimmed. */
154
206
  get #currentValue() {
155
- return (this.displayTarget.textContent ?? "").trim();
207
+ const display = this.displayTarget;
208
+ return (display.dataset.value ?? display.textContent ?? "").trim();
156
209
  }
157
210
  /** Whether the editing control is a multi-line `<textarea>`. */
158
211
  get #isMultiline() {
@@ -9,15 +9,46 @@ var FilterController = class extends Controller {
9
9
  #onChange = () => {
10
10
  this.apply();
11
11
  };
12
+ /**
13
+ * Gates the declaration callback to the connected window.
14
+ *
15
+ * Stimulus delivers a Value callback ahead of `connect()` and again for every
16
+ * runtime change; without the gate, merely connecting would emit an evaluation
17
+ * — and its `change` — before `connect()` runs its own.
18
+ */
19
+ #connected = false;
12
20
  connect() {
13
- this.apply();
21
+ this.#evaluate();
14
22
  this.element.addEventListener("change", this.#onChange);
23
+ this.#connected = true;
15
24
  }
16
25
  disconnect() {
26
+ this.#connected = false;
17
27
  this.element.removeEventListener("change", this.#onChange);
18
28
  }
29
+ /**
30
+ * Re-evaluates when the match declaration changes at runtime.
31
+ *
32
+ * The declaration decides which items are shown, so an element kept across a
33
+ * morph — where `connect()` does not run again — still has to follow it instead
34
+ * of waiting for the next control interaction. The evaluation is the ordinary
35
+ * one, `change` included: a declaration swap is an evaluation like any other.
36
+ */
37
+ matchValueChanged() {
38
+ if (!this.#connected) return;
39
+ this.#evaluate();
40
+ }
19
41
  /** Re-derives every item's visibility from the active tokens and syncs groups/empty. */
20
42
  apply() {
43
+ this.#evaluate();
44
+ }
45
+ /**
46
+ * The evaluation itself: every item's visibility from the active tokens, then
47
+ * the groups and the empty element, then the `change` event.
48
+ *
49
+ * @stimeoRenderRoot
50
+ */
51
+ #evaluate() {
21
52
  const active = this.#activeTokens();
22
53
  let visibleCount = 0;
23
54
  for (const item of this.itemTargets) {
@@ -315,15 +315,25 @@ var FocusTrap = class {
315
315
  * content cannot be focused or reached by assistive technology, honoring the
316
316
  * `aria-modal="true"` contract. An element that was *already* `inert` is left
317
317
  * untracked so `#releaseBackground` does not wrongly clear it.
318
+ *
319
+ * The walk climbs from the container to `body` and inerts each ancestor's other
320
+ * children. Scanning only `body`'s children would skip the branch the container
321
+ * sits in — everything beside it inside that branch is background too, and a
322
+ * nested container is the ordinary case.
318
323
  */
319
324
  #isolateBackground() {
320
325
  const container = this.#getContainer();
321
326
  this.#inertedSiblings = [];
322
- for (const sibling of Array.from(document.body.children)) {
323
- if (!(sibling instanceof HTMLElement)) continue;
324
- if (sibling.contains(container) || sibling.inert) continue;
325
- sibling.inert = true;
326
- this.#inertedSiblings.push(sibling);
327
+ for (let node = container; node !== document.body; ) {
328
+ const parent = node.parentElement;
329
+ if (!parent) break;
330
+ for (const sibling of Array.from(parent.children)) {
331
+ if (!(sibling instanceof HTMLElement)) continue;
332
+ if (sibling === node || sibling.inert) continue;
333
+ sibling.inert = true;
334
+ this.#inertedSiblings.push(sibling);
335
+ }
336
+ node = parent;
327
337
  }
328
338
  }
329
339
  /** Reverts the `inert` flags applied by `#isolateBackground`. */
@@ -375,13 +385,39 @@ var FocusController = class extends Controller {
375
385
  initialFocus: () => this.hasInitialTarget ? this.initialTarget : null,
376
386
  onEscape: () => this.deactivate()
377
387
  });
388
+ /**
389
+ * Whether this scope is currently trapping.
390
+ *
391
+ * Held here rather than read back from the trap: the trap releases itself
392
+ * before Turbo caches the page, so its own flag stops answering for the state
393
+ * this controller publishes on the element and in its events.
394
+ */
395
+ #active = false;
396
+ /**
397
+ * Returns the element to its untrapped form before Turbo copies the page. The
398
+ * trap performs its own release on the same event, so what is left is the hook
399
+ * this controller owns — carried into the snapshot it would describe a scope
400
+ * that is no longer trapping.
401
+ *
402
+ * Silent, like `disconnect()`: the page is about to be frozen, and a release
403
+ * nobody asked for is not a close to report.
404
+ */
405
+ #beforeCache = new BeforeCacheReset(() => {
406
+ this.#active = false;
407
+ this.element.removeAttribute("data-focus-trapped");
408
+ });
378
409
  /** Stimulus drives activation from the `trap` value (also fires on connect). */
379
410
  trapValueChanged() {
380
411
  if (this.trapValue) this.#activate();
381
412
  else this.#deactivate();
382
413
  }
414
+ connect() {
415
+ this.#beforeCache.activate();
416
+ }
383
417
  disconnect() {
384
418
  this.#trap.deactivate({ restoreFocus: false });
419
+ this.#beforeCache.deactivate();
420
+ this.#active = false;
385
421
  this.element.removeAttribute("data-focus-trapped");
386
422
  }
387
423
  /** Turns the trap on. Acts synchronously and keeps the `trap` value in sync. */
@@ -395,13 +431,15 @@ var FocusController = class extends Controller {
395
431
  this.#deactivate();
396
432
  }
397
433
  #activate() {
398
- if (this.#trap.active) return;
434
+ if (this.#active) return;
435
+ this.#active = true;
399
436
  this.#trap.activate();
400
437
  this.element.setAttribute("data-focus-trapped", "true");
401
438
  this.dispatch("activate", { detail: {} });
402
439
  }
403
440
  #deactivate() {
404
- if (!this.#trap.active) return;
441
+ if (!this.#active) return;
442
+ this.#active = false;
405
443
  this.#trap.deactivate({ restoreFocus: this.restoreValue });
406
444
  this.element.removeAttribute("data-focus-trapped");
407
445
  this.dispatch("deactivate", { detail: {} });
@@ -146,7 +146,7 @@ var FormFieldController = class _FormFieldController extends Controller {
146
146
  static #INVALID_ATTR = "data-stimeo--form-field-invalid";
147
147
  /** Collapses one target/morph batch into one silent ARIA reconciliation. */
148
148
  #reconcile = new MicrotaskCoalescer(() => this.#reconcileDom());
149
- /** ARIA ownership is scoped to the current singular control target. */
149
+ /** Returns borrowed control ARIA before Turbo snapshots the page. */
150
150
  #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
151
151
  #ariaDescribedBy = new AttributeLease("aria-describedby");
152
152
  #ariaErrorMessage = new AttributeLease("aria-errormessage");
@@ -58,16 +58,72 @@ var LayoutObserver = class {
58
58
  }
59
59
  };
60
60
 
61
+ // src/utils/microtask_coalescer.ts
62
+ var MicrotaskCoalescer = class {
63
+ #run;
64
+ #queued = false;
65
+ #active = false;
66
+ #generation = 0;
67
+ /** @param run - the single reconciliation pass, invoked at most once per batch. */
68
+ constructor(run) {
69
+ this.#run = run;
70
+ }
71
+ /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
72
+ activate() {
73
+ this.#active = true;
74
+ }
75
+ /** Closes the window and drops any pending pass; call from `disconnect()`. */
76
+ cancel() {
77
+ this.#active = false;
78
+ this.#queued = false;
79
+ this.#generation += 1;
80
+ }
81
+ /** Requests one pass after the batch settles. Idempotent; inert outside the window. */
82
+ schedule() {
83
+ if (!this.#active || this.#queued) return;
84
+ this.#queued = true;
85
+ const generation = this.#generation;
86
+ queueMicrotask(() => {
87
+ if (generation !== this.#generation || !this.#queued || !this.#active) return;
88
+ this.#queued = false;
89
+ this.#run();
90
+ });
91
+ }
92
+ };
93
+
61
94
  // src/controllers/masonry_controller.ts
62
95
  var COLUMNS_PROPERTY = "--stimeo--masonry-columns";
96
+ var DEFAULT_MIN_COLUMN_WIDTH = 240;
97
+ var DEFAULT_GAP = 16;
98
+ function usableNumber(value, fallback) {
99
+ return Number.isFinite(value) ? value : fallback;
100
+ }
63
101
  var MasonryController = class extends Controller {
64
102
  static targets = ["item"];
65
103
  static values = {
66
- minColumnWidth: { type: Number, default: 240 },
67
- gap: { type: Number, default: 16 }
104
+ minColumnWidth: { type: Number, default: DEFAULT_MIN_COLUMN_WIDTH },
105
+ gap: { type: Number, default: DEFAULT_GAP }
68
106
  };
69
107
  static events = ["layout"];
70
- #layout = new LayoutObserver(() => this.#relayout());
108
+ /**
109
+ * The declared numbers after validation, so the layout path never sees a value
110
+ * it cannot compute with. Both are resolved once per declaration change rather
111
+ * than on every pass.
112
+ */
113
+ #minColumnWidth = DEFAULT_MIN_COLUMN_WIDTH;
114
+ #gap = DEFAULT_GAP;
115
+ /**
116
+ * Collapses every re-layout trigger of one DOM mutation into a single pass, and
117
+ * refuses to run before `connect()` or after `disconnect()`.
118
+ *
119
+ * The triggers arrive in bursts — a resize stream, a morph that syncs several
120
+ * attributes, a batch of rows — and each pass measures every item, so folding
121
+ * them keeps the work proportional to the batch rather than to the events in it.
122
+ */
123
+ #reconcile = new MicrotaskCoalescer(() => this.#relayout());
124
+ /** Items that left the target set and still carry the column hook. */
125
+ #released = /* @__PURE__ */ new Set();
126
+ #layout = new LayoutObserver(() => this.#reconcile.schedule());
71
127
  #mutationObserver = null;
72
128
  /** Last published column count, so `layout` fires only on real changes. */
73
129
  #lastColumns = 0;
@@ -77,20 +133,49 @@ var MasonryController = class extends Controller {
77
133
  * first pass ran before they settled; `load` does not bubble, so this is bound in
78
134
  * the capture phase to catch every descendant.
79
135
  */
80
- #onLoad = () => this.#relayout();
136
+ #onLoad = () => this.#reconcile.schedule();
137
+ /** Resolves the declared column width once, falling back when it is unreadable. */
138
+ minColumnWidthValueChanged() {
139
+ this.#minColumnWidth = usableNumber(this.minColumnWidthValue, DEFAULT_MIN_COLUMN_WIDTH);
140
+ this.#reconcile.schedule();
141
+ }
142
+ /** Resolves the declared gap once, falling back when it is unreadable. */
143
+ gapValueChanged() {
144
+ this.#gap = usableNumber(this.gapValue, DEFAULT_GAP);
145
+ this.#reconcile.schedule();
146
+ }
147
+ /** Packs an element that became an item without moving in the DOM. */
148
+ itemTargetConnected() {
149
+ this.#reconcile.schedule();
150
+ }
151
+ /**
152
+ * Queues the column hook of an element that stopped being an item for removal.
153
+ *
154
+ * The removal is queued rather than immediate because teardown reports every
155
+ * target as disconnected: doing it here would strip the whole grid just before
156
+ * a Turbo snapshot is taken. {@link MicrotaskCoalescer.cancel} drops the queue
157
+ * with the pass, so only a genuine target change reaches it.
158
+ */
159
+ itemTargetDisconnected(item) {
160
+ this.#released.add(item);
161
+ this.#reconcile.schedule();
162
+ }
81
163
  /** Observes size/content changes and performs the first layout pass. */
82
164
  connect() {
83
165
  this.#layout.observe(this.element);
84
166
  this.#layout.observeViewport();
85
167
  if (typeof MutationObserver !== "undefined") {
86
- this.#mutationObserver = new MutationObserver(() => this.#relayout());
168
+ this.#mutationObserver = new MutationObserver(() => this.#reconcile.schedule());
87
169
  this.#mutationObserver.observe(this.element, { childList: true, subtree: true });
88
170
  }
89
171
  this.element.addEventListener("load", this.#onLoad, true);
90
172
  this.#relayout();
173
+ this.#reconcile.activate();
91
174
  }
92
175
  /** Releases both observers and the load listener so nothing fires after detach. */
93
176
  disconnect() {
177
+ this.#reconcile.cancel();
178
+ this.#released.clear();
94
179
  this.#layout.disconnect();
95
180
  this.#mutationObserver?.disconnect();
96
181
  this.#mutationObserver = null;
@@ -99,26 +184,53 @@ var MasonryController = class extends Controller {
99
184
  }
100
185
  /**
101
186
  * Recomputes the column count and assigns every item to the shortest column.
102
- * Runs automatically on connect, on resize, on item add/remove, and when a
103
- * descendant resource loads (private — there is no public action; the observers
104
- * and the capture-phase `load` listener drive it). Items are walked in DOM
105
- * order; each lands in the column with the least accumulated height, which
106
- * keeps the packing balanced without reordering the DOM.
187
+ * Runs automatically on connect, on resize, on item add/remove, when a declared
188
+ * number changes, and when a descendant resource loads (private — there is no
189
+ * public action; the observers, the target callbacks and the capture-phase
190
+ * `load` listener drive it). Items are walked in DOM order; each lands in the
191
+ * column with the least accumulated height, which keeps the packing balanced
192
+ * without reordering the DOM.
193
+ *
194
+ * Every box is measured before anything is written. Interleaving the two would
195
+ * make a consumer's `data-column` rule invalidate style once per item, and the
196
+ * next measurement then has to settle layout again — once per item instead of
197
+ * once per pass. The assignment is independent of the measurement because the
198
+ * columns are uniform in width, so the order of the two passes does not change
199
+ * the result.
200
+ *
201
+ * @stimeoRenderRoot
107
202
  */
108
203
  #relayout() {
109
204
  const items = this.itemTargets;
110
205
  const columns = this.#columnCount();
206
+ const boxes = items.map((item) => item.getBoundingClientRect().height);
207
+ let changed = false;
208
+ if (this.#released.size > 0) {
209
+ const owned = new Set(items);
210
+ for (const released of this.#released) {
211
+ if (owned.has(released)) continue;
212
+ if (released.hasAttribute("data-column")) {
213
+ released.removeAttribute("data-column");
214
+ changed = true;
215
+ }
216
+ }
217
+ this.#released.clear();
218
+ }
111
219
  const heights = new Array(columns).fill(0);
112
- for (const item of items) {
220
+ items.forEach((item, index) => {
113
221
  let shortest = 0;
114
222
  for (let col = 1; col < columns; col++) {
115
223
  if ((heights[col] ?? 0) < (heights[shortest] ?? 0)) shortest = col;
116
224
  }
117
- item.setAttribute("data-column", String(shortest));
118
- heights[shortest] = (heights[shortest] ?? 0) + item.getBoundingClientRect().height + this.gapValue;
119
- }
225
+ const assigned = String(shortest);
226
+ if (item.getAttribute("data-column") !== assigned) {
227
+ item.setAttribute("data-column", assigned);
228
+ changed = true;
229
+ }
230
+ heights[shortest] = (heights[shortest] ?? 0) + (boxes[index] ?? 0) + this.#gap;
231
+ });
120
232
  this.element.style.setProperty(COLUMNS_PROPERTY, String(columns));
121
- if (columns !== this.#lastColumns) {
233
+ if (columns !== this.#lastColumns || changed) {
122
234
  this.#lastColumns = columns;
123
235
  this.dispatch("layout", { detail: { columns } });
124
236
  }
@@ -131,9 +243,9 @@ var MasonryController = class extends Controller {
131
243
  */
132
244
  #columnCount() {
133
245
  const width = this.element.getBoundingClientRect().width;
134
- const denominator = this.minColumnWidthValue + this.gapValue;
246
+ const denominator = this.#minColumnWidth + this.#gap;
135
247
  if (width <= 0 || denominator <= 0) return 1;
136
- return Math.max(1, Math.floor((width + this.gapValue) / denominator));
248
+ return Math.max(1, Math.floor((width + this.#gap) / denominator));
137
249
  }
138
250
  };
139
251
 
@@ -629,8 +629,8 @@ var MenubarController = class extends Controller {
629
629
  * being hidden itself, a menu can stay visible after its owning top was removed,
630
630
  * an open pair can appear from a morph with no layer registered for it, and the
631
631
  * Tab stop can end up on a now-inert top, on a runtime-added one, or on none at
632
- * all. Nothing is remembered between calls (except which side of the Escape stack
633
- * this layer is on, which the stack itself does not expose), so the outcome is the
632
+ * all. The only state carried between calls is the tracked focus record and which side
633
+ * of the Escape stack this layer is on (which the stack itself does not expose), so the outcome is the
634
634
  * same whichever mutation arrived and calling it more often than needed is free.
635
635
  */
636
636
  #reconcile() {
@@ -1048,7 +1048,7 @@ var MultiSelectController = class extends Controller {
1048
1048
  get #values() {
1049
1049
  return this.#selectedOptions.map((option) => this.#optionValue(option));
1050
1050
  }
1051
- /** Normalized cardinality cap: zero is unlimited and positive fractions round down. */
1051
+ /** Normalized cardinality cap: zero and below are unlimited; a positive value floors, never below 1. */
1052
1052
  get #selectionLimit() {
1053
1053
  if (!Number.isFinite(this.maxValue) || this.maxValue <= 0) return 0;
1054
1054
  return Math.max(1, Math.floor(this.maxValue));