stimeo-ui 0.16.0 → 0.17.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.
Files changed (115) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +31 -0
  3. data/README.md +8 -1
  4. data/dist/cable/index.js +363 -436
  5. data/dist/controllers/accordion_controller.js +166 -55
  6. data/dist/controllers/alert_dialog_controller.js +862 -221
  7. data/dist/controllers/announcer_controller.js +135 -198
  8. data/dist/controllers/aspect_ratio_controller.js +0 -10
  9. data/dist/controllers/auto_submit_controller.js +271 -127
  10. data/dist/controllers/avatar_controller.js +171 -105
  11. data/dist/controllers/breadcrumb_controller.js +0 -130
  12. data/dist/controllers/bulk_select_controller.js +262 -52
  13. data/dist/controllers/calendar_controller.js +175 -209
  14. data/dist/controllers/carousel_controller.js +461 -399
  15. data/dist/controllers/character_counter_controller.js +246 -156
  16. data/dist/controllers/checkbox_controller.js +32 -74
  17. data/dist/controllers/clipboard_controller.js +168 -147
  18. data/dist/controllers/collapsible_controller.js +301 -120
  19. data/dist/controllers/color_picker_controller.js +149 -139
  20. data/dist/controllers/combobox_controller.js +360 -133
  21. data/dist/controllers/command_palette_controller.js +970 -358
  22. data/dist/controllers/conditional_fields_controller.js +166 -124
  23. data/dist/controllers/confirm_controller.js +939 -248
  24. data/dist/controllers/context_menu_controller.js +256 -128
  25. data/dist/controllers/count_up_controller.js +73 -21
  26. data/dist/controllers/countdown_controller.js +183 -148
  27. data/dist/controllers/currency_input_controller.js +134 -158
  28. data/dist/controllers/data_grid_controller.js +114 -186
  29. data/dist/controllers/date_range_picker_controller.js +288 -206
  30. data/dist/controllers/dialog_controller.js +858 -223
  31. data/dist/controllers/direct_upload_controller.js +73 -153
  32. data/dist/controllers/dirty_form_controller.js +14 -48
  33. data/dist/controllers/dismissible_controller.js +175 -21
  34. data/dist/controllers/drawer_controller.js +933 -341
  35. data/dist/controllers/dropdown_controller.js +312 -82
  36. data/dist/controllers/editable_controller.js +158 -68
  37. data/dist/controllers/empty_state_controller.js +67 -53
  38. data/dist/controllers/file_dropzone_controller.js +311 -207
  39. data/dist/controllers/filter_controller.js +67 -39
  40. data/dist/controllers/flash_controller.js +172 -314
  41. data/dist/controllers/focus_controller.js +654 -240
  42. data/dist/controllers/form_field_controller.js +147 -132
  43. data/dist/controllers/form_validation_controller.js +13 -86
  44. data/dist/controllers/frame_loading_controller.js +264 -213
  45. data/dist/controllers/highlight_controller.js +107 -75
  46. data/dist/controllers/hover_card_controller.js +309 -118
  47. data/dist/controllers/idle_controller.js +267 -101
  48. data/dist/controllers/input_mask_controller.js +71 -72
  49. data/dist/controllers/intersection_controller.js +147 -106
  50. data/dist/controllers/lazy_frame_controller.js +9 -65
  51. data/dist/controllers/listbox_controller.js +306 -204
  52. data/dist/controllers/local_time_controller.js +54 -64
  53. data/dist/controllers/masonry_controller.js +70 -93
  54. data/dist/controllers/menu_controller.js +214 -156
  55. data/dist/controllers/menubar_controller.js +170 -329
  56. data/dist/controllers/meter_controller.js +151 -67
  57. data/dist/controllers/multi_select_controller.js +593 -358
  58. data/dist/controllers/navigation_menu_controller.js +124 -232
  59. data/dist/controllers/nested_form_controller.js +352 -182
  60. data/dist/controllers/network_status_controller.js +242 -54
  61. data/dist/controllers/number_input_controller.js +320 -263
  62. data/dist/controllers/optimistic_controller.js +16 -76
  63. data/dist/controllers/otp_controller.js +198 -193
  64. data/dist/controllers/overflow_indicator_controller.js +116 -113
  65. data/dist/controllers/overflow_menu_controller.js +343 -283
  66. data/dist/controllers/pagination_controller.js +295 -119
  67. data/dist/controllers/password_reveal_controller.js +389 -121
  68. data/dist/controllers/password_strength_controller.js +99 -153
  69. data/dist/controllers/persist_controller.js +237 -103
  70. data/dist/controllers/pointer_drag_controller.js +185 -165
  71. data/dist/controllers/popover_controller.js +236 -97
  72. data/dist/controllers/portal_controller.js +209 -76
  73. data/dist/controllers/preview_guard_controller.js +232 -116
  74. data/dist/controllers/progress_controller.js +125 -56
  75. data/dist/controllers/radio_group_controller.js +117 -137
  76. data/dist/controllers/range_slider_controller.js +192 -105
  77. data/dist/controllers/rating_controller.js +418 -207
  78. data/dist/controllers/read_more_controller.js +134 -63
  79. data/dist/controllers/reading_progress_controller.js +147 -135
  80. data/dist/controllers/relative_time_controller.js +145 -110
  81. data/dist/controllers/{reset_before_cache_controller.js → reset_on_restore_controller.js} +97 -38
  82. data/dist/controllers/resizable_controller.js +255 -139
  83. data/dist/controllers/roving_controller.js +39 -84
  84. data/dist/controllers/scroll_area_controller.js +428 -225
  85. data/dist/controllers/scroll_restore_controller.js +0 -53
  86. data/dist/controllers/scroll_visibility_controller.js +395 -145
  87. data/dist/controllers/scrollspy_controller.js +181 -242
  88. data/dist/controllers/separator_controller.js +267 -140
  89. data/dist/controllers/sidebar_controller.js +1023 -386
  90. data/dist/controllers/skeleton_controller.js +248 -97
  91. data/dist/controllers/slider_controller.js +159 -79
  92. data/dist/controllers/smart_sticky_header_controller.js +162 -63
  93. data/dist/controllers/sortable_controller.js +0 -112
  94. data/dist/controllers/spinner_controller.js +277 -169
  95. data/dist/controllers/step_indicator_controller.js +99 -46
  96. data/dist/controllers/stepper_controller.js +114 -73
  97. data/dist/controllers/stick_to_bottom_controller.js +262 -138
  98. data/dist/controllers/sticky_observer_controller.js +214 -49
  99. data/dist/controllers/submit_once_controller.js +294 -205
  100. data/dist/controllers/switch_controller.js +33 -60
  101. data/dist/controllers/tabs_controller.js +35 -29
  102. data/dist/controllers/tags_input_controller.js +136 -145
  103. data/dist/controllers/textarea_autosize_controller.js +71 -58
  104. data/dist/controllers/theme_controller.js +130 -112
  105. data/dist/controllers/time_picker_controller.js +146 -80
  106. data/dist/controllers/toast_controller.js +186 -297
  107. data/dist/controllers/toggle_group_controller.js +95 -136
  108. data/dist/controllers/toolbar_controller.js +68 -98
  109. data/dist/controllers/tooltip_controller.js +158 -124
  110. data/dist/controllers/transition_controller.js +123 -119
  111. data/dist/controllers/tree_view_controller.js +150 -345
  112. data/dist/index.js +6370 -9346
  113. data/dist/positioning/index.js +148 -47
  114. data/lib/stimeo/ui/version.rb +1 -1
  115. metadata +3 -3
