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,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
- /** Delegated click handler for the per-row remove buttons (dynamic-safe). */
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('[data-stimeo--nested-form-target="remove"]');
21
- if (!button || !this.element.contains(button)) return;
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 control, and announces the new count. No-ops at
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.hasTemplateTarget || !this.hasListTarget || this.#atMax) return;
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.insertAdjacentHTML("beforeend", markup);
45
- const row = this.listTarget.lastElementChild;
46
- if (!row) return;
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
- this.#firstControl(row)?.focus();
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 a `destroyFlag`) has its flag set
54
- * to `1` and is hidden so Rails destroys it on submit; an unsaved row is dropped
55
- * from the DOM. Returns focus to a neighboring row. No-ops at `min`.
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
- if (this.#effectiveRows.length <= this.minValue) return;
59
- const neighbors = this.#effectiveRows;
60
- const position = neighbors.indexOf(row);
61
- const neighbor = neighbors[position + 1] ?? neighbors[position - 1] ?? null;
62
- const flag = row.querySelector(
63
- '[data-stimeo--nested-form-target="destroyFlag"]'
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 (persisted) {
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
- const focusTarget = neighbor ? this.#firstControl(neighbor) : this.hasAddTarget ? this.addTarget : null;
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
- /** Recomputes the live count and the min/max state hooks from the DOM. */
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", this.maxValue > 0 && count >= this.maxValue);
83
- this.#reflect("data-nested-at-min", count <= this.minValue);
84
- if (this.hasAddTarget) this.addTarget.disabled = this.#atMax;
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 || !this.countMessageValue) return;
89
- const message = this.countMessageValue.replaceAll(
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
- /** Direct child rows of the list that are not flagged for destruction. */
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((row) => !row.hidden);
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 };