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,6 +1,238 @@
|
|
|
1
1
|
import { Controller } from '@hotwired/stimulus';
|
|
2
2
|
|
|
3
3
|
// src/controllers/nested_form_controller.ts
|
|
4
|
+
|
|
5
|
+
// src/utils/announce.ts
|
|
6
|
+
function announce(message, options = {}) {
|
|
7
|
+
const text = message.trim();
|
|
8
|
+
if (text.length === 0) return;
|
|
9
|
+
window.dispatchEvent(
|
|
10
|
+
new CustomEvent("stimeo--announcer:announce", {
|
|
11
|
+
detail: { message: text, assertive: options.assertive === true }
|
|
12
|
+
})
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
function fillTemplate(template, values) {
|
|
16
|
+
return template.replace(/\{([a-zA-Z][a-zA-Z0-9]*)\}/g, (match, name) => {
|
|
17
|
+
const replacement = values[name];
|
|
18
|
+
return replacement === void 0 ? match : String(replacement);
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// src/utils/attribute_lease.ts
|
|
23
|
+
var AttributeLease = class {
|
|
24
|
+
#attribute;
|
|
25
|
+
#records = /* @__PURE__ */ new Map();
|
|
26
|
+
/** @param attribute - The attribute whose temporary values this lease owns. */
|
|
27
|
+
constructor(attribute) {
|
|
28
|
+
this.#attribute = attribute;
|
|
29
|
+
}
|
|
30
|
+
/** Writes or removes the leased attribute while preserving its authored value. */
|
|
31
|
+
write(element, value) {
|
|
32
|
+
const existing = this.#records.get(element);
|
|
33
|
+
if (existing) {
|
|
34
|
+
existing.written = value;
|
|
35
|
+
} else {
|
|
36
|
+
this.#records.set(element, {
|
|
37
|
+
original: element.getAttribute(this.#attribute),
|
|
38
|
+
written: value
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
this.#reflect(element, value);
|
|
42
|
+
}
|
|
43
|
+
/** Returns one lease without overwriting a value subsequently authored by a consumer. */
|
|
44
|
+
return(element) {
|
|
45
|
+
const record = this.#records.get(element);
|
|
46
|
+
if (!record) return;
|
|
47
|
+
this.#records.delete(element);
|
|
48
|
+
const stillOwned = element.getAttribute(this.#attribute) === record.written;
|
|
49
|
+
if (stillOwned) this.#reflect(element, record.original);
|
|
50
|
+
}
|
|
51
|
+
/** Reflects only a real value transition, avoiding self-triggered mutation work. */
|
|
52
|
+
#reflect(element, value) {
|
|
53
|
+
if (element.getAttribute(this.#attribute) === value) return;
|
|
54
|
+
if (value === null) element.removeAttribute(this.#attribute);
|
|
55
|
+
else element.setAttribute(this.#attribute, value);
|
|
56
|
+
}
|
|
57
|
+
/** Returns every outstanding lease using the same ownership check as {@link return}. */
|
|
58
|
+
returnAll() {
|
|
59
|
+
for (const element of Array.from(this.#records.keys())) this.return(element);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// src/utils/before_cache_reset.ts
|
|
64
|
+
var BeforeCacheReset = class _BeforeCacheReset {
|
|
65
|
+
/** Every subscribed instance, iterated by the one shared document listener. */
|
|
66
|
+
static #subscribers = /* @__PURE__ */ new Set();
|
|
67
|
+
/** The shared listener; installed while at least one instance is subscribed. */
|
|
68
|
+
static #onBeforeCache = () => {
|
|
69
|
+
for (const subscriber of _BeforeCacheReset.#subscribers) subscriber.#rewind();
|
|
70
|
+
};
|
|
71
|
+
#rewind;
|
|
72
|
+
/** @param rewind - the pass that returns this controller's state to its initial form. */
|
|
73
|
+
constructor(rewind) {
|
|
74
|
+
this.#rewind = rewind;
|
|
75
|
+
}
|
|
76
|
+
/** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */
|
|
77
|
+
activate() {
|
|
78
|
+
const first = _BeforeCacheReset.#subscribers.size === 0;
|
|
79
|
+
_BeforeCacheReset.#subscribers.add(this);
|
|
80
|
+
if (first) {
|
|
81
|
+
document.addEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */
|
|
85
|
+
deactivate() {
|
|
86
|
+
_BeforeCacheReset.#subscribers.delete(this);
|
|
87
|
+
if (_BeforeCacheReset.#subscribers.size > 0) return;
|
|
88
|
+
document.removeEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// src/utils/focus_candidate.ts
|
|
93
|
+
function inheritsFieldsetDisabled(control) {
|
|
94
|
+
let fieldset = control.closest("fieldset[disabled]");
|
|
95
|
+
while (fieldset) {
|
|
96
|
+
const legend = Array.from(fieldset.children).find((child) => child.tagName === "LEGEND");
|
|
97
|
+
if (!legend?.contains(control)) return true;
|
|
98
|
+
fieldset = fieldset.parentElement?.closest("fieldset[disabled]") ?? null;
|
|
99
|
+
}
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
function canTakeFocus(element) {
|
|
103
|
+
if (element.closest("[hidden], [inert]")) return false;
|
|
104
|
+
if (element instanceof HTMLInputElement && element.type === "hidden") return false;
|
|
105
|
+
if (!("disabled" in element)) return true;
|
|
106
|
+
if (element.disabled) return false;
|
|
107
|
+
return !inheritsFieldsetDisabled(element);
|
|
108
|
+
}
|
|
109
|
+
var TAB_STOP_CANDIDATE_SELECTOR = [
|
|
110
|
+
"a[href]",
|
|
111
|
+
"area[href]",
|
|
112
|
+
"button",
|
|
113
|
+
"input",
|
|
114
|
+
"select",
|
|
115
|
+
"textarea",
|
|
116
|
+
"summary",
|
|
117
|
+
"iframe",
|
|
118
|
+
"audio[controls]",
|
|
119
|
+
"video[controls]",
|
|
120
|
+
"[tabindex]",
|
|
121
|
+
"[contenteditable]"
|
|
122
|
+
].join(",");
|
|
123
|
+
function isRenderedForFocus(element) {
|
|
124
|
+
const check = element.checkVisibility;
|
|
125
|
+
return typeof check === "function" ? check.call(element, { visibilityProperty: true }) : true;
|
|
126
|
+
}
|
|
127
|
+
function authoredTabindex(element) {
|
|
128
|
+
const value = element.getAttribute("tabindex");
|
|
129
|
+
if (value === null || !/^[+-]?\d+$/.test(value.trim())) return null;
|
|
130
|
+
return Number(value);
|
|
131
|
+
}
|
|
132
|
+
function hasNativeTabStop(element) {
|
|
133
|
+
if (element instanceof HTMLAnchorElement || element instanceof HTMLAreaElement) {
|
|
134
|
+
return element.hasAttribute("href");
|
|
135
|
+
}
|
|
136
|
+
if (element instanceof HTMLButtonElement || element instanceof HTMLSelectElement || element instanceof HTMLTextAreaElement) {
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
if (element instanceof HTMLInputElement) return element.type !== "hidden";
|
|
140
|
+
if (element instanceof HTMLIFrameElement) return true;
|
|
141
|
+
if (element.tagName === "AUDIO" || element.tagName === "VIDEO") {
|
|
142
|
+
return element.hasAttribute("controls");
|
|
143
|
+
}
|
|
144
|
+
if (element instanceof HTMLElement && element.tagName === "SUMMARY") {
|
|
145
|
+
const details = element.parentElement;
|
|
146
|
+
return details instanceof HTMLDetailsElement && Array.from(details.children).find((child) => child.tagName === "SUMMARY") === element;
|
|
147
|
+
}
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
function hasEditableTabStop(element) {
|
|
151
|
+
const value = element.getAttribute("contenteditable")?.toLowerCase();
|
|
152
|
+
return value === "" || value === "true" || value === "plaintext-only";
|
|
153
|
+
}
|
|
154
|
+
function isTabStop(element) {
|
|
155
|
+
if (!canTakeFocus(element) || !isRenderedForFocus(element)) return false;
|
|
156
|
+
const tabindex = authoredTabindex(element);
|
|
157
|
+
if (tabindex !== null) return tabindex >= 0;
|
|
158
|
+
return hasNativeTabStop(element) || hasEditableTabStop(element);
|
|
159
|
+
}
|
|
160
|
+
function firstTabStop(root) {
|
|
161
|
+
for (const candidate of root.querySelectorAll(TAB_STOP_CANDIDATE_SELECTOR)) {
|
|
162
|
+
if (isTabStop(candidate)) return candidate;
|
|
163
|
+
}
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// src/utils/microtask_coalescer.ts
|
|
168
|
+
var MicrotaskCoalescer = class {
|
|
169
|
+
#run;
|
|
170
|
+
#queued = false;
|
|
171
|
+
#active = false;
|
|
172
|
+
#generation = 0;
|
|
173
|
+
/** @param run - the single reconciliation pass, invoked at most once per batch. */
|
|
174
|
+
constructor(run) {
|
|
175
|
+
this.#run = run;
|
|
176
|
+
}
|
|
177
|
+
/** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
|
|
178
|
+
activate() {
|
|
179
|
+
this.#active = true;
|
|
180
|
+
}
|
|
181
|
+
/** Closes the window and drops any pending pass; call from `disconnect()`. */
|
|
182
|
+
cancel() {
|
|
183
|
+
this.#active = false;
|
|
184
|
+
this.#queued = false;
|
|
185
|
+
this.#generation += 1;
|
|
186
|
+
}
|
|
187
|
+
/** Requests one pass after the batch settles. Idempotent; inert outside the window. */
|
|
188
|
+
schedule() {
|
|
189
|
+
if (!this.#active || this.#queued) return;
|
|
190
|
+
this.#queued = true;
|
|
191
|
+
const generation = this.#generation;
|
|
192
|
+
queueMicrotask(() => {
|
|
193
|
+
if (generation !== this.#generation || !this.#queued || !this.#active) return;
|
|
194
|
+
this.#queued = false;
|
|
195
|
+
this.#run();
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
// src/utils/tabindex_loan.ts
|
|
201
|
+
var TabindexLoan = class {
|
|
202
|
+
#value;
|
|
203
|
+
#lent = /* @__PURE__ */ new Set();
|
|
204
|
+
/** Returns live loans before Turbo can copy them into its page snapshot. */
|
|
205
|
+
#beforeCache = new BeforeCacheReset(() => this.returnAll());
|
|
206
|
+
/**
|
|
207
|
+
* @param value - the `tabindex` to lend. `"-1"` (the default) is
|
|
208
|
+
* programmatically focusable but not a Tab stop; `"0"` is a real Tab stop,
|
|
209
|
+
* which a scroll region with no focusable content of its own needs.
|
|
210
|
+
*/
|
|
211
|
+
constructor(value = "-1") {
|
|
212
|
+
this.#value = value;
|
|
213
|
+
}
|
|
214
|
+
/** Lends `element` the value; no-ops when it already carries a `tabindex`. */
|
|
215
|
+
lend(element) {
|
|
216
|
+
if (element.hasAttribute("tabindex")) return;
|
|
217
|
+
element.setAttribute("tabindex", this.#value);
|
|
218
|
+
this.#lent.add(element);
|
|
219
|
+
this.#beforeCache.activate();
|
|
220
|
+
}
|
|
221
|
+
/** Takes back every loan whose value is still the one that was lent. */
|
|
222
|
+
returnAll() {
|
|
223
|
+
for (const element of this.#lent) {
|
|
224
|
+
if (element.getAttribute("tabindex") === this.#value) element.removeAttribute("tabindex");
|
|
225
|
+
}
|
|
226
|
+
this.#lent.clear();
|
|
227
|
+
this.#beforeCache.deactivate();
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
// src/controllers/nested_form_controller.ts
|
|
232
|
+
var ROOT_SELECTOR = '[data-controller~="stimeo--nested-form"]';
|
|
233
|
+
var REMOVE_SELECTOR = '[data-stimeo--nested-form-target="remove"]';
|
|
234
|
+
var DESTROY_FLAG_SELECTOR = '[data-stimeo--nested-form-target="destroyFlag"]';
|
|
235
|
+
var DESTROYED_VALUES = /* @__PURE__ */ new Set(["1", "true"]);
|
|
4
236
|
var NestedFormController = class extends Controller {
|
|
5
237
|
static targets = ["list", "template", "add", "remove", "destroyFlag"];
|
|
6
238
|
static values = {
|
|
@@ -11,92 +243,251 @@ var NestedFormController = class extends Controller {
|
|
|
11
243
|
countMessage: { type: String, default: "" }
|
|
12
244
|
};
|
|
13
245
|
static actions = ["add"];
|
|
14
|
-
static events = ["add", "remove"];
|
|
246
|
+
static events = ["add", "remove", "reconcile"];
|
|
15
247
|
/** Monotonic source for unique row indices; never a row-state counter. */
|
|
16
248
|
#lastIndex = 0;
|
|
17
|
-
|
|
249
|
+
#warnedMissing = false;
|
|
250
|
+
#warnedTemplate = false;
|
|
251
|
+
/** The state last written to the hooks; reconciliation reports only real moves. */
|
|
252
|
+
#published = null;
|
|
253
|
+
/** Watches the list for row changes the controller did not perform itself. */
|
|
254
|
+
#observer = null;
|
|
255
|
+
#reconcile = new MicrotaskCoalescer(() => this.#reconcileNow());
|
|
256
|
+
/** Restores the authored add-button `disabled` when a lease ends. */
|
|
257
|
+
#addDisabled = new AttributeLease("disabled");
|
|
258
|
+
#beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
|
|
259
|
+
/** Makes the root a programmatic focus destination when no other candidate survives. */
|
|
260
|
+
#tabindex = new TabindexLoan();
|
|
261
|
+
/**
|
|
262
|
+
* Delegated click handler for the per-row remove buttons (dynamic-safe). Only
|
|
263
|
+
* buttons whose nearest nested-form root is this instance are acted on, so a
|
|
264
|
+
* nested inner form's buttons never remove an outer row.
|
|
265
|
+
*/
|
|
18
266
|
#onClick = (event) => {
|
|
19
267
|
const target = event.target;
|
|
20
|
-
const button = target?.closest(
|
|
21
|
-
if (!button ||
|
|
268
|
+
const button = target?.closest(REMOVE_SELECTOR);
|
|
269
|
+
if (!button || this.#ownerOf(button) !== this.element) return;
|
|
22
270
|
const row = this.#rowContaining(button);
|
|
23
271
|
if (row) this.#removeRow(row);
|
|
24
272
|
};
|
|
25
273
|
connect() {
|
|
274
|
+
this.#warnedMissing = false;
|
|
275
|
+
this.#warnedTemplate = false;
|
|
26
276
|
this.element.addEventListener("click", this.#onClick);
|
|
277
|
+
this.#reconcile.activate();
|
|
278
|
+
this.#beforeCache.activate();
|
|
279
|
+
if (!this.hasListTarget || !this.hasTemplateTarget) this.#warnMissing();
|
|
27
280
|
this.#refresh();
|
|
28
281
|
}
|
|
29
282
|
disconnect() {
|
|
30
283
|
this.element.removeEventListener("click", this.#onClick);
|
|
284
|
+
this.#observer?.disconnect();
|
|
285
|
+
this.#observer = null;
|
|
286
|
+
this.#reconcile.cancel();
|
|
287
|
+
this.#beforeCache.deactivate();
|
|
288
|
+
this.#rewindForCache();
|
|
289
|
+
this.#tabindex.returnAll();
|
|
290
|
+
this.#published = null;
|
|
291
|
+
}
|
|
292
|
+
/** Follows an arriving or swapped-in list: rebind to the primary, then reconcile. */
|
|
293
|
+
listTargetConnected() {
|
|
294
|
+
this.#rebindObserver();
|
|
295
|
+
this.#reconcile.schedule();
|
|
296
|
+
}
|
|
297
|
+
/** Follows a departing list the same way — the primary may have changed. */
|
|
298
|
+
listTargetDisconnected() {
|
|
299
|
+
this.#rebindObserver();
|
|
300
|
+
this.#reconcile.schedule();
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Points the observer at the current primary list. Re-deriving on every list
|
|
304
|
+
* arrival and departure makes the binding independent of the order Stimulus
|
|
305
|
+
* reports an overlapping swap in — a staggered swap (successor appended before
|
|
306
|
+
* the old list leaves) ends observed and reconciled either way.
|
|
307
|
+
*/
|
|
308
|
+
#rebindObserver() {
|
|
309
|
+
this.#observer?.disconnect();
|
|
310
|
+
this.#observer = null;
|
|
311
|
+
if (!this.hasListTarget) return;
|
|
312
|
+
this.#observer = new MutationObserver(() => this.#reconcile.schedule());
|
|
313
|
+
this.#observer.observe(this.listTarget, {
|
|
314
|
+
childList: true,
|
|
315
|
+
subtree: true,
|
|
316
|
+
attributes: true,
|
|
317
|
+
attributeFilter: ["value"]
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
/** Returns the lease with a departing add button; a new one re-arms on refresh. */
|
|
321
|
+
addTargetDisconnected(target) {
|
|
322
|
+
this.#addDisabled.return(target);
|
|
323
|
+
this.#reconcile.schedule();
|
|
324
|
+
}
|
|
325
|
+
addTargetConnected() {
|
|
326
|
+
this.#reconcile.schedule();
|
|
327
|
+
}
|
|
328
|
+
/** Re-clamps when application code or a Turbo morph changes `min`. */
|
|
329
|
+
minValueChanged() {
|
|
330
|
+
this.#reconcile.schedule();
|
|
331
|
+
}
|
|
332
|
+
/** Re-clamps when application code or a Turbo morph changes `max`. */
|
|
333
|
+
maxValueChanged() {
|
|
334
|
+
this.#reconcile.schedule();
|
|
31
335
|
}
|
|
32
336
|
/**
|
|
33
337
|
* Clones the template row, replaces the index placeholder with a unique value,
|
|
34
|
-
* appends it, focuses its first
|
|
35
|
-
* `max
|
|
338
|
+
* appends it, focuses its first tab stop, and announces the new count. No-ops at
|
|
339
|
+
* `max`, when the required targets are missing (named on the console once per
|
|
340
|
+
* connection), or when the template does not produce exactly one root element
|
|
341
|
+
* (also named once; the insertion is rolled back so nothing accumulates).
|
|
36
342
|
*/
|
|
37
343
|
add() {
|
|
38
|
-
if (!this.
|
|
344
|
+
if (!this.hasListTarget || !this.hasTemplateTarget) {
|
|
345
|
+
this.#warnMissing();
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
if (this.#atMax) return;
|
|
39
349
|
const index = this.#nextIndex();
|
|
40
350
|
const markup = this.templateTarget.innerHTML.replaceAll(
|
|
41
351
|
this.indexPlaceholderValue,
|
|
42
352
|
String(index)
|
|
43
353
|
);
|
|
44
|
-
this.listTarget
|
|
45
|
-
const
|
|
46
|
-
|
|
354
|
+
const list = this.listTarget;
|
|
355
|
+
const beforeNodes = list.childNodes.length;
|
|
356
|
+
const beforeElements = list.childElementCount;
|
|
357
|
+
list.insertAdjacentHTML("beforeend", markup);
|
|
358
|
+
const added = Array.from(list.children).slice(beforeElements);
|
|
359
|
+
if (added.length !== 1) {
|
|
360
|
+
while (list.childNodes.length > beforeNodes) list.lastChild?.remove();
|
|
361
|
+
this.#warnBadTemplate(added.length);
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
const row = added[0];
|
|
47
365
|
this.#refresh();
|
|
48
|
-
|
|
366
|
+
firstTabStop(row)?.focus();
|
|
49
367
|
this.dispatch("add", { detail: { index, element: row } });
|
|
50
368
|
this.#announce();
|
|
51
369
|
}
|
|
52
370
|
/**
|
|
53
|
-
* Removes a row: a persisted row (one carrying
|
|
54
|
-
* to `1` and is hidden so Rails destroys it on submit; an unsaved row
|
|
55
|
-
* from the DOM. Returns focus to a
|
|
371
|
+
* Removes a row: a persisted row (one carrying its own `destroyFlag`) has the
|
|
372
|
+
* flag set to `1` and is hidden so Rails destroys it on submit; an unsaved row
|
|
373
|
+
* is dropped from the DOM. Returns focus to a surviving row. No-ops at `min`.
|
|
56
374
|
*/
|
|
57
375
|
#removeRow(row) {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
);
|
|
376
|
+
const rows = this.#effectiveRows;
|
|
377
|
+
if (this.#destroyed(row)) {
|
|
378
|
+
row.hidden = true;
|
|
379
|
+
this.#focusAfterRemove(this.#positionAmong(rows, row));
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
if (rows.length <= this.minValue) return;
|
|
383
|
+
const position = rows.indexOf(row);
|
|
384
|
+
const flag = this.#destroyFlagOf(row);
|
|
65
385
|
const persisted = flag !== null;
|
|
66
|
-
if (
|
|
386
|
+
if (flag) {
|
|
67
387
|
flag.value = "1";
|
|
68
388
|
row.hidden = true;
|
|
69
389
|
} else {
|
|
70
390
|
row.remove();
|
|
71
391
|
}
|
|
72
392
|
this.#refresh();
|
|
73
|
-
|
|
74
|
-
focusTarget?.focus();
|
|
393
|
+
this.#focusAfterRemove(Math.max(0, position));
|
|
75
394
|
this.dispatch("remove", { detail: { element: row, persisted } });
|
|
76
395
|
this.#announce();
|
|
77
396
|
}
|
|
78
|
-
/**
|
|
397
|
+
/**
|
|
398
|
+
* The index of the first effective row following `row` in document order.
|
|
399
|
+
* -1 (no following row) feeds the focus slices as a negative index, which
|
|
400
|
+
* yields the same fully-reversed nearest-first order as `rows.length` would.
|
|
401
|
+
*/
|
|
402
|
+
#positionAmong(rows, row) {
|
|
403
|
+
return rows.findIndex(
|
|
404
|
+
(candidate) => (row.compareDocumentPosition(candidate) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* Moves focus to the first tab stop of the nearest surviving row — following
|
|
409
|
+
* rows first, then preceding ones — falling back to the add button and finally
|
|
410
|
+
* to the root via a temporary `tabindex`.
|
|
411
|
+
*/
|
|
412
|
+
#focusAfterRemove(position) {
|
|
413
|
+
const rows = this.#effectiveRows;
|
|
414
|
+
const ordered = [...rows.slice(position), ...rows.slice(0, position).reverse()];
|
|
415
|
+
for (const row of ordered) {
|
|
416
|
+
const stop = firstTabStop(row);
|
|
417
|
+
if (stop) {
|
|
418
|
+
stop.focus();
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
if (this.hasAddTarget && isTabStop(this.addTarget)) {
|
|
423
|
+
this.addTarget.focus();
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
this.#tabindex.lend(this.element);
|
|
427
|
+
this.element.focus();
|
|
428
|
+
}
|
|
429
|
+
/** Recomputes the count and min/max hooks from the DOM and records them as published. */
|
|
79
430
|
#refresh() {
|
|
431
|
+
if (!this.hasListTarget) return;
|
|
80
432
|
const count = this.#effectiveRows.length;
|
|
433
|
+
const atMin = count <= this.minValue;
|
|
434
|
+
const atMax = this.maxValue > 0 && count >= this.maxValue;
|
|
81
435
|
this.element.setAttribute("data-nested-count", String(count));
|
|
82
|
-
this.#reflect("data-nested-at-max",
|
|
83
|
-
this.#reflect("data-nested-at-min",
|
|
84
|
-
if (this.hasAddTarget)
|
|
436
|
+
this.#reflect("data-nested-at-max", atMax);
|
|
437
|
+
this.#reflect("data-nested-at-min", atMin);
|
|
438
|
+
if (this.hasAddTarget) {
|
|
439
|
+
if (this.maxValue > 0) this.#addDisabled.write(this.addTarget, atMax ? "" : null);
|
|
440
|
+
else this.#addDisabled.return(this.addTarget);
|
|
441
|
+
}
|
|
442
|
+
this.#published = { count, atMin, atMax };
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* Applies row changes the controller did not perform itself (Turbo Streams,
|
|
446
|
+
* morphs, runtime Value changes): refreshes the hooks and reports a moved
|
|
447
|
+
* public state as `reconcile`. The controller's own operations refresh
|
|
448
|
+
* synchronously first, so their observer echo arrives here as a no-move.
|
|
449
|
+
*/
|
|
450
|
+
#reconcileNow() {
|
|
451
|
+
const previous = this.#published;
|
|
452
|
+
this.#refresh();
|
|
453
|
+
const current = this.#published;
|
|
454
|
+
if (!previous || !current) return;
|
|
455
|
+
const moved = previous.count !== current.count || previous.atMin !== current.atMin || previous.atMax !== current.atMax;
|
|
456
|
+
if (moved) this.dispatch("reconcile", { detail: { ...current } });
|
|
85
457
|
}
|
|
86
458
|
/** Bridges the count change to the shared announcer when configured. */
|
|
87
459
|
#announce() {
|
|
88
|
-
if (!this.announceValue ||
|
|
89
|
-
|
|
90
|
-
"{count}",
|
|
91
|
-
String(this.#effectiveRows.length)
|
|
92
|
-
);
|
|
93
|
-
window.dispatchEvent(new CustomEvent("stimeo--announcer:announce", { detail: { message } }));
|
|
460
|
+
if (!this.announceValue || this.countMessageValue === "") return;
|
|
461
|
+
announce(fillTemplate(this.countMessageValue, { count: this.#effectiveRows.length }));
|
|
94
462
|
}
|
|
95
463
|
/** Sets `attribute` to `"true"` when `on`, else removes it. */
|
|
96
464
|
#reflect(attribute, on) {
|
|
97
465
|
if (on) this.element.setAttribute(attribute, "true");
|
|
98
466
|
else this.element.removeAttribute(attribute);
|
|
99
467
|
}
|
|
468
|
+
/** Returns the disabled lease so an authored value never leaks into a snapshot. */
|
|
469
|
+
#rewindForCache() {
|
|
470
|
+
if (this.hasAddTarget) this.#addDisabled.return(this.addTarget);
|
|
471
|
+
}
|
|
472
|
+
/** Names the missing required target(s) once per connection. */
|
|
473
|
+
#warnMissing() {
|
|
474
|
+
if (this.#warnedMissing) return;
|
|
475
|
+
this.#warnedMissing = true;
|
|
476
|
+
const missing = [
|
|
477
|
+
this.hasListTarget ? null : 'a "list" target',
|
|
478
|
+
this.hasTemplateTarget ? null : 'a "template" target'
|
|
479
|
+
].filter((part) => part !== null).join(" and ");
|
|
480
|
+
console.warn(
|
|
481
|
+
`Stimeo UI: "${this.identifier}" cannot manage rows because its markup lacks ${missing}.`
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
/** Names a template that does not produce exactly one element, once per connection. */
|
|
485
|
+
#warnBadTemplate(produced) {
|
|
486
|
+
if (this.#warnedTemplate) return;
|
|
487
|
+
this.#warnedTemplate = true;
|
|
488
|
+
const reason = produced === 0 ? "produces no element" : "must produce exactly one root element";
|
|
489
|
+
console.warn(`Stimeo UI: "${this.identifier}" added no row because its template ${reason}.`);
|
|
490
|
+
}
|
|
100
491
|
/** A strictly-increasing unique index (collision-free even on rapid adds). */
|
|
101
492
|
#nextIndex() {
|
|
102
493
|
const index = Math.max(Date.now(), this.#lastIndex + 1);
|
|
@@ -106,24 +497,41 @@ var NestedFormController = class extends Controller {
|
|
|
106
497
|
get #atMax() {
|
|
107
498
|
return this.maxValue > 0 && this.#effectiveRows.length >= this.maxValue;
|
|
108
499
|
}
|
|
109
|
-
/**
|
|
500
|
+
/**
|
|
501
|
+
* Direct child rows of the list whose own destroy flag is not set. Callers
|
|
502
|
+
* reach this only behind a list-presence gate (`add`, `#refresh`, and the
|
|
503
|
+
* click path through `#rowContaining`).
|
|
504
|
+
*/
|
|
110
505
|
get #effectiveRows() {
|
|
111
|
-
return Array.from(this.listTarget.children).filter(
|
|
506
|
+
return Array.from(this.listTarget.children).filter(
|
|
507
|
+
(row) => !this.#destroyed(row)
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
/** Whether `row` is flagged for destruction; the flag value is the truth source. */
|
|
511
|
+
#destroyed(row) {
|
|
512
|
+
const flag = this.#destroyFlagOf(row);
|
|
513
|
+
return flag !== null && DESTROYED_VALUES.has(flag.value);
|
|
514
|
+
}
|
|
515
|
+
/** The row's own destroy flag, skipping flags owned by a nested inner form. */
|
|
516
|
+
#destroyFlagOf(row) {
|
|
517
|
+
for (const flag of row.querySelectorAll(DESTROY_FLAG_SELECTOR)) {
|
|
518
|
+
if (this.#ownerOf(flag) === this.element) return flag;
|
|
519
|
+
}
|
|
520
|
+
return null;
|
|
521
|
+
}
|
|
522
|
+
/** The nearest nested-form root that owns `el`. */
|
|
523
|
+
#ownerOf(el) {
|
|
524
|
+
return el.closest(ROOT_SELECTOR);
|
|
112
525
|
}
|
|
113
526
|
/** The nearest ancestor of `el` that is a direct child of the list, else null. */
|
|
114
527
|
#rowContaining(el) {
|
|
528
|
+
if (!this.hasListTarget) return null;
|
|
115
529
|
let node = el;
|
|
116
530
|
while (node && node.parentElement !== this.listTarget) {
|
|
117
531
|
node = node.parentElement;
|
|
118
532
|
}
|
|
119
533
|
return node;
|
|
120
534
|
}
|
|
121
|
-
/** First visible focusable control inside `row` (skips hidden inputs). */
|
|
122
|
-
#firstControl(row) {
|
|
123
|
-
return row.querySelector(
|
|
124
|
-
'input:not([type="hidden"]), select, textarea, button, [tabindex]'
|
|
125
|
-
);
|
|
126
|
-
}
|
|
127
535
|
};
|
|
128
536
|
|
|
129
537
|
export { NestedFormController };
|