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