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,5 +1,74 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
 
3
+ // src/controllers/input_mask_controller.ts
4
+
5
+ // src/utils/composition_tracker.ts
6
+ var CompositionTracker = class {
7
+ #observedTargets = /* @__PURE__ */ new Set();
8
+ #activeTargets = /* @__PURE__ */ new Set();
9
+ #onStart;
10
+ #onEnd;
11
+ constructor(options = {}) {
12
+ this.#onStart = options.onStart;
13
+ this.#onEnd = options.onEnd;
14
+ }
15
+ /** Starts lifecycle tracking for `target`; repeated calls are idempotent. */
16
+ observe(target) {
17
+ if (this.#observedTargets.has(target)) return;
18
+ target.addEventListener("compositionstart", this.#handleStart);
19
+ target.addEventListener("compositionend", this.#handleEnd);
20
+ this.#observedTargets.add(target);
21
+ }
22
+ /** Stops tracking one target and clears any active composition it owned. */
23
+ unobserve(target) {
24
+ if (!this.#observedTargets.delete(target)) return;
25
+ target.removeEventListener("compositionstart", this.#handleStart);
26
+ target.removeEventListener("compositionend", this.#handleEnd);
27
+ this.#activeTargets.delete(target);
28
+ }
29
+ /** Releases every listener and clears state so reconnect starts cleanly. */
30
+ disconnect() {
31
+ for (const target of this.#observedTargets) {
32
+ target.removeEventListener("compositionstart", this.#handleStart);
33
+ target.removeEventListener("compositionend", this.#handleEnd);
34
+ }
35
+ this.#observedTargets.clear();
36
+ this.#activeTargets.clear();
37
+ }
38
+ /** True when lifecycle tracking or the current event reports composition. */
39
+ isComposing(event) {
40
+ return this.#activeTargets.size > 0 || event?.isComposing === true;
41
+ }
42
+ #handleStart = (event) => {
43
+ if (event.currentTarget) this.#activeTargets.add(event.currentTarget);
44
+ this.#onStart?.(event);
45
+ };
46
+ #handleEnd = (event) => {
47
+ if (event.currentTarget) this.#activeTargets.delete(event.currentTarget);
48
+ this.#onEnd?.(event);
49
+ };
50
+ };
51
+
52
+ // src/utils/focus_candidate.ts
53
+ function inheritsFieldsetDisabled(control) {
54
+ let fieldset = control.closest("fieldset[disabled]");
55
+ while (fieldset) {
56
+ const legend = Array.from(fieldset.children).find((child) => child.tagName === "LEGEND");
57
+ if (!legend?.contains(control)) return true;
58
+ fieldset = fieldset.parentElement?.closest("fieldset[disabled]") ?? null;
59
+ }
60
+ return false;
61
+ }
62
+
63
+ // src/utils/half_width.ts
64
+ var FULL_WIDTH_SHIFT = 65248;
65
+ function halfWidthChar(char) {
66
+ if (char >= "\uFF01" && char <= "\uFF5E") {
67
+ return String.fromCharCode(char.charCodeAt(0) - FULL_WIDTH_SHIFT);
68
+ }
69
+ return char === "\u3000" ? " " : char;
70
+ }
71
+
3
72
  // src/controllers/input_mask_controller.ts
4
73
  var DEFAULT_TOKENS = {
5
74
  "9": "\\d",
@@ -7,6 +76,11 @@ var DEFAULT_TOKENS = {
7
76
  "*": "[A-Za-z0-9]"
8
77
  };
9
78
  var UNMASK_ATTR = "data-stimeo--input-mask-unmask";
79
+ function acceptedForm(regex, char) {
80
+ if (regex.test(char)) return char;
81
+ const half = halfWidthChar(char);
82
+ return half !== char && regex.test(half) ? half : null;
83
+ }
10
84
  function applyMask(value, pattern, tokens) {
11
85
  let masked = "";
12
86
  let unmasked = "";
@@ -18,17 +92,21 @@ function applyMask(value, pattern, tokens) {
18
92
  if (regex) totalTokens += 1;
19
93
  if (valueIndex >= value.length) continue;
20
94
  if (regex) {
21
- while (valueIndex < value.length && !regex.test(value[valueIndex] ?? "")) valueIndex += 1;
22
- const char = value[valueIndex];
23
- if (char === void 0) continue;
24
- masked += char;
25
- unmasked += char;
95
+ let taken = null;
96
+ while (valueIndex < value.length) {
97
+ taken = acceptedForm(regex, value[valueIndex] ?? "");
98
+ if (taken !== null) break;
99
+ valueIndex += 1;
100
+ }
101
+ if (taken === null) continue;
102
+ masked += taken;
103
+ unmasked += taken;
26
104
  tokenFlags.push(true);
27
105
  valueIndex += 1;
28
106
  } else {
29
107
  masked += patternChar;
30
108
  tokenFlags.push(false);
31
- if (value[valueIndex] === patternChar) valueIndex += 1;
109
+ if (halfWidthChar(value[valueIndex] ?? "") === halfWidthChar(patternChar)) valueIndex += 1;
32
110
  }
33
111
  }
34
112
  return {
@@ -38,67 +116,169 @@ function applyMask(value, pattern, tokens) {
38
116
  tokenFlags
39
117
  };
40
118
  }
119
+ function compileTokens(declaration) {
120
+ const map = /* @__PURE__ */ new Map();
121
+ for (const [key, source] of Object.entries({ ...DEFAULT_TOKENS, ...parseTokens(declaration) })) {
122
+ try {
123
+ map.set(key, new RegExp(`^(?:${source})$`));
124
+ } catch {
125
+ }
126
+ }
127
+ return map;
128
+ }
129
+ function parseTokens(declaration) {
130
+ let parsed;
131
+ try {
132
+ parsed = JSON.parse(declaration);
133
+ } catch {
134
+ return {};
135
+ }
136
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return {};
137
+ const tokens = {};
138
+ for (const [key, source] of Object.entries(parsed)) {
139
+ if (typeof source === "string") tokens[key] = source;
140
+ }
141
+ return tokens;
142
+ }
143
+ function deletionOf(event) {
144
+ const inputType = event instanceof InputEvent ? event.inputType : "";
145
+ if (!inputType.startsWith("delete")) return null;
146
+ return inputType.endsWith("Forward") ? "forward" : "backward";
147
+ }
148
+ function tokenCharOffset(tokenFlags, n) {
149
+ let seen = 0;
150
+ for (let i = 0; i < tokenFlags.length; i += 1) {
151
+ if (!tokenFlags[i]) continue;
152
+ seen += 1;
153
+ if (seen === n) return i;
154
+ }
155
+ return -1;
156
+ }
41
157
  var InputMaskController = class extends Controller {
42
158
  static values = {
43
159
  pattern: { type: String, default: "" },
44
- tokens: { type: Object, default: {} },
160
+ tokens: { type: String, default: "" },
45
161
  unmaskToHidden: { type: Boolean, default: true }
46
162
  };
47
163
  static actions = ["format"];
48
- static events = ["change"];
164
+ static events = ["change", "reconcile"];
165
+ /** The value this controller last committed, and the baseline both events compare. */
166
+ #lastValue = null;
167
+ #started = false;
168
+ /** Validated token map; the hot path never parses the `tokens` declaration. */
169
+ #tokens = compileTokens("");
170
+ /** Holds mid-composition input so the IME's uncommitted text is never rewritten. */
171
+ #composition = new CompositionTracker({ onEnd: () => this.#reformat("edit") });
172
+ /** Re-parses the declaration and re-formats under the tokens it now selects. */
173
+ tokensValueChanged() {
174
+ this.#tokens = compileTokens(this.tokensValue);
175
+ if (this.#started) this.#reformat("reconcile");
176
+ }
177
+ patternValueChanged() {
178
+ if (this.#started) this.#reformat("reconcile");
179
+ }
180
+ /** Clears a sink it stops maintaining so a submit cannot carry a stale raw value. */
181
+ unmaskToHiddenValueChanged() {
182
+ if (!this.#started) return;
183
+ if (!this.unmaskToHiddenValue) {
184
+ const sink = this.#resolveSink();
185
+ if (sink) sink.value = "";
186
+ }
187
+ this.#reformat("reconcile");
188
+ }
49
189
  connect() {
50
- this.#apply();
190
+ this.#started = true;
191
+ this.#composition.observe(this.element);
192
+ this.#lastValue = this.element.value;
193
+ this.#reformat("reconcile");
194
+ }
195
+ disconnect() {
196
+ this.#started = false;
197
+ this.#composition.disconnect();
51
198
  }
52
199
  /** Formats the field on input, preserving the caret. Bound via `data-action`. */
53
- format() {
54
- this.#apply();
200
+ format(event) {
201
+ if (this.#composition.isComposing(event)) return;
202
+ this.#reformat("edit", deletionOf(event));
55
203
  }
56
- /** Core reformat: mask the current value, restore the caret, sync, and announce. */
57
- #apply() {
58
- if (!this.patternValue) return;
204
+ /**
205
+ * Core reformat: mask the current value, restore the caret, sync the sink and
206
+ * the state hooks, and report a moved value under the event `cause` selects.
207
+ */
208
+ #reformat(cause, deletion = null) {
59
209
  const input = this.element;
60
- const previous = input.value;
61
- const caret = input.selectionStart ?? previous.length;
62
- const tokens = this.#tokenRegexes();
63
- const significant = this.#countSignificant(previous.slice(0, caret), tokens);
64
- const result = applyMask(previous, this.patternValue, tokens);
210
+ const raw = input.value;
211
+ if (this.patternValue === "") {
212
+ this.#flag("data-mask-complete", false);
213
+ this.#flag("data-mask-empty", raw.length === 0);
214
+ this.#lastValue = raw;
215
+ return;
216
+ }
217
+ const caret = input.selectionStart ?? raw.length;
218
+ let anchor = this.#significantBefore(raw.slice(0, caret));
219
+ let result = applyMask(raw, this.patternValue, this.#tokens);
220
+ if (deletion !== null && result.masked === this.#lastValue) {
221
+ const target = deletion === "backward" ? anchor : anchor + 1;
222
+ const offset = tokenCharOffset(result.tokenFlags, target);
223
+ if (offset >= 0) {
224
+ anchor = target - 1;
225
+ result = applyMask(
226
+ result.masked.slice(0, offset) + result.masked.slice(offset + 1),
227
+ this.patternValue,
228
+ this.#tokens
229
+ );
230
+ }
231
+ }
232
+ if (input.readOnly || input.disabled || inheritsFieldsetDisabled(input)) {
233
+ this.#publish(result, raw);
234
+ this.#lastValue = raw;
235
+ return;
236
+ }
65
237
  input.value = result.masked;
66
- this.#restoreCaret(input, result.tokenFlags, significant);
238
+ this.#restoreCaret(result.tokenFlags, anchor);
239
+ this.#publish(result, result.masked);
240
+ if (result.masked === this.#lastValue) return;
241
+ this.#lastValue = result.masked;
242
+ const detail = { masked: result.masked, unmasked: result.unmasked, complete: result.complete };
243
+ if (cause === "edit") this.dispatch("change", { detail });
244
+ else this.dispatch("reconcile", { detail });
245
+ }
246
+ /** Syncs the raw-value sink and the state hooks for the `shown` field text. */
247
+ #publish(result, shown) {
67
248
  if (this.unmaskToHiddenValue) {
68
- const unmask = this.#unmaskField();
69
- if (unmask) unmask.value = result.unmasked;
249
+ const sink = this.#resolveSink();
250
+ if (sink) sink.value = result.unmasked;
70
251
  }
71
252
  this.#flag("data-mask-complete", result.complete);
72
- this.#flag("data-mask-empty", result.masked.length === 0);
73
- if (result.masked !== previous) {
74
- this.dispatch("change", {
75
- detail: { masked: result.masked, unmasked: result.unmasked, complete: result.complete }
76
- });
77
- }
253
+ this.#flag("data-mask-empty", shown.length === 0);
254
+ }
255
+ /**
256
+ * How many token slots the text before the caret fills. Masking that prefix is
257
+ * what makes a rejected character — or a literal that also matches a token —
258
+ * count exactly as the rendering counts it, so the caret cannot drift.
259
+ */
260
+ #significantBefore(prefix) {
261
+ return applyMask(prefix, this.patternValue, this.#tokens).unmasked.length;
78
262
  }
79
263
  /** Places the caret after the `n`-th token char (skipping following literals). */
80
- #restoreCaret(input, tokenFlags, n) {
264
+ #restoreCaret(tokenFlags, n) {
81
265
  let position;
82
266
  if (n <= 0) {
83
267
  let i = 0;
84
268
  while (i < tokenFlags.length && !tokenFlags[i]) i += 1;
85
269
  position = i;
86
270
  } else {
87
- let seen = 0;
88
- position = tokenFlags.length;
89
- for (let i = 0; i < tokenFlags.length; i += 1) {
90
- if (!tokenFlags[i]) continue;
91
- seen += 1;
92
- if (seen === n) {
93
- let j = i + 1;
94
- while (j < tokenFlags.length && !tokenFlags[j]) j += 1;
95
- position = j;
96
- break;
97
- }
271
+ const offset = tokenCharOffset(tokenFlags, n);
272
+ if (offset < 0) {
273
+ position = tokenFlags.length;
274
+ } else {
275
+ let j = offset + 1;
276
+ while (j < tokenFlags.length && !tokenFlags[j]) j += 1;
277
+ position = j;
98
278
  }
99
279
  }
100
280
  try {
101
- input.setSelectionRange(position, position);
281
+ this.element.setSelectionRange(position, position);
102
282
  } catch {
103
283
  }
104
284
  }
@@ -107,52 +287,47 @@ var InputMaskController = class extends Controller {
107
287
  * can coexist in one form:
108
288
  *
109
289
  * 1. **Explicit pairing** — a sink whose attribute value names this input's
110
- * `id` (`data-stimeo--input-mask-unmask="zip"`), looked up across the form
111
- * (or the document when the input is form-less).
112
- * 2. **Nearest container** otherwise, walking up from the input (stopping at
113
- * the form boundary), the first *value-less* sink in the closest ancestor.
114
- * Wrapped input+sink pairs each find their own sink, and the single
115
- * form-level sink keeps working unchanged. A sink claimed by another
116
- * input's id is never matched here.
290
+ * `id` (`data-stimeo--input-mask-unmask="zip"`).
291
+ * 2. **Nearest container** — otherwise, walking up from the input, the first
292
+ * *value-less* sink in the closest ancestor. Wrapped input+sink pairs each
293
+ * find their own sink, and the single form-level sink keeps working
294
+ * unchanged. A sink claimed by another input's id is never matched here.
295
+ *
296
+ * Both steps only ever consider the associated form's own controls, so a sink
297
+ * belonging to a different form (or to none) is never written, while one the
298
+ * `form` attribute associates from elsewhere in the document still resolves —
299
+ * for either the input or the sink, neither of which needs to contain or be
300
+ * contained by the form. A form-less input reads the document instead.
117
301
  */
118
- #unmaskField() {
302
+ #resolveSink() {
303
+ const candidates = this.#sinkCandidates();
119
304
  const id = this.element.id;
120
305
  if (id.length > 0) {
121
- const scope = this.element.form ?? document;
122
- const quoted = id.replace(/["\\]/g, "\\$&");
123
- const paired = scope.querySelector(`input[${UNMASK_ATTR}="${quoted}"]`);
306
+ const paired = candidates.find((sink) => sink.getAttribute(UNMASK_ATTR) === id);
124
307
  if (paired) return paired;
125
308
  }
309
+ const free = candidates.filter((sink) => sink.getAttribute(UNMASK_ATTR) === "");
126
310
  for (let node = this.element.parentElement; node !== null; node = node.parentElement) {
127
- const sink = node.querySelector(`input[${UNMASK_ATTR}=""]`);
311
+ const ancestor = node;
312
+ const sink = free.find((candidate) => ancestor.contains(candidate));
128
313
  if (sink) return sink;
129
- if (node === this.element.form) break;
130
314
  }
131
315
  return null;
132
316
  }
133
- /** Counts characters in `text` that fill any token (the caret-significant chars). */
134
- #countSignificant(text, tokens) {
135
- let count = 0;
136
- for (const char of text) {
137
- for (const regex of tokens.values()) {
138
- if (regex.test(char)) {
139
- count += 1;
140
- break;
141
- }
142
- }
143
- }
144
- return count;
145
- }
146
- /** Compiles the effective token map (defaults with the user `tokens` merged over). */
147
- #tokenRegexes() {
148
- const map = /* @__PURE__ */ new Map();
149
- for (const [key, source] of Object.entries({ ...DEFAULT_TOKENS, ...this.tokensValue })) {
150
- try {
151
- map.set(key, new RegExp(`^(?:${source})$`));
152
- } catch {
153
- }
317
+ /**
318
+ * Every sink the input's form owns, in document order — one collection read per
319
+ * resolution, so the cost tracks the form's own controls and not the depth of
320
+ * the markup around it. `form.elements` lists controls the `form` attribute
321
+ * associates from anywhere in the document, and lists nothing another form owns.
322
+ */
323
+ #sinkCandidates() {
324
+ const form = this.element.form;
325
+ if (form === null) {
326
+ return Array.from(document.querySelectorAll(`input[${UNMASK_ATTR}]`));
154
327
  }
155
- return map;
328
+ return Array.from(form.elements).filter(
329
+ (element) => element instanceof HTMLInputElement && element.hasAttribute(UNMASK_ATTR)
330
+ );
156
331
  }
157
332
  /** Sets a boolean `data-*` flag to `"true"` when `on`, else removes it. */
158
333
  #flag(name, on) {
@@ -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