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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +151 -0
- data/dist/controllers/auto_submit_controller.js +94 -16
- data/dist/controllers/bulk_select_controller.js +139 -28
- data/dist/controllers/carousel_controller.js +451 -100
- data/dist/controllers/clipboard_controller.js +102 -20
- data/dist/controllers/color_picker_controller.js +180 -43
- data/dist/controllers/currency_input_controller.js +305 -74
- data/dist/controllers/data_grid_controller.js +150 -24
- data/dist/controllers/direct_upload_controller.js +12 -2
- data/dist/controllers/editable_controller.js +83 -30
- data/dist/controllers/file_dropzone_controller.js +386 -63
- data/dist/controllers/filter_controller.js +32 -1
- data/dist/controllers/flash_controller.js +3 -1
- data/dist/controllers/frame_loading_controller.js +2 -1
- data/dist/controllers/input_mask_controller.js +251 -76
- data/dist/controllers/masonry_controller.js +129 -17
- data/dist/controllers/nested_form_controller.js +450 -42
- data/dist/controllers/otp_controller.js +485 -114
- data/dist/controllers/reset_before_cache_controller.js +51 -5
- data/dist/controllers/resizable_controller.js +128 -55
- data/dist/controllers/spinner_controller.js +7 -5
- data/dist/controllers/submit_once_controller.js +3 -1
- data/dist/controllers/textarea_autosize_controller.js +131 -3
- data/dist/index.js +2764 -923
- data/lib/stimeo/ui/version.rb +1 -1
- metadata +2 -2
|
@@ -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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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:
|
|
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.#
|
|
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.#
|
|
200
|
+
format(event) {
|
|
201
|
+
if (this.#composition.isComposing(event)) return;
|
|
202
|
+
this.#reformat("edit", deletionOf(event));
|
|
55
203
|
}
|
|
56
|
-
/**
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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(
|
|
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
|
|
69
|
-
if (
|
|
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",
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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(
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
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"`)
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
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
|
-
#
|
|
302
|
+
#resolveSink() {
|
|
303
|
+
const candidates = this.#sinkCandidates();
|
|
119
304
|
const id = this.element.id;
|
|
120
305
|
if (id.length > 0) {
|
|
121
|
-
const
|
|
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
|
|
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
|
-
/**
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
|
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:
|
|
67
|
-
gap: { type: Number, default:
|
|
104
|
+
minColumnWidth: { type: Number, default: DEFAULT_MIN_COLUMN_WIDTH },
|
|
105
|
+
gap: { type: Number, default: DEFAULT_GAP }
|
|
68
106
|
};
|
|
69
107
|
static events = ["layout"];
|
|
70
|
-
|
|
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.#
|
|
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.#
|
|
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,
|
|
103
|
-
* descendant resource loads (private — there is no
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
118
|
-
|
|
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
|
|
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
|
|
248
|
+
return Math.max(1, Math.floor((width + this.#gap) / denominator));
|
|
137
249
|
}
|
|
138
250
|
};
|
|
139
251
|
|