@@ -1,8 +1,5 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
 
3
- // src/controllers/tags_input_controller.ts
4
-
5
- // src/utils/announce.ts
6
3
  function announce(message, options = {}) {
7
4
  const text = message.trim();
8
5
  if (text.length === 0) return;
@@ -19,12 +16,10 @@ function fillTemplate(template, values) {
19
16
  });
20
17
  }
21
18
 
22
- // src/utils/logical_scroll.ts
23
19
  function isRtl(element) {
24
20
  return window.getComputedStyle(element).direction === "rtl";
25
21
  }
26
22
 
27
- // src/utils/arrow_step.ts
28
23
  function logicalArrowKey(key, element) {
29
24
  if (key !== "ArrowRight" && key !== "ArrowLeft") return key;
30
25
  if (!isRtl(element)) return key;
@@ -35,7 +30,6 @@ function isReservedArrowChord(event, allow = []) {
35
30
  return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
36
31
  }
37
32
 
38
- // src/utils/element_part.ts
39
33
  function matchingPart(root, selector) {
40
34
  return root.matches(selector) ? root : root.querySelector(selector);
41
35
  }
@@ -54,31 +48,14 @@ function writeLabel(slot, text) {
54
48
  else slot.prepend(text);
55
49
  }
56
50
 
57
- // src/utils/roving_tabindex.ts
58
51
  var RovingTabindex = class {
59
- /** Returns the current ordered item elements; called on every operation. */
60
52
  #getItems;
61
- /**
62
- * @param getItems - Returns the current ordered item elements. Called on every
63
- * operation so the live target list is always used.
64
- */
65
53
  constructor(getItems) {
66
54
  this.#getItems = getItems;
67
55
  }
68
- /** Index of the currently tabbable item (`tabindex="0"`), or `-1` if none. */
69
56
  get activeIndex() {
70
57
  return this.#getItems().findIndex((item) => item.tabIndex === 0);
71
58
  }
72
- /**
73
- * Makes exactly the item at `index` tabbable (`tabindex="0"`) and removes every
74
- * other item from the Tab sequence (`tabindex="-1"`). An out-of-range `index`
75
- * (e.g. `-1`) leaves all items at `-1`, which a controller can use to express
76
- * "nothing is currently tabbable".
77
- *
78
- * @param index - Position of the item to make tabbable.
79
- * @param options - Pass `{ focus: true }` to also move DOM focus to that item,
80
- * and `items` to reuse an event-scoped collection snapshot.
81
- */
82
59
  setActive(index, options = {}) {
83
60
  const { focus = false } = options;
84
61
  const items = options.items ?? this.#getItems();
@@ -89,7 +66,6 @@ var RovingTabindex = class {
89
66
  }
90
67
  };
91
68
 
92
- // src/utils/chip_row.ts
93
69
  var ChipRow = class {
94
70
  #directionElement;
95
71
  #getItems;
@@ -105,10 +81,6 @@ var ChipRow = class {
105
81
  this.#onRemove = options.onRemove;
106
82
  this.#focusAfterEnd = options.focusAfterEnd;
107
83
  }
108
- /**
109
- * Binds delegation to `container`; any row currently bound is released first.
110
- * Idempotent for the same node, so target callbacks may call it freely.
111
- */
112
84
  connect(container) {
113
85
  if (this.#container === container) return;
114
86
  this.disconnect();
@@ -116,10 +88,6 @@ var ChipRow = class {
116
88
  container.addEventListener("click", this.#onClick);
117
89
  container.addEventListener("keydown", this.#onKeydown);
118
90
  }
119
- /**
120
- * Releases the current row. When `container` is supplied, a stale disconnect
121
- * callback cannot tear listeners off a newer replacement target.
122
- */
123
91
  disconnect(container) {
124
92
  const current = this.#container;
125
93
  if (!current || container !== void 0 && current !== container) return;
@@ -127,19 +95,15 @@ var ChipRow = class {
127
95
  current.removeEventListener("keydown", this.#onKeydown);
128
96
  this.#container = null;
129
97
  }
130
- /** Current remove buttons, at most one consumer-resolved button per chip item. */
131
98
  get buttons() {
132
99
  return this.#entries.map(({ button }) => button);
133
100
  }
134
- /** Number of currently navigable remove buttons. */
135
101
  get length() {
136
102
  return this.buttons.length;
137
103
  }
138
- /** Declared-item index of the last navigable chip, or `-1` for an empty row. */
139
104
  get lastIndex() {
140
105
  return this.#entries.at(-1)?.itemIndex ?? -1;
141
106
  }
142
- /** Keeps exactly one button tabbable, preferring the first authored Tab stop. */
143
107
  ensureTabStop() {
144
108
  const buttons = this.#entries.map(({ button }) => button);
145
109
  const active = buttons.findIndex((button) => button.tabIndex === 0);
@@ -148,7 +112,6 @@ var ChipRow = class {
148
112
  this.#roving.setActive(active === -1 ? 0 : active, { items: buttons });
149
113
  }
150
114
  }
151
- /** Focuses the last chip, returning false for an empty row. */
152
115
  focusLast() {
153
116
  const buttons = this.#entries.map(({ button }) => button);
154
117
  const last = buttons.length - 1;
@@ -156,10 +119,6 @@ var ChipRow = class {
156
119
  this.#roving.setActive(last, { focus: true, items: buttons });
157
120
  return true;
158
121
  }
159
- /**
160
- * Focuses the chip that followed a removed index, or the new last chip.
161
- * Returns false when removal emptied the row so the consumer can rescue focus.
162
- */
163
122
  focusAfterRemoval(index) {
164
123
  const entries = this.#entries;
165
124
  if (entries.length === 0) return false;
@@ -170,14 +129,12 @@ var ChipRow = class {
170
129
  });
171
130
  return true;
172
131
  }
173
- /** Current remove-button entries, retaining each button's declared item index. */
174
132
  get #entries() {
175
133
  return this.#getItems().flatMap((item, itemIndex) => {
176
134
  const button = this.#getButton(item);
177
135
  return button ? [{ button, itemIndex }] : [];
178
136
  });
179
137
  }
180
- /** Resolves a delegated event to the remove button owned by a declared chip. */
181
138
  #entry(event) {
182
139
  const target = event.target;
183
140
  if (!(target instanceof Element)) return null;
@@ -188,13 +145,11 @@ var ChipRow = class {
188
145
  const entry = entries[buttonIndex];
189
146
  return { ...entry, buttonIndex, buttons: entries.map(({ button: button2 }) => button2) };
190
147
  }
191
- /** Delegates removal clicks without waiting for Stimulus to wire a new chip. */
192
148
  #onClick = (event) => {
193
149
  const entry = this.#entry(event);
194
150
  if (!entry) return;
195
151
  this.#onRemove(entry.itemIndex);
196
152
  };
197
- /** Applies the shared logical-arrow and removal policy within the chip row. */
198
153
  #onKeydown = (event) => {
199
154
  if (event.defaultPrevented || isReservedArrowChord(event)) return;
200
155
  const entry = this.#entry(event);
@@ -225,7 +180,6 @@ var ChipRow = class {
225
180
  };
226
181
  };
227
182
 
228
- // src/utils/composition_tracker.ts
229
183
  var COMPOSITION_INPUT_TYPES = /* @__PURE__ */ new Set([
230
184
  "insertCompositionText",
231
185
  "insertFromComposition",
@@ -237,13 +191,11 @@ var CompositionTracker = class {
237
191
  #activeTargets = /* @__PURE__ */ new Set();
238
192
  #onStart;
239
193
  #onEnd;
240
- /** The field whose confirming `input` is still owed, while the window is open. */
241
194
  #confirmedTarget = null;
242
195
  constructor(options = {}) {
243
196
  this.#onStart = options.onStart;
244
197
  this.#onEnd = options.onEnd;
245
198
  }
246
- /** Starts lifecycle tracking for `target`; repeated calls are idempotent. */
247
199
  observe(target) {
248
200
  if (this.#observedTargets.has(target)) return;
249
201
  target.addEventListener("compositionstart", this.#handleStart);
@@ -251,7 +203,6 @@ var CompositionTracker = class {
251
203
  target.addEventListener("keydown", this.#handleKeydown);
252
204
  this.#observedTargets.add(target);
253
205
  }
254
- /** Stops tracking one target and clears any active composition it owned. */
255
206
  unobserve(target) {
256
207
  if (!this.#observedTargets.delete(target)) return;
257
208
  target.removeEventListener("compositionstart", this.#handleStart);
@@ -260,7 +211,6 @@ var CompositionTracker = class {
260
211
  this.#activeTargets.delete(target);
261
212
  if (this.#confirmedTarget === target) this.#confirmedTarget = null;
262
213
  }
263
- /** Releases every listener and clears state so reconnect starts cleanly. */
264
214
  disconnect() {
265
215
  for (const target of this.#observedTargets) {
266
216
  target.removeEventListener("compositionstart", this.#handleStart);
@@ -271,16 +221,9 @@ var CompositionTracker = class {
271
221
  this.#activeTargets.clear();
272
222
  this.#confirmedTarget = null;
273
223
  }
274
- /** True when lifecycle tracking or the current event reports composition. */
275
224
  isComposing(event) {
276
225
  return this.#activeTargets.size > 0 || event?.isComposing === true;
277
226
  }
278
- /**
279
- * Whether `event` is the `input` echoing the composition just confirmed.
280
- *
281
- * Asking closes the window either way, so one confirmation is folded at most
282
- * once and a consumer asks once per `input`.
283
- */
284
227
  consumesConfirmedInput(event) {
285
228
  const confirmed = this.#confirmedTarget;
286
229
  this.#confirmedTarget = null;
@@ -298,13 +241,11 @@ var CompositionTracker = class {
298
241
  this.#confirmedTarget = event.target;
299
242
  this.#onEnd?.(event);
300
243
  };
301
- /** A key on an observed field opens an edit of its own, so no echo is owed. */
302
244
  #handleKeydown = () => {
303
245
  this.#confirmedTarget = null;
304
246
  };
305
247
  };
306
248
 
307
- // src/utils/field_mirror.ts
308
249
  function writeFields(container, values, { name, form = "" }) {
309
250
  const current = [...container.children];
310
251
  const submits = current.length === values.length && current.every(
@@ -327,27 +268,33 @@ function commitField(target) {
327
268
  target.dispatchEvent(new Event("change", { bubbles: true }));
328
269
  }
329
270
 
330
- // src/utils/microtask_coalescer.ts
271
+ var ListenerSet = class {
272
+ #abort = new AbortController();
273
+ add(target, type, handler, options) {
274
+ target.addEventListener(type, handler, { ...options, signal: this.#abort.signal });
275
+ }
276
+ dispose() {
277
+ this.#abort.abort();
278
+ this.#abort = new AbortController();
279
+ }
280
+ };
281
+
331
282
  var MicrotaskCoalescer = class {
332
283
  #run;
333
284
  #queued = false;
334
285
  #active = false;
335
286
  #generation = 0;
336
- /** @param run - the single reconciliation pass, invoked at most once per batch. */
337
287
  constructor(run) {
338
288
  this.#run = run;
339
289
  }
340
- /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
341
290
  activate() {
342
291
  this.#active = true;
343
292
  }
344
- /** Closes the window and drops any pending pass; call from `disconnect()`. */
345
293
  cancel() {
346
294
  this.#active = false;
347
295
  this.#queued = false;
348
296
  this.#generation += 1;
349
297
  }
350
- /** Requests one pass after the batch settles. Idempotent; inert outside the window. */
351
298
  schedule() {
352
299
  if (!this.#active || this.#queued) return;
353
300
  this.#queued = true;
@@ -360,12 +307,90 @@ var MicrotaskCoalescer = class {
360
307
  }
361
308
  };
362
309
 
363
- // src/utils/target_selector.ts
310
+ var MorphRenderWatcher = class {
311
+ #listeners = new ListenerSet();
312
+ #pass;
313
+ #element = null;
314
+ #onMorph = () => this.#pass.schedule();
315
+ constructor(run) {
316
+ this.#pass = new MicrotaskCoalescer(run);
317
+ }
318
+ observe(element) {
319
+ if (this.#element === element) return;
320
+ this.disconnect();
321
+ this.#element = element;
322
+ this.#pass.activate();
323
+ this.#listeners.add(element, "turbo:morph-element", this.#onMorph);
324
+ }
325
+ schedule() {
326
+ this.#pass.schedule();
327
+ }
328
+ disconnect() {
329
+ this.#listeners.dispose();
330
+ this.#pass.cancel();
331
+ this.#element = null;
332
+ }
333
+ };
334
+
335
+ var MoveCounter = class {
336
+ #sequence = 0;
337
+ record() {
338
+ this.#sequence += 1;
339
+ return this.#sequence;
340
+ }
341
+ isLatest(token) {
342
+ return token === this.#sequence;
343
+ }
344
+ };
345
+
346
+ var NUMBER_BOUNDS = {
347
+ nonNegative: { finite: true, min: 0 }};
348
+ function matchesNumberBounds(value, bounds) {
349
+ if (!Number.isFinite(value)) {
350
+ const direction = value === Infinity ? "positive" : value === -Infinity ? "negative" : null;
351
+ if (direction === null) return false;
352
+ if (bounds.allowInfinity !== "both" && bounds.allowInfinity !== direction) return false;
353
+ }
354
+ if (bounds.min !== void 0 && value < bounds.min) return false;
355
+ if (bounds.max !== void 0 && value > bounds.max) return false;
356
+ if (bounds.exclusiveMin !== void 0 && value <= bounds.exclusiveMin) return false;
357
+ if (bounds.integer && !Number.isInteger(value)) return false;
358
+ if (bounds.allowedValues !== void 0 && !bounds.allowedValues.includes(value)) return false;
359
+ return true;
360
+ }
361
+
362
+ function readNumber(raw, fallback, bounds) {
363
+ return matchesNumberBounds(raw, bounds) ? raw : fallback;
364
+ }
365
+
366
+ var NumberValueReader = class {
367
+ #lastRejected = /* @__PURE__ */ new Map();
368
+ read(owner, name, raw, fallback, bounds) {
369
+ const resolved = readNumber(raw, fallback, bounds);
370
+ if (matchesNumberBounds(raw, bounds)) {
371
+ this.#lastRejected.delete(name);
372
+ return resolved;
373
+ }
374
+ const attribute = `data-${owner.identifier}-${name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}-value`;
375
+ const literal = owner.element.getAttribute(attribute);
376
+ if (literal === null) {
377
+ this.#lastRejected.delete(name);
378
+ return resolved;
379
+ }
380
+ if (this.#lastRejected.get(name) !== literal) {
381
+ this.#lastRejected.set(name, literal);
382
+ console.warn(
383
+ `Stimeo UI: "${owner.identifier}" has an invalid number Value "${name}" declaration ${JSON.stringify(literal)}; using ${fallback}.`
384
+ );
385
+ }
386
+ return resolved;
387
+ }
388
+ };
389
+
364
390
  function targetSelector(identifier, name) {
365
391
  return `[data-${identifier}-target~="${name}"]`;
366
392
  }
367
393
 
368
- // src/utils/template_row.ts
369
394
  function cloneTemplateRoot(template) {
370
395
  const root = template.content.firstElementChild;
371
396
  return root instanceof HTMLElement ? root.cloneNode(true) : null;
@@ -376,22 +401,15 @@ var TemplateRow = class {
376
401
  constructor(options) {
377
402
  this.#options = options;
378
403
  }
379
- /** The attribute selector for one declared part, in this controller's namespace. */
380
404
  selector(name) {
381
405
  return targetSelector(this.#options.identifier, name);
382
406
  }
383
- /** Re-arms the once-per-connection diagnostic. */
384
407
  connect() {
385
408
  this.#warned = false;
386
409
  }
387
- /**
388
- * Names one missing part on the console, at most once per connection, and
389
- * returns `null` so a caller can hand it straight back.
390
- */
391
410
  report(missing) {
392
411
  return this.#say(`lacks ${missing}`);
393
412
  }
394
- /** Writes one diagnostic per connection, saying what the template got wrong. */
395
413
  #say(problem) {
396
414
  if (this.#warned) return null;
397
415
  this.#warned = true;
@@ -399,10 +417,6 @@ var TemplateRow = class {
399
417
  console.warn(`Stimeo UI: "${identifier}" ${outcome} because its ${noun} ${problem}.`);
400
418
  return null;
401
419
  }
402
- /**
403
- * Clones the row and resolves its parts, or names what the template got wrong
404
- * and returns `null`. `values` fills the button's authored `aria-label`.
405
- */
406
420
  instantiate(template, values) {
407
421
  const { root: rootName, required, optional, button: buttonName } = this.#options;
408
422
  const root = cloneTemplateRoot(template);
@@ -430,7 +444,6 @@ var TemplateRow = class {
430
444
  button.setAttribute("aria-label", fillTemplate(name, values));
431
445
  return { root, slots, button };
432
446
  }
433
- /** The row may be its own part; otherwise the first descendant carrying the target. */
434
447
  #resolve(root, name) {
435
448
  return matchingPart(root, this.selector(name));
436
449
  }
@@ -439,8 +452,9 @@ var TemplateRow = class {
439
452
  }
440
453
  };
441
454
 
442
- // src/controllers/tags_input_controller.ts
443
- var TagsInputController = class extends Controller {
455
+ var TagsInputController = class _TagsInputController extends Controller {
456
+ #numbers = new NumberValueReader();
457
+ #moves = new MoveCounter();
444
458
  static targets = ["input", "tags", "tag", "tagTemplate", "label", "remove", "fields"];
445
459
  static values = {
446
460
  delimiter: { type: String, default: "," },
@@ -451,11 +465,12 @@ var TagsInputController = class extends Controller {
451
465
  announceText: { type: String, default: "" },
452
466
  announceRemovedText: { type: String, default: "" }
453
467
  };
468
+ static valueConstraints = {
469
+ max: NUMBER_BOUNDS.nonNegative
470
+ };
454
471
  static actions = ["onKeydown"];
455
472
  static events = ["change", "reconcile", "reject"];
456
- /** Last reconciled tag order, separating user edits from DOM/Turbo repair. */
457
473
  #tagValues = [];
458
- /** Builds one chip from the authored template and owns its diagnostic. */
459
474
  #rows = new TemplateRow({
460
475
  identifier: this.identifier,
461
476
  root: "tag",
@@ -464,8 +479,7 @@ var TagsInputController = class extends Controller {
464
479
  outcome: "added no tag",
465
480
  noun: "chip template"
466
481
  });
467
- /** Collapses one target/Value mutation batch into one final-DOM repair pass. */
468
- #reconcile = new MicrotaskCoalescer(() => this.#reconcileTags());
482
+ #reconcile = new MorphRenderWatcher(() => this.#reconcileTags());
469
483
  #chipRow = new ChipRow({
470
484
  directionElement: this.element,
471
485
  getItems: () => this.tagTargets,
@@ -473,9 +487,7 @@ var TagsInputController = class extends Controller {
473
487
  onRemove: (index) => this.#removeAt(index),
474
488
  focusAfterEnd: () => this.#focusInput()
475
489
  });
476
- /** Owns IME lifecycle state for commit-key guarding. */
477
490
  #composition = new CompositionTracker();
478
- /** Wires tag-list keyboard navigation and removal, and seeds the single Tab stop. */
479
491
  connect() {
480
492
  this.#rows.connect();
481
493
  if (this.hasInputTarget) this.#composition.observe(this.inputTarget);
@@ -483,56 +495,47 @@ var TagsInputController = class extends Controller {
483
495
  const tags = this.#values;
484
496
  this.#syncState(tags);
485
497
  this.#tagValues = tags;
486
- this.#reconcile.activate();
498
+ this.#reconcile.observe(this.element);
487
499
  }
488
- /** Releases the delegated listeners so no handler outlives the element. */
489
500
  disconnect() {
490
- this.#reconcile.cancel();
501
+ this.#reconcile.disconnect();
491
502
  this.#composition.disconnect();
492
503
  this.#chipRow.disconnect();
493
504
  }
494
- /** Tracks an input added initially or after connect. */
495
505
  inputTargetConnected(input) {
496
506
  this.#composition.observe(input);
497
507
  }
498
- /** Removes composition listeners when the active input is replaced or removed. */
499
508
  inputTargetDisconnected(input) {
500
509
  this.#composition.unobserve(input);
501
510
  }
502
- /** Rebinds delegation and derived state when the tags container is replaced. */
503
511
  tagsTargetConnected(_tags) {
504
512
  this.#reconcile.schedule();
505
513
  }
506
- /** Releases only the row that disconnected, leaving a newer target intact. */
507
514
  tagsTargetDisconnected(tags) {
508
515
  this.#chipRow.disconnect(tags);
509
516
  this.#reconcile.schedule();
510
517
  }
511
- /** Reconciles a tag inserted by Turbo, a server render, or another controller. */
512
518
  tagTargetConnected(_tag) {
513
519
  this.#reconcile.schedule();
514
520
  }
515
- /** Reconciles a tag removed by Turbo, a server render, or another controller. */
516
521
  tagTargetDisconnected(_tag) {
517
522
  this.#reconcile.schedule();
518
523
  }
519
- /** Seeds a fields target inserted after connect. */
520
524
  fieldsTargetConnected() {
521
525
  this.#reconcile.schedule();
522
526
  }
523
- /** Rebuilds submitted fields when the public name changes at runtime. */
527
+ fieldsTargetDisconnected() {
528
+ this.#reconcile.schedule();
529
+ }
524
530
  nameValueChanged() {
525
531
  this.#reconcile.schedule();
526
532
  }
527
- /** Repoints submitted fields when the owning form changes at runtime. */
528
533
  formValueChanged() {
529
534
  this.#reconcile.schedule();
530
535
  }
531
- /** Recomputes the full hook when the cap changes at runtime. */
532
536
  maxValueChanged() {
533
537
  this.#reconcile.schedule();
534
538
  }
535
- /** Commits on `Enter`/delimiter and deletes the last tag on empty `Backspace`. */
536
539
  onKeydown(event) {
537
540
  if (event.defaultPrevented) return;
538
541
  if (isReservedArrowChord(event)) return;
@@ -558,18 +561,13 @@ var TagsInputController = class extends Controller {
558
561
  }
559
562
  }
560
563
  }
561
- /**
562
- * Validates and adds the current input value as a tag, then clears the input.
563
- *
564
- * @stimeoRuntimeOnly `max` and `allowDuplicates` decide whether this one entry is taken.
565
- */
566
564
  #commitInput() {
567
565
  const value = this.inputTarget.value.trim();
568
566
  if (value === "") {
569
567
  this.#reject(value, "empty");
570
568
  return;
571
569
  }
572
- if (this.maxValue > 0 && this.tagTargets.length >= this.maxValue) {
570
+ if (this.#safeMax > 0 && this.tagTargets.length >= this.#safeMax) {
573
571
  this.#reject(value, "max");
574
572
  return;
575
573
  }
@@ -580,18 +578,8 @@ var TagsInputController = class extends Controller {
580
578
  if (!this.#appendTag(value)) return;
581
579
  this.inputTarget.value = "";
582
580
  const tags = this.#values;
583
- this.#syncState(tags, true);
584
- this.#tagValues = tags;
585
- this.#announceTransition(true, value, tags.length);
586
- this.dispatch("change", { detail: { tags } });
581
+ this.#publishTags(tags, true, value);
587
582
  }
588
- /**
589
- * Builds one chip from the template and appends it to the tag list.
590
- *
591
- * A refused row leaves the commit a no-op — nothing about the input, the tag
592
- * set, the hidden fields, the announcement, or the events changes — and the
593
- * row reports why on the console once per connection.
594
- */
595
583
  #appendTag(value) {
596
584
  if (!this.hasTagsTarget) return false;
597
585
  if (!this.hasTagTemplateTarget) {
@@ -606,76 +594,79 @@ var TagsInputController = class extends Controller {
606
594
  this.tagsTarget.appendChild(row.root);
607
595
  return true;
608
596
  }
609
- /** Removes the tag at `index`, then applies the interaction-origin focus policy. */
610
597
  #removeAt(index, focus = "neighbor") {
611
598
  const tag = this.tagTargets[index];
612
599
  if (!tag) return;
613
600
  const value = tag.dataset.value ?? "";
614
601
  tag.remove();
615
602
  const tags = this.#values;
616
- this.#syncState(tags, true);
617
- this.#tagValues = tags;
618
- this.#announceTransition(false, value, tags.length);
619
- this.dispatch("change", { detail: { tags } });
603
+ if (!this.#publishTags(tags, false, value)) return;
620
604
  if (focus === "input") {
621
605
  this.#focusInput();
622
606
  return;
623
607
  }
624
608
  if (!this.#chipRow.focusAfterRemoval(index)) this.#focusInput();
625
609
  }
626
- /** Moves focus to the current input when one exists. */
627
610
  #focusInput() {
628
611
  if (this.hasInputTarget) this.inputTarget.focus();
629
612
  }
630
- /**
631
- * Rebuilds the hidden form fields, the `full` flag, and the roving Tab stop.
632
- *
633
- * @stimeoRenderRoot
634
- */
635
- #syncState(values, notify = false) {
613
+ #syncState(values) {
614
+ let fieldChanged = false;
636
615
  if (this.hasFieldsTarget) {
637
616
  const options = { name: this.nameValue, form: this.formValue };
638
- if (writeFields(this.fieldsTarget, values, options) && notify) {
639
- commitField(this.fieldsTarget);
640
- }
617
+ fieldChanged = writeFields(this.fieldsTarget, values, options);
641
618
  }
642
- const full = this.maxValue > 0 && values.length >= this.maxValue;
619
+ const full = this.#safeMax > 0 && values.length >= this.#safeMax;
643
620
  this.element.toggleAttribute(`data-${this.identifier}-full`, full);
644
621
  this.#chipRow.ensureTabStop();
622
+ return fieldChanged;
623
+ }
624
+ #publishTags(tags, added, value) {
625
+ const changed = !this.#sameTags(this.#tagValues, tags);
626
+ const fieldChanged = this.#syncState(tags);
627
+ this.#tagValues = tags;
628
+ if (!changed) return true;
629
+ const token = this.#moves.record();
630
+ if (fieldChanged) commitField(this.fieldsTarget);
631
+ if (!this.#moves.isLatest(token)) return false;
632
+ this.#announceTransition(added, value, tags.length);
633
+ if (!this.#moves.isLatest(token)) return false;
634
+ this.dispatch("change", { detail: { tags } });
635
+ return this.#moves.isLatest(token);
645
636
  }
646
- /** Repairs derived state after DOM/Turbo changes and reports a changed tag order. */
647
637
  #reconcileTags() {
648
638
  if (this.hasTagsTarget) this.#chipRow.connect(this.tagsTarget);
649
- else this.#chipRow.disconnect();
650
639
  const tags = this.#values;
651
640
  this.#syncState(tags);
652
641
  const changed = !this.#sameTags(this.#tagValues, tags);
653
642
  this.#tagValues = tags;
654
- if (changed) this.dispatch("reconcile", { detail: { tags } });
643
+ if (changed) {
644
+ this.dispatch("reconcile", { detail: { tags } });
645
+ }
655
646
  }
656
- /**
657
- * Sends one localized tag transition through the page's shared announcer.
658
- *
659
- * @stimeoRuntimeOnly The texts word the one announcement of this change.
660
- */
661
647
  #announceTransition(added, value, count) {
662
648
  const template = added ? this.announceTextValue : this.announceRemovedTextValue;
663
649
  announce(fillTemplate(template, { label: value, value, count }));
664
650
  }
665
- /** Reports rejection with `{ value, reason: "duplicate" | "empty" | "max" }`. */
666
651
  #reject(value, reason) {
667
652
  this.dispatch("reject", { detail: { value, reason } });
668
653
  }
669
- /** Current tag values in order. */
670
654
  get #values() {
671
655
  return this.tagTargets.map((tag) => tag.dataset.value ?? "");
672
656
  }
673
- /** Whether two arrays carry the same values in the same submitted order. */
674
657
  #sameTags(left, right) {
675
658
  return left.length === right.length && left.every((value, index) => value === right[index]);
676
659
  }
660
+ get #safeMax() {
661
+ return this.#numbers.read(
662
+ this,
663
+ "max",
664
+ this.maxValue,
665
+ _TagsInputController.values.max.default,
666
+ _TagsInputController.valueConstraints.max
667
+ );
668
+ }
677
669
  };
678
670
 
679
671
  export { TagsInputController };
680
- //# sourceMappingURL=tags_input_controller.js.map
681
672
  //# sourceMappingURL=tags_input_controller.js.map