stimeo-ui 0.4.0 → 0.6.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 (173) hide show
  1. package/CHANGELOG.md +184 -0
  2. package/dist/controllers/announcer_controller.js.map +1 -1
  3. package/dist/controllers/aspect_ratio_controller.d.ts +3 -5
  4. package/dist/controllers/aspect_ratio_controller.js +19 -11
  5. package/dist/controllers/aspect_ratio_controller.js.map +1 -1
  6. package/dist/controllers/avatar_controller.d.ts +36 -15
  7. package/dist/controllers/avatar_controller.js +195 -40
  8. package/dist/controllers/avatar_controller.js.map +1 -1
  9. package/dist/controllers/breadcrumb_controller.js +5 -1
  10. package/dist/controllers/breadcrumb_controller.js.map +1 -1
  11. package/dist/controllers/carousel_controller.d.ts +12 -3
  12. package/dist/controllers/carousel_controller.js +90 -10
  13. package/dist/controllers/carousel_controller.js.map +1 -1
  14. package/dist/controllers/checkbox_controller.d.ts +31 -5
  15. package/dist/controllers/checkbox_controller.js +136 -25
  16. package/dist/controllers/checkbox_controller.js.map +1 -1
  17. package/dist/controllers/clipboard_controller.js +8 -3
  18. package/dist/controllers/clipboard_controller.js.map +1 -1
  19. package/dist/controllers/collapsible_controller.d.ts +1 -1
  20. package/dist/controllers/collapsible_controller.js +4 -1
  21. package/dist/controllers/collapsible_controller.js.map +1 -1
  22. package/dist/controllers/color_picker_controller.d.ts +9 -3
  23. package/dist/controllers/color_picker_controller.js +41 -11
  24. package/dist/controllers/color_picker_controller.js.map +1 -1
  25. package/dist/controllers/combobox_controller.js.map +1 -1
  26. package/dist/controllers/command_palette_controller.js.map +1 -1
  27. package/dist/controllers/context_menu_controller.d.ts +1 -1
  28. package/dist/controllers/context_menu_controller.js +2 -2
  29. package/dist/controllers/context_menu_controller.js.map +1 -1
  30. package/dist/controllers/countdown_controller.d.ts +5 -3
  31. package/dist/controllers/countdown_controller.js +5 -1
  32. package/dist/controllers/countdown_controller.js.map +1 -1
  33. package/dist/controllers/date_range_picker_controller.d.ts +23 -4
  34. package/dist/controllers/date_range_picker_controller.js +162 -31
  35. package/dist/controllers/date_range_picker_controller.js.map +1 -1
  36. package/dist/controllers/direct_upload_controller.d.ts +1 -1
  37. package/dist/controllers/direct_upload_controller.js +3 -3
  38. package/dist/controllers/direct_upload_controller.js.map +1 -1
  39. package/dist/controllers/empty_state_controller.d.ts +27 -7
  40. package/dist/controllers/empty_state_controller.js +107 -16
  41. package/dist/controllers/empty_state_controller.js.map +1 -1
  42. package/dist/controllers/file_dropzone_controller.d.ts +7 -1
  43. package/dist/controllers/file_dropzone_controller.js +26 -3
  44. package/dist/controllers/file_dropzone_controller.js.map +1 -1
  45. package/dist/controllers/flash_controller.d.ts +25 -5
  46. package/dist/controllers/flash_controller.js +161 -21
  47. package/dist/controllers/flash_controller.js.map +1 -1
  48. package/dist/controllers/focus_controller.d.ts +2 -3
  49. package/dist/controllers/focus_controller.js.map +1 -1
  50. package/dist/controllers/form_validation_controller.js +8 -2
  51. package/dist/controllers/form_validation_controller.js.map +1 -1
  52. package/dist/controllers/frame_loading_controller.d.ts +19 -2
  53. package/dist/controllers/frame_loading_controller.js +94 -22
  54. package/dist/controllers/frame_loading_controller.js.map +1 -1
  55. package/dist/controllers/highlight_controller.d.ts +8 -4
  56. package/dist/controllers/highlight_controller.js +38 -1
  57. package/dist/controllers/highlight_controller.js.map +1 -1
  58. package/dist/controllers/hover_card_controller.d.ts +2 -2
  59. package/dist/controllers/hover_card_controller.js.map +1 -1
  60. package/dist/controllers/idle_controller.d.ts +5 -4
  61. package/dist/controllers/idle_controller.js +39 -6
  62. package/dist/controllers/idle_controller.js.map +1 -1
  63. package/dist/controllers/listbox_controller.js.map +1 -1
  64. package/dist/controllers/local_time_controller.js +2 -0
  65. package/dist/controllers/local_time_controller.js.map +1 -1
  66. package/dist/controllers/masonry_controller.d.ts +1 -1
  67. package/dist/controllers/masonry_controller.js +1 -1
  68. package/dist/controllers/masonry_controller.js.map +1 -1
  69. package/dist/controllers/menubar_controller.js +5 -3
  70. package/dist/controllers/menubar_controller.js.map +1 -1
  71. package/dist/controllers/meter_controller.js +3 -1
  72. package/dist/controllers/meter_controller.js.map +1 -1
  73. package/dist/controllers/multi_select_controller.d.ts +46 -12
  74. package/dist/controllers/multi_select_controller.js +460 -151
  75. package/dist/controllers/multi_select_controller.js.map +1 -1
  76. package/dist/controllers/network_status_controller.d.ts +15 -13
  77. package/dist/controllers/network_status_controller.js +1 -3
  78. package/dist/controllers/network_status_controller.js.map +1 -1
  79. package/dist/controllers/number_input_controller.d.ts +29 -3
  80. package/dist/controllers/number_input_controller.js +455 -64
  81. package/dist/controllers/number_input_controller.js.map +1 -1
  82. package/dist/controllers/overflow_menu_controller.js +5 -1
  83. package/dist/controllers/overflow_menu_controller.js.map +1 -1
  84. package/dist/controllers/pagination_controller.js +38 -1
  85. package/dist/controllers/pagination_controller.js.map +1 -1
  86. package/dist/controllers/password_strength_controller.d.ts +4 -4
  87. package/dist/controllers/password_strength_controller.js +21 -3
  88. package/dist/controllers/password_strength_controller.js.map +1 -1
  89. package/dist/controllers/persist_controller.d.ts +4 -4
  90. package/dist/controllers/persist_controller.js +24 -5
  91. package/dist/controllers/persist_controller.js.map +1 -1
  92. package/dist/controllers/pointer_drag_controller.js +10 -0
  93. package/dist/controllers/pointer_drag_controller.js.map +1 -1
  94. package/dist/controllers/popover_controller.d.ts +2 -2
  95. package/dist/controllers/popover_controller.js.map +1 -1
  96. package/dist/controllers/portal_controller.d.ts +1 -2
  97. package/dist/controllers/portal_controller.js +10 -0
  98. package/dist/controllers/portal_controller.js.map +1 -1
  99. package/dist/controllers/progress_controller.d.ts +1 -1
  100. package/dist/controllers/progress_controller.js +7 -3
  101. package/dist/controllers/progress_controller.js.map +1 -1
  102. package/dist/controllers/radio_group_controller.d.ts +41 -15
  103. package/dist/controllers/radio_group_controller.js +540 -56
  104. package/dist/controllers/radio_group_controller.js.map +1 -1
  105. package/dist/controllers/range_slider_controller.d.ts +23 -0
  106. package/dist/controllers/range_slider_controller.js +385 -94
  107. package/dist/controllers/range_slider_controller.js.map +1 -1
  108. package/dist/controllers/rating_controller.d.ts +36 -31
  109. package/dist/controllers/rating_controller.js +274 -89
  110. package/dist/controllers/rating_controller.js.map +1 -1
  111. package/dist/controllers/relative_time_controller.js +2 -0
  112. package/dist/controllers/relative_time_controller.js.map +1 -1
  113. package/dist/controllers/resizable_controller.js +33 -0
  114. package/dist/controllers/resizable_controller.js.map +1 -1
  115. package/dist/controllers/roving_controller.d.ts +4 -0
  116. package/dist/controllers/roving_controller.js +60 -5
  117. package/dist/controllers/roving_controller.js.map +1 -1
  118. package/dist/controllers/scroll_area_controller.d.ts +7 -1
  119. package/dist/controllers/scroll_area_controller.js +155 -23
  120. package/dist/controllers/scroll_area_controller.js.map +1 -1
  121. package/dist/controllers/scroll_visibility_controller.js +33 -0
  122. package/dist/controllers/scroll_visibility_controller.js.map +1 -1
  123. package/dist/controllers/separator_controller.js +13 -17
  124. package/dist/controllers/separator_controller.js.map +1 -1
  125. package/dist/controllers/skeleton_controller.d.ts +2 -2
  126. package/dist/controllers/skeleton_controller.js +71 -3
  127. package/dist/controllers/skeleton_controller.js.map +1 -1
  128. package/dist/controllers/slider_controller.d.ts +17 -1
  129. package/dist/controllers/slider_controller.js +325 -48
  130. package/dist/controllers/slider_controller.js.map +1 -1
  131. package/dist/controllers/spinner_controller.d.ts +15 -10
  132. package/dist/controllers/spinner_controller.js +18 -3
  133. package/dist/controllers/spinner_controller.js.map +1 -1
  134. package/dist/controllers/step_indicator_controller.d.ts +1 -1
  135. package/dist/controllers/step_indicator_controller.js +3 -1
  136. package/dist/controllers/step_indicator_controller.js.map +1 -1
  137. package/dist/controllers/stepper_controller.js +2 -0
  138. package/dist/controllers/stepper_controller.js.map +1 -1
  139. package/dist/controllers/switch_controller.d.ts +12 -8
  140. package/dist/controllers/switch_controller.js +162 -18
  141. package/dist/controllers/switch_controller.js.map +1 -1
  142. package/dist/controllers/tags_input_controller.d.ts +41 -11
  143. package/dist/controllers/tags_input_controller.js +356 -120
  144. package/dist/controllers/tags_input_controller.js.map +1 -1
  145. package/dist/controllers/textarea_autosize_controller.js +1 -1
  146. package/dist/controllers/textarea_autosize_controller.js.map +1 -1
  147. package/dist/controllers/time_picker_controller.d.ts +42 -10
  148. package/dist/controllers/time_picker_controller.js +296 -104
  149. package/dist/controllers/time_picker_controller.js.map +1 -1
  150. package/dist/controllers/toggle_group_controller.d.ts +39 -13
  151. package/dist/controllers/toggle_group_controller.js +378 -55
  152. package/dist/controllers/toggle_group_controller.js.map +1 -1
  153. package/dist/controllers/toolbar_controller.js +5 -3
  154. package/dist/controllers/toolbar_controller.js.map +1 -1
  155. package/dist/controllers/tooltip_controller.d.ts +2 -2
  156. package/dist/controllers/tooltip_controller.js.map +1 -1
  157. package/dist/controllers/transition_controller.d.ts +2 -3
  158. package/dist/controllers/transition_controller.js.map +1 -1
  159. package/dist/controllers/tree_view_controller.d.ts +1 -2
  160. package/dist/controllers/tree_view_controller.js +24 -4
  161. package/dist/controllers/tree_view_controller.js.map +1 -1
  162. package/dist/index.js +3811 -1048
  163. package/dist/index.js.map +1 -1
  164. package/dist/inspector/cli.d.ts +92 -4
  165. package/dist/inspector/cli.js +174 -6
  166. package/dist/inspector/cli.js.map +1 -1
  167. package/dist/inspector/cli_bin.js +234 -8
  168. package/dist/inspector/cli_bin.js.map +1 -1
  169. package/dist/inspector/examples.json +24 -24
  170. package/dist/inspector/manifest.json +573 -59
  171. package/dist/positioning/index.d.ts +2 -2
  172. package/dist/positioning/index.js.map +1 -1
  173. package/package.json +3 -3
@@ -2,6 +2,23 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/tags_input_controller.ts
4
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
+
5
22
  // src/utils/logical_scroll.ts
6
23
  function isRtl(element) {
7
24
  return window.getComputedStyle(element).direction === "rtl";
@@ -18,6 +35,177 @@ function isReservedArrowChord(event, allow = []) {
18
35
  return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
19
36
  }
20
37
 
38
+ // src/utils/roving_tabindex.ts
39
+ var RovingTabindex = class {
40
+ /** Returns the current ordered item elements; called on every operation. */
41
+ #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
+ constructor(getItems) {
47
+ this.#getItems = getItems;
48
+ }
49
+ /** Index of the currently tabbable item (`tabindex="0"`), or `-1` if none. */
50
+ get activeIndex() {
51
+ return this.#getItems().findIndex((item) => item.tabIndex === 0);
52
+ }
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
+ setActive(index, options = {}) {
64
+ const { focus = false } = options;
65
+ const items = options.items ?? this.#getItems();
66
+ items.forEach((item, i) => {
67
+ item.tabIndex = i === index ? 0 : -1;
68
+ });
69
+ if (focus) items[index]?.focus();
70
+ }
71
+ };
72
+
73
+ // src/utils/chip_row.ts
74
+ var ChipRow = class {
75
+ #directionElement;
76
+ #getItems;
77
+ #getButton;
78
+ #onRemove;
79
+ #focusAfterEnd;
80
+ #roving = new RovingTabindex(() => this.buttons);
81
+ #container = null;
82
+ constructor(options) {
83
+ this.#directionElement = options.directionElement;
84
+ this.#getItems = options.getItems;
85
+ this.#getButton = options.getButton ?? ((item) => item.querySelector("button"));
86
+ this.#onRemove = options.onRemove;
87
+ this.#focusAfterEnd = options.focusAfterEnd;
88
+ }
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
+ connect(container) {
94
+ if (this.#container === container) return;
95
+ this.disconnect();
96
+ this.#container = container;
97
+ container.addEventListener("click", this.#onClick);
98
+ container.addEventListener("keydown", this.#onKeydown);
99
+ }
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
+ disconnect(container) {
105
+ const current = this.#container;
106
+ if (!current || container !== void 0 && current !== container) return;
107
+ current.removeEventListener("click", this.#onClick);
108
+ current.removeEventListener("keydown", this.#onKeydown);
109
+ this.#container = null;
110
+ }
111
+ /** Current remove buttons, at most one consumer-resolved button per chip item. */
112
+ get buttons() {
113
+ return this.#entries.map(({ button }) => button);
114
+ }
115
+ /** Number of currently navigable remove buttons. */
116
+ get length() {
117
+ return this.buttons.length;
118
+ }
119
+ /** Declared-item index of the last navigable chip, or `-1` for an empty row. */
120
+ get lastIndex() {
121
+ return this.#entries.at(-1)?.itemIndex ?? -1;
122
+ }
123
+ /** Keeps exactly one button tabbable, preferring the first authored Tab stop. */
124
+ ensureTabStop() {
125
+ const buttons = this.#entries.map(({ button }) => button);
126
+ const active = buttons.findIndex((button) => button.tabIndex === 0);
127
+ const count = buttons.filter((button) => button.tabIndex === 0).length;
128
+ if (buttons.length > 0 && count !== 1) {
129
+ this.#roving.setActive(active === -1 ? 0 : active, { items: buttons });
130
+ }
131
+ }
132
+ /** Focuses the last chip, returning false for an empty row. */
133
+ focusLast() {
134
+ const buttons = this.#entries.map(({ button }) => button);
135
+ const last = buttons.length - 1;
136
+ if (last < 0) return false;
137
+ this.#roving.setActive(last, { focus: true, items: buttons });
138
+ return true;
139
+ }
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
+ focusAfterRemoval(index) {
145
+ const entries = this.#entries;
146
+ if (entries.length === 0) return false;
147
+ const following = entries.findIndex((entry) => entry.itemIndex >= index);
148
+ this.#roving.setActive(following === -1 ? entries.length - 1 : following, {
149
+ focus: true,
150
+ items: entries.map(({ button }) => button)
151
+ });
152
+ return true;
153
+ }
154
+ /** Current remove-button entries, retaining each button's declared item index. */
155
+ get #entries() {
156
+ return this.#getItems().flatMap((item, itemIndex) => {
157
+ const button = this.#getButton(item);
158
+ return button ? [{ button, itemIndex }] : [];
159
+ });
160
+ }
161
+ /** Resolves a delegated event to the remove button owned by a declared chip. */
162
+ #entry(event) {
163
+ const target = event.target;
164
+ if (!(target instanceof Element)) return null;
165
+ const button = target.closest("button");
166
+ const entries = this.#entries;
167
+ const buttonIndex = entries.findIndex((entry2) => entry2.button === button);
168
+ if (buttonIndex === -1) return null;
169
+ const entry = entries[buttonIndex];
170
+ return { ...entry, buttonIndex, buttons: entries.map(({ button: button2 }) => button2) };
171
+ }
172
+ /** Delegates removal clicks without waiting for Stimulus to wire a new chip. */
173
+ #onClick = (event) => {
174
+ const entry = this.#entry(event);
175
+ if (!entry) return;
176
+ this.#onRemove(entry.itemIndex);
177
+ };
178
+ /** Applies the shared logical-arrow and removal policy within the chip row. */
179
+ #onKeydown = (event) => {
180
+ if (event.defaultPrevented || isReservedArrowChord(event)) return;
181
+ const entry = this.#entry(event);
182
+ if (!entry) return;
183
+ const buttons = entry.buttons;
184
+ const index = entry.buttonIndex;
185
+ switch (logicalArrowKey(event.key, this.#directionElement)) {
186
+ case "ArrowLeft":
187
+ if (index > 0) {
188
+ event.preventDefault();
189
+ this.#roving.setActive(index - 1, { focus: true, items: buttons });
190
+ }
191
+ break;
192
+ case "ArrowRight":
193
+ event.preventDefault();
194
+ if (index < buttons.length - 1) {
195
+ this.#roving.setActive(index + 1, { focus: true, items: buttons });
196
+ } else {
197
+ this.#focusAfterEnd();
198
+ }
199
+ break;
200
+ case "Delete":
201
+ case "Backspace":
202
+ event.preventDefault();
203
+ this.#onRemove(entry.itemIndex);
204
+ break;
205
+ }
206
+ };
207
+ };
208
+
21
209
  // src/utils/composition_tracker.ts
22
210
  var CompositionTracker = class {
23
211
  #observedTargets = /* @__PURE__ */ new Set();
@@ -65,65 +253,82 @@ var CompositionTracker = class {
65
253
  };
66
254
  };
67
255
 
68
- // src/utils/roving_tabindex.ts
69
- var RovingTabindex = class {
70
- /** Returns the current ordered item elements; called on every operation. */
71
- #getItems;
72
- /**
73
- * @param getItems - Returns the current ordered item elements. Called on every
74
- * operation so the live target list is always used.
75
- */
76
- constructor(getItems) {
77
- this.#getItems = getItems;
256
+ // src/utils/microtask_coalescer.ts
257
+ var MicrotaskCoalescer = class {
258
+ #run;
259
+ #queued = false;
260
+ #active = false;
261
+ #generation = 0;
262
+ /** @param run - the single reconciliation pass, invoked at most once per batch. */
263
+ constructor(run) {
264
+ this.#run = run;
78
265
  }
79
- /** Index of the currently tabbable item (`tabindex="0"`), or `-1` if none. */
80
- get activeIndex() {
81
- return this.#getItems().findIndex((item) => item.tabIndex === 0);
266
+ /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
267
+ activate() {
268
+ this.#active = true;
82
269
  }
83
- /**
84
- * Makes exactly the item at `index` tabbable (`tabindex="0"`) and removes every
85
- * other item from the Tab sequence (`tabindex="-1"`). An out-of-range `index`
86
- * (e.g. `-1`) leaves all items at `-1`, which a controller can use to express
87
- * "nothing is currently tabbable".
88
- *
89
- * @param index - Position of the item to make tabbable.
90
- * @param options - Pass `{ focus: true }` to also move DOM focus to that item.
91
- */
92
- setActive(index, { focus = false } = {}) {
93
- const items = this.#getItems();
94
- items.forEach((item, i) => {
95
- item.tabIndex = i === index ? 0 : -1;
270
+ /** Closes the window and drops any pending pass; call from `disconnect()`. */
271
+ cancel() {
272
+ this.#active = false;
273
+ this.#queued = false;
274
+ this.#generation += 1;
275
+ }
276
+ /** Requests one pass after the batch settles. Idempotent; inert outside the window. */
277
+ schedule() {
278
+ if (!this.#active || this.#queued) return;
279
+ this.#queued = true;
280
+ const generation = this.#generation;
281
+ queueMicrotask(() => {
282
+ if (generation !== this.#generation || !this.#queued || !this.#active) return;
283
+ this.#queued = false;
284
+ this.#run();
96
285
  });
97
- if (focus) items[index]?.focus();
98
286
  }
99
287
  };
100
288
 
101
289
  // src/controllers/tags_input_controller.ts
102
290
  var TagsInputController = class extends Controller {
103
- static targets = ["input", "tags", "tag", "tagTemplate", "status", "fields"];
291
+ static targets = ["input", "tags", "tag", "tagTemplate", "label", "remove", "fields"];
104
292
  static values = {
105
293
  delimiter: { type: String, default: "," },
106
294
  max: { type: Number, default: 0 },
107
295
  allowDuplicates: { type: Boolean, default: false },
108
- name: { type: String, default: "tags[]" }
296
+ name: { type: String, default: "tags[]" },
297
+ announceText: { type: String, default: "" },
298
+ announceRemovedText: { type: String, default: "" }
109
299
  };
110
300
  static actions = ["onKeydown"];
111
- static events = ["change", "reject"];
112
- #roving = new RovingTabindex(() => this.#removeButtons);
301
+ static events = ["change", "reconcile", "reject"];
302
+ /** Last reconciled tag order, separating user edits from DOM/Turbo repair. */
303
+ #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());
308
+ #chipRow = new ChipRow({
309
+ directionElement: this.element,
310
+ getItems: () => this.tagTargets,
311
+ getButton: (tag) => tag.querySelector('button[data-stimeo--tags-input-target~="remove"]'),
312
+ onRemove: (index) => this.#removeAt(index),
313
+ focusAfterEnd: () => this.#focusInput()
314
+ });
113
315
  /** Owns IME lifecycle state for commit-key guarding. */
114
316
  #composition = new CompositionTracker();
115
317
  /** Wires tag-list keyboard navigation and removal, and seeds the single Tab stop. */
116
318
  connect() {
319
+ this.#warnedTemplate = false;
117
320
  if (this.hasInputTarget) this.#composition.observe(this.inputTarget);
118
- this.tagsTarget.addEventListener("keydown", this.#onTagKeydown);
119
- this.tagsTarget.addEventListener("click", this.#onTagClick);
120
- this.#syncState();
321
+ if (this.hasTagsTarget) this.#chipRow.connect(this.tagsTarget);
322
+ const tags = this.#values;
323
+ this.#syncState(tags);
324
+ this.#tagValues = tags;
325
+ this.#reconcile.activate();
121
326
  }
122
327
  /** Releases the delegated listeners so no handler outlives the element. */
123
328
  disconnect() {
329
+ this.#reconcile.cancel();
124
330
  this.#composition.disconnect();
125
- this.tagsTarget.removeEventListener("keydown", this.#onTagKeydown);
126
- this.tagsTarget.removeEventListener("click", this.#onTagClick);
331
+ this.#chipRow.disconnect();
127
332
  }
128
333
  /** Tracks an input added initially or after connect. */
129
334
  inputTargetConnected(input) {
@@ -133,10 +338,40 @@ var TagsInputController = class extends Controller {
133
338
  inputTargetDisconnected(input) {
134
339
  this.#composition.unobserve(input);
135
340
  }
341
+ /** Rebinds delegation and derived state when the tags container is replaced. */
342
+ tagsTargetConnected(_tags) {
343
+ this.#reconcile.schedule();
344
+ }
345
+ /** Releases only the row that disconnected, leaving a newer target intact. */
346
+ tagsTargetDisconnected(tags) {
347
+ this.#chipRow.disconnect(tags);
348
+ this.#reconcile.schedule();
349
+ }
350
+ /** Reconciles a tag inserted by Turbo, a server render, or another controller. */
351
+ tagTargetConnected(_tag) {
352
+ this.#reconcile.schedule();
353
+ }
354
+ /** Reconciles a tag removed by Turbo, a server render, or another controller. */
355
+ tagTargetDisconnected(_tag) {
356
+ this.#reconcile.schedule();
357
+ }
358
+ /** Seeds a fields target inserted after connect. */
359
+ fieldsTargetConnected() {
360
+ this.#reconcile.schedule();
361
+ }
362
+ /** Rebuilds submitted fields when the public name changes at runtime. */
363
+ nameValueChanged() {
364
+ this.#reconcile.schedule();
365
+ }
366
+ /** Recomputes the full hook when the cap changes at runtime. */
367
+ maxValueChanged() {
368
+ this.#reconcile.schedule();
369
+ }
136
370
  /** Commits on `Enter`/delimiter and deletes the last tag on empty `Backspace`. */
137
371
  onKeydown(event) {
138
372
  if (event.defaultPrevented) return;
139
373
  if (isReservedArrowChord(event)) return;
374
+ if (!this.hasInputTarget) return;
140
375
  if (this.#composition.isComposing(event)) return;
141
376
  if (event.key === "Enter" || event.key === this.delimiterValue) {
142
377
  event.preventDefault();
@@ -144,33 +379,20 @@ var TagsInputController = class extends Controller {
144
379
  return;
145
380
  }
146
381
  if (event.key === "Backspace" && this.inputTarget.value === "") {
147
- const buttons = this.#removeButtons;
148
- if (buttons.length > 0) {
382
+ const last = this.#chipRow.lastIndex;
383
+ if (last >= 0) {
149
384
  event.preventDefault();
150
- this.#removeAt(buttons.length - 1);
385
+ this.#removeAt(last, "input");
151
386
  }
152
387
  return;
153
388
  }
154
389
  if (logicalArrowKey(event.key, this.element) === "ArrowLeft" && this.inputTarget.value === "") {
155
- const buttons = this.#removeButtons;
156
- if (buttons.length > 0) {
390
+ if (this.#chipRow.length > 0) {
157
391
  event.preventDefault();
158
- this.#roving.setActive(buttons.length - 1, { focus: true });
392
+ this.#chipRow.focusLast();
159
393
  }
160
394
  }
161
395
  }
162
- /**
163
- * Deletes the tag whose remove button was clicked. Delegated on the tags
164
- * container (like `#onTagKeydown`) rather than bound per chip via
165
- * `data-action`, so it works the instant a chip is appended without waiting on
166
- * Stimulus to wire a freshly created element.
167
- */
168
- #onTagClick = (event) => {
169
- const button = event.target.closest("button");
170
- if (!button || !this.tagsTarget.contains(button)) return;
171
- const index = this.#removeButtons.indexOf(button);
172
- if (index !== -1) this.#removeAt(index);
173
- };
174
396
  /** Validates and adds the current input value as a tag, then clears the input. */
175
397
  #commitInput() {
176
398
  const value = this.inputTarget.value.trim();
@@ -186,78 +408,83 @@ var TagsInputController = class extends Controller {
186
408
  this.#reject(value, "duplicate");
187
409
  return;
188
410
  }
189
- this.#appendTag(value);
411
+ if (!this.#appendTag(value)) return;
190
412
  this.inputTarget.value = "";
191
- this.#announce(value);
192
- this.#syncState();
193
- this.dispatch("change", { detail: { tags: this.#values } });
413
+ 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 } });
194
418
  }
195
419
  /** Builds one chip from the template and appends it to the tag list. */
196
420
  #appendTag(value) {
197
- if (!this.hasTagTemplateTarget) return;
421
+ if (!this.hasTagsTarget) return false;
422
+ if (!this.hasTagTemplateTarget) return this.#warnTemplate('a "tagTemplate" target');
198
423
  const fragment = this.tagTemplateTarget.content.cloneNode(true);
199
- const tag = fragment.querySelector('[data-stimeo--tags-input-target="tag"]');
200
- const label = fragment.querySelector('[data-tags-input-slot="label"]');
201
- const button = fragment.querySelector("button");
202
- if (!tag || !button) return;
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');
435
+ }
203
436
  tag.dataset.value = value;
204
- if (label) label.textContent = value;
205
- button.setAttribute("aria-label", `Remove ${value}`);
437
+ label.textContent = value;
438
+ button.setAttribute("aria-label", fillTemplate(removeName, { label: value, value }));
206
439
  button.tabIndex = -1;
207
440
  this.tagsTarget.appendChild(fragment);
441
+ return true;
442
+ }
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;
208
461
  }
209
- /** Removes the tag at `index`, then moves focus to a neighbor or the input. */
210
- #removeAt(index) {
462
+ /** Removes the tag at `index`, then applies the interaction-origin focus policy. */
463
+ #removeAt(index, focus = "neighbor") {
211
464
  const tag = this.tagTargets[index];
212
465
  if (!tag) return;
213
466
  const value = tag.dataset.value ?? "";
214
467
  tag.remove();
215
- this.#announce(value);
216
- this.#syncState();
217
- this.dispatch("change", { detail: { tags: this.#values } });
218
- const remaining = this.#removeButtons;
219
- if (remaining.length === 0) {
220
- this.inputTarget.focus();
221
- } else {
222
- this.#roving.setActive(Math.min(index, remaining.length - 1), { focus: true });
468
+ 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 } });
473
+ if (focus === "input") {
474
+ this.#focusInput();
475
+ return;
223
476
  }
477
+ if (!this.#chipRow.focusAfterRemoval(index)) this.#focusInput();
478
+ }
479
+ /** Moves focus to the current input when one exists. */
480
+ #focusInput() {
481
+ if (this.hasInputTarget) this.inputTarget.focus();
224
482
  }
225
- /** Handles arrow navigation and deletion within the chip list (delegated). */
226
- #onTagKeydown = (event) => {
227
- if (event.defaultPrevented) return;
228
- if (isReservedArrowChord(event)) return;
229
- const button = event.target.closest("button");
230
- if (!button) return;
231
- const buttons = this.#removeButtons;
232
- const index = buttons.indexOf(button);
233
- if (index === -1) return;
234
- switch (logicalArrowKey(event.key, this.element)) {
235
- case "ArrowLeft":
236
- if (index > 0) {
237
- event.preventDefault();
238
- this.#roving.setActive(index - 1, { focus: true });
239
- }
240
- break;
241
- case "ArrowRight":
242
- event.preventDefault();
243
- if (index < buttons.length - 1) {
244
- this.#roving.setActive(index + 1, { focus: true });
245
- } else {
246
- this.inputTarget.focus();
247
- }
248
- break;
249
- case "Delete":
250
- case "Backspace":
251
- event.preventDefault();
252
- this.#removeAt(index);
253
- break;
254
- }
255
- };
256
483
  /** Rebuilds the hidden form fields, the `full` flag, and the roving Tab stop. */
257
- #syncState() {
484
+ #syncState(values) {
258
485
  if (this.hasFieldsTarget) {
259
486
  this.fieldsTarget.replaceChildren(
260
- ...this.#values.map((value) => {
487
+ ...values.map((value) => {
261
488
  const input = document.createElement("input");
262
489
  input.type = "hidden";
263
490
  input.name = this.nameValue;
@@ -266,28 +493,37 @@ var TagsInputController = class extends Controller {
266
493
  })
267
494
  );
268
495
  }
269
- const full = this.maxValue > 0 && this.tagTargets.length >= this.maxValue;
496
+ const full = this.maxValue > 0 && values.length >= this.maxValue;
270
497
  this.element.toggleAttribute("data-stimeo--tags-input-full", full);
271
- if (this.#removeButtons.length > 0 && this.#roving.activeIndex === -1) {
272
- this.#roving.setActive(0);
273
- }
498
+ this.#chipRow.ensureTabStop();
499
+ }
500
+ /** Repairs derived state after DOM/Turbo changes and reports a changed tag order. */
501
+ #reconcileTags() {
502
+ if (this.hasTagsTarget) this.#chipRow.connect(this.tagsTarget);
503
+ else this.#chipRow.disconnect();
504
+ const tags = this.#values;
505
+ this.#syncState(tags);
506
+ const changed = !this.#sameTags(this.#tagValues, tags);
507
+ this.#tagValues = tags;
508
+ if (changed) this.dispatch("reconcile", { detail: { tags } });
274
509
  }
275
- /** Mirrors the changed tag into the live region for assistive tech. */
276
- #announce(value) {
277
- if (this.hasStatusTarget) this.statusTarget.textContent = value;
510
+ /** Sends one localized tag transition through the page's shared announcer. */
511
+ #announceTransition(added, value, count) {
512
+ const template = added ? this.announceTextValue : this.announceRemovedTextValue;
513
+ announce(fillTemplate(template, { label: value, value, count }));
278
514
  }
279
- /** Reports a rejected addition via `stimeo--tags-input:reject`. */
515
+ /** Reports rejection with `{ value, reason: "duplicate" | "empty" | "max" }`. */
280
516
  #reject(value, reason) {
281
517
  this.dispatch("reject", { detail: { value, reason } });
282
518
  }
283
- /** The remove buttons in document order (the roving navigation set). */
284
- get #removeButtons() {
285
- return Array.from(this.tagsTarget.querySelectorAll("button"));
286
- }
287
519
  /** Current tag values in order. */
288
520
  get #values() {
289
521
  return this.tagTargets.map((tag) => tag.dataset.value ?? "");
290
522
  }
523
+ /** Whether two arrays carry the same values in the same submitted order. */
524
+ #sameTags(left, right) {
525
+ return left.length === right.length && left.every((value, index) => value === right[index]);
526
+ }
291
527
  };
292
528
 
293
529
  export { TagsInputController };