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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +163 -0
- data/dist/controllers/alert_dialog_controller.js +17 -7
- data/dist/controllers/announcer_controller.js +1 -1
- data/dist/controllers/bulk_select_controller.js +139 -28
- data/dist/controllers/clipboard_controller.js +102 -20
- data/dist/controllers/collapsible_controller.js +1 -1
- data/dist/controllers/color_picker_controller.js +180 -43
- data/dist/controllers/command_palette_controller.js +15 -5
- data/dist/controllers/confirm_controller.js +15 -5
- data/dist/controllers/countdown_controller.js +1 -1
- data/dist/controllers/data_grid_controller.js +151 -25
- data/dist/controllers/dialog_controller.js +15 -5
- data/dist/controllers/direct_upload_controller.js +1 -5
- data/dist/controllers/drawer_controller.js +17 -7
- data/dist/controllers/editable_controller.js +83 -30
- data/dist/controllers/filter_controller.js +32 -1
- data/dist/controllers/focus_controller.js +45 -7
- data/dist/controllers/form_field_controller.js +1 -1
- data/dist/controllers/masonry_controller.js +129 -17
- data/dist/controllers/menubar_controller.js +2 -2
- data/dist/controllers/multi_select_controller.js +1 -1
- data/dist/controllers/otp_controller.js +29 -16
- data/dist/controllers/overflow_menu_controller.js +2 -2
- data/dist/controllers/password_reveal_controller.js +102 -3
- data/dist/controllers/password_strength_controller.js +287 -42
- data/dist/controllers/reset_before_cache_controller.js +51 -5
- data/dist/controllers/resizable_controller.js +128 -55
- data/dist/controllers/scroll_restore_controller.js +127 -27
- data/dist/controllers/scroll_visibility_controller.js +167 -14
- data/dist/controllers/sidebar_controller.js +15 -5
- data/dist/controllers/step_indicator_controller.js +0 -5
- data/dist/controllers/theme_controller.js +151 -40
- data/dist/controllers/toast_controller.js +2 -2
- data/dist/controllers/transition_controller.js +79 -15
- data/dist/index.js +1612 -529
- data/dist/positioning/index.js +84 -25
- data/lib/stimeo/ui/version.rb +1 -1
- 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
|
|
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 (
|
|
323
|
-
|
|
324
|
-
if (
|
|
325
|
-
sibling.
|
|
326
|
-
|
|
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
|
|
580
|
-
*
|
|
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 = ["
|
|
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
|
-
/**
|
|
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
|
|
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.#
|
|
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,
|
|
123
|
-
*
|
|
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
|
-
*
|
|
173
|
+
* commit) rather than honoring the user's new focus target (blur).
|
|
127
174
|
*/
|
|
128
|
-
#
|
|
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
|
|
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
|
-
/**
|
|
140
|
-
#
|
|
141
|
-
if (!this
|
|
142
|
-
this
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
/**
|
|
147
|
-
#
|
|
148
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
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 (
|
|
323
|
-
|
|
324
|
-
if (
|
|
325
|
-
sibling.
|
|
326
|
-
|
|
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.#
|
|
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.#
|
|
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
|
-
/**
|
|
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:
|
|
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
|
|
|
@@ -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.
|
|
633
|
-
* this layer is on
|
|
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
|
|
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));
|