stimeo-ui 0.2.0 → 0.3.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 (75) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +163 -0
  3. data/dist/controllers/accordion_controller.js +10 -0
  4. data/dist/controllers/alert_dialog_controller.js +318 -0
  5. data/dist/controllers/breadcrumb_controller.js +225 -13
  6. data/dist/controllers/calendar_controller.js +89 -22
  7. data/dist/controllers/carousel_controller.js +313 -0
  8. data/dist/controllers/clipboard_controller.js +144 -0
  9. data/dist/controllers/collapsible_controller.js +327 -0
  10. data/dist/controllers/color_picker_controller.js +252 -0
  11. data/dist/controllers/combobox_controller.js +162 -23
  12. data/dist/controllers/command_palette_controller.js +194 -17
  13. data/dist/controllers/context_menu_controller.js +32 -10
  14. data/dist/controllers/count_up_controller.js +8 -1
  15. data/dist/controllers/currency_input_controller.js +147 -0
  16. data/dist/controllers/data_grid_controller.js +246 -0
  17. data/dist/controllers/date_range_picker_controller.js +441 -0
  18. data/dist/controllers/dismissible_controller.js +117 -0
  19. data/dist/controllers/drawer_controller.js +630 -0
  20. data/dist/controllers/editable_controller.js +169 -0
  21. data/dist/controllers/file_dropzone_controller.js +165 -0
  22. data/dist/controllers/filter_controller.js +86 -0
  23. data/dist/controllers/flash_controller.js +36 -5
  24. data/dist/controllers/form_validation_controller.js +1 -1
  25. data/dist/controllers/highlight_controller.js +6 -4
  26. data/dist/controllers/intersection_controller.js +67 -19
  27. data/dist/controllers/lazy_frame_controller.js +54 -11
  28. data/dist/controllers/listbox_controller.js +257 -53
  29. data/dist/controllers/local_time_controller.js +2 -2
  30. data/dist/controllers/masonry_controller.js +142 -0
  31. data/dist/controllers/menu_controller.js +104 -17
  32. data/dist/controllers/menubar_controller.js +785 -0
  33. data/dist/controllers/multi_select_controller.js +755 -0
  34. data/dist/controllers/navigation_menu_controller.js +511 -0
  35. data/dist/controllers/number_input_controller.js +7 -0
  36. data/dist/controllers/otp_controller.js +18 -1
  37. data/dist/controllers/overflow_indicator_controller.js +246 -27
  38. data/dist/controllers/overflow_menu_controller.js +381 -57
  39. data/dist/controllers/pagination_controller.js +163 -32
  40. data/dist/controllers/password_reveal_controller.js +117 -0
  41. data/dist/controllers/persist_controller.js +6 -6
  42. data/dist/controllers/pointer_drag_controller.js +9 -1
  43. data/dist/controllers/popover_controller.js +2 -2
  44. data/dist/controllers/radio_group_controller.js +22 -3
  45. data/dist/controllers/range_slider_controller.js +192 -0
  46. data/dist/controllers/rating_controller.js +16 -2
  47. data/dist/controllers/read_more_controller.js +238 -0
  48. data/dist/controllers/resizable_controller.js +65 -1
  49. data/dist/controllers/roving_controller.js +17 -2
  50. data/dist/controllers/scroll_area_controller.js +101 -14
  51. data/dist/controllers/scroll_restore_controller.js +93 -0
  52. data/dist/controllers/scroll_visibility_controller.js +40 -6
  53. data/dist/controllers/scrollspy_controller.js +369 -74
  54. data/dist/controllers/separator_controller.js +96 -0
  55. data/dist/controllers/sidebar_controller.js +761 -0
  56. data/dist/controllers/skeleton_controller.js +1 -1
  57. data/dist/controllers/slider_controller.js +32 -6
  58. data/dist/controllers/sortable_controller.js +34 -3
  59. data/dist/controllers/spinner_controller.js +1 -1
  60. data/dist/controllers/stepper_controller.js +28 -12
  61. data/dist/controllers/stick_to_bottom_controller.js +9 -4
  62. data/dist/controllers/sticky_observer_controller.js +109 -20
  63. data/dist/controllers/switch_controller.js +1 -0
  64. data/dist/controllers/tabs_controller.js +26 -3
  65. data/dist/controllers/tags_input_controller.js +295 -0
  66. data/dist/controllers/theme_controller.js +42 -13
  67. data/dist/controllers/time_picker_controller.js +231 -0
  68. data/dist/controllers/toast_controller.js +40 -14
  69. data/dist/controllers/toggle_group_controller.js +23 -2
  70. data/dist/controllers/toolbar_controller.js +230 -31
  71. data/dist/controllers/transition_controller.js +153 -38
  72. data/dist/controllers/tree_view_controller.js +691 -0
  73. data/dist/index.js +4256 -915
  74. data/lib/stimeo/ui/version.rb +2 -3
  75. metadata +28 -2
@@ -0,0 +1,755 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ // src/controllers/multi_select_controller.ts
4
+
5
+ // src/utils/aria_ids.ts
6
+ var counter = 0;
7
+ function uniqueId(prefix = "stimeo") {
8
+ let candidate;
9
+ do {
10
+ counter += 1;
11
+ candidate = `${prefix}-${counter}`;
12
+ } while (typeof document !== "undefined" && document.getElementById(candidate) !== null);
13
+ return candidate;
14
+ }
15
+ function ensureId(element, prefix = "stimeo") {
16
+ if (element.id) return element.id;
17
+ const id = uniqueId(prefix);
18
+ element.id = id;
19
+ return id;
20
+ }
21
+
22
+ // src/utils/logical_scroll.ts
23
+ function isRtl(element) {
24
+ return window.getComputedStyle(element).direction === "rtl";
25
+ }
26
+
27
+ // src/utils/arrow_step.ts
28
+ function logicalArrowKey(key, element) {
29
+ if (key !== "ArrowRight" && key !== "ArrowLeft") return key;
30
+ if (!isRtl(element)) return key;
31
+ return key === "ArrowRight" ? "ArrowLeft" : "ArrowRight";
32
+ }
33
+ function isReservedArrowChord(event, allow = []) {
34
+ if (!event.key.startsWith("Arrow")) return false;
35
+ return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
36
+ }
37
+
38
+ // src/utils/composition_tracker.ts
39
+ var CompositionTracker = class {
40
+ #observedTargets = /* @__PURE__ */ new Set();
41
+ #activeTargets = /* @__PURE__ */ new Set();
42
+ #onStart;
43
+ #onEnd;
44
+ constructor(options = {}) {
45
+ this.#onStart = options.onStart;
46
+ this.#onEnd = options.onEnd;
47
+ }
48
+ /** Starts lifecycle tracking for `target`; repeated calls are idempotent. */
49
+ observe(target) {
50
+ if (this.#observedTargets.has(target)) return;
51
+ target.addEventListener("compositionstart", this.#handleStart);
52
+ target.addEventListener("compositionend", this.#handleEnd);
53
+ this.#observedTargets.add(target);
54
+ }
55
+ /** Stops tracking one target and clears any active composition it owned. */
56
+ unobserve(target) {
57
+ if (!this.#observedTargets.delete(target)) return;
58
+ target.removeEventListener("compositionstart", this.#handleStart);
59
+ target.removeEventListener("compositionend", this.#handleEnd);
60
+ this.#activeTargets.delete(target);
61
+ }
62
+ /** Releases every listener and clears state so reconnect starts cleanly. */
63
+ disconnect() {
64
+ for (const target of this.#observedTargets) {
65
+ target.removeEventListener("compositionstart", this.#handleStart);
66
+ target.removeEventListener("compositionend", this.#handleEnd);
67
+ }
68
+ this.#observedTargets.clear();
69
+ this.#activeTargets.clear();
70
+ }
71
+ /** True when lifecycle tracking or the current event reports composition. */
72
+ isComposing(event) {
73
+ return this.#activeTargets.size > 0 || event?.isComposing === true;
74
+ }
75
+ #handleStart = (event) => {
76
+ if (event.currentTarget) this.#activeTargets.add(event.currentTarget);
77
+ this.#onStart?.(event);
78
+ };
79
+ #handleEnd = (event) => {
80
+ if (event.currentTarget) this.#activeTargets.delete(event.currentTarget);
81
+ this.#onEnd?.(event);
82
+ };
83
+ };
84
+
85
+ // src/utils/microtask_coalescer.ts
86
+ var MicrotaskCoalescer = class {
87
+ #run;
88
+ #queued = false;
89
+ #active = false;
90
+ #generation = 0;
91
+ /** @param run - the single reconciliation pass, invoked at most once per batch. */
92
+ constructor(run) {
93
+ this.#run = run;
94
+ }
95
+ /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
96
+ activate() {
97
+ this.#active = true;
98
+ }
99
+ /** Closes the window and drops any pending pass; call from `disconnect()`. */
100
+ cancel() {
101
+ this.#active = false;
102
+ this.#queued = false;
103
+ this.#generation += 1;
104
+ }
105
+ /** Requests one pass after the batch settles. Idempotent; inert outside the window. */
106
+ schedule() {
107
+ if (!this.#active || this.#queued) return;
108
+ this.#queued = true;
109
+ const generation = this.#generation;
110
+ queueMicrotask(() => {
111
+ if (generation !== this.#generation || !this.#queued || !this.#active) return;
112
+ this.#queued = false;
113
+ this.#run();
114
+ });
115
+ }
116
+ };
117
+
118
+ // src/utils/option_scroll.ts
119
+ function scrollOptionIntoView(list, option) {
120
+ if (list.scrollHeight <= list.clientHeight) return;
121
+ const listRect = list.getBoundingClientRect();
122
+ const optionRect = option.getBoundingClientRect();
123
+ if (optionRect.top < listRect.top) {
124
+ list.scrollTop -= listRect.top - optionRect.top;
125
+ } else if (optionRect.bottom > listRect.bottom) {
126
+ list.scrollTop += optionRect.bottom - listRect.bottom;
127
+ }
128
+ }
129
+
130
+ // src/utils/roving_tabindex.ts
131
+ var RovingTabindex = class {
132
+ /** Returns the current ordered item elements; called on every operation. */
133
+ #getItems;
134
+ /**
135
+ * @param getItems - Returns the current ordered item elements. Called on every
136
+ * operation so the live target list is always used.
137
+ */
138
+ constructor(getItems) {
139
+ this.#getItems = getItems;
140
+ }
141
+ /** Index of the currently tabbable item (`tabindex="0"`), or `-1` if none. */
142
+ get activeIndex() {
143
+ return this.#getItems().findIndex((item) => item.tabIndex === 0);
144
+ }
145
+ /**
146
+ * Makes exactly the item at `index` tabbable (`tabindex="0"`) and removes every
147
+ * other item from the Tab sequence (`tabindex="-1"`). An out-of-range `index`
148
+ * (e.g. `-1`) leaves all items at `-1`, which a controller can use to express
149
+ * "nothing is currently tabbable".
150
+ *
151
+ * @param index - Position of the item to make tabbable.
152
+ * @param options - Pass `{ focus: true }` to also move DOM focus to that item.
153
+ */
154
+ setActive(index, { focus = false } = {}) {
155
+ const items = this.#getItems();
156
+ items.forEach((item, i) => {
157
+ item.tabIndex = i === index ? 0 : -1;
158
+ });
159
+ if (focus) items[index]?.focus();
160
+ }
161
+ };
162
+
163
+ // src/utils/tabindex_loan.ts
164
+ var TabindexLoan = class {
165
+ #value;
166
+ #lent = /* @__PURE__ */ new Set();
167
+ /**
168
+ * @param value - the `tabindex` to lend. `"-1"` (the default) is
169
+ * programmatically focusable but not a Tab stop; `"0"` is a real Tab stop,
170
+ * which a scroll region with no focusable content of its own needs.
171
+ */
172
+ constructor(value = "-1") {
173
+ this.#value = value;
174
+ }
175
+ /** Lends `element` the value; no-ops when it already carries a `tabindex`. */
176
+ lend(element) {
177
+ if (element.hasAttribute("tabindex")) return;
178
+ element.setAttribute("tabindex", this.#value);
179
+ this.#lent.add(element);
180
+ }
181
+ /** Takes back every loan whose value is still the one that was lent. */
182
+ returnAll() {
183
+ for (const element of this.#lent) {
184
+ if (element.getAttribute("tabindex") === this.#value) element.removeAttribute("tabindex");
185
+ }
186
+ this.#lent.clear();
187
+ }
188
+ };
189
+
190
+ // src/controllers/multi_select_controller.ts
191
+ var MultiSelectController = class extends Controller {
192
+ static targets = [
193
+ "input",
194
+ "list",
195
+ "option",
196
+ "tags",
197
+ "tag",
198
+ "tagTemplate",
199
+ "status",
200
+ "fields"
201
+ ];
202
+ static values = {
203
+ max: { type: Number, default: 0 },
204
+ name: { type: String, default: "options[]" },
205
+ form: { type: String, default: "" }
206
+ };
207
+ static actions = ["close", "filter", "onKeydown", "open", "toggleOption"];
208
+ static events = ["change", "filter"];
209
+ /** Stable id of the active option; the current target is resolved from the DOM. */
210
+ #activeOptionId = null;
211
+ /** Whether the root borrowed a tab stop to catch focus, so teardown can undo it. */
212
+ #tabindex = new TabindexLoan();
213
+ /** Prevents initial/teardown target callbacks from mutating authored DOM. */
214
+ #connected = false;
215
+ /** Collapses one batch of target callbacks into a single final-DOM reconciliation. */
216
+ #reconcile = new MicrotaskCoalescer(() => this.#reconcileOptions());
217
+ /** Absorbs the browser's redundant final input after compositionend. */
218
+ #ignorePostCompositionInput = false;
219
+ /** Owns IME lifecycle state; confirmed text emits one filter result. */
220
+ #composition = new CompositionTracker({
221
+ onStart: () => {
222
+ this.#ignorePostCompositionInput = false;
223
+ },
224
+ onEnd: () => {
225
+ this.#ignorePostCompositionInput = true;
226
+ queueMicrotask(() => {
227
+ this.#ignorePostCompositionInput = false;
228
+ });
229
+ this.filter();
230
+ }
231
+ });
232
+ #roving = new RovingTabindex(() => this.#removeButtons);
233
+ /** Starts closed, syncs chips for any pre-selected options, and listens out. */
234
+ connect() {
235
+ if (this.hasInputTarget) this.#composition.observe(this.inputTarget);
236
+ this.#normalizeSelection();
237
+ this.close();
238
+ if (this.hasTagsTarget) {
239
+ this.tagsTarget.addEventListener("keydown", this.#onTagKeydown);
240
+ this.tagsTarget.addEventListener("click", this.#onTagClick);
241
+ this.#rebuildTags();
242
+ }
243
+ this.#syncFields();
244
+ document.addEventListener("click", this.#onOutsideClick, true);
245
+ this.#connected = true;
246
+ this.#reconcile.activate();
247
+ }
248
+ /**
249
+ * Derives the chips from the selected options, idempotently: a Turbo Drive cache
250
+ * restore or morph can re-connect with chips already in the DOM, so they are
251
+ * cleared before deriving afresh to avoid duplicates.
252
+ */
253
+ #rebuildTags() {
254
+ if (!this.hasTagsTarget) return;
255
+ for (const tag of this.tagTargets) tag.remove();
256
+ for (const option of this.#selectedOptions) this.#appendTag(option);
257
+ if (this.#removeButtons.length > 0) this.#roving.setActive(0);
258
+ }
259
+ /** Tears down document and chip listeners on disconnect (Turbo included). */
260
+ disconnect() {
261
+ this.#connected = false;
262
+ this.#reconcile.cancel();
263
+ this.#composition.disconnect();
264
+ this.#ignorePostCompositionInput = false;
265
+ if (this.hasTagsTarget) {
266
+ this.tagsTarget.removeEventListener("keydown", this.#onTagKeydown);
267
+ this.tagsTarget.removeEventListener("click", this.#onTagClick);
268
+ }
269
+ document.removeEventListener("click", this.#onOutsideClick, true);
270
+ this.#releaseTabindex();
271
+ }
272
+ /** Reconciles active state after an option target is added at runtime. */
273
+ optionTargetConnected() {
274
+ this.#scheduleOptionReconcile();
275
+ }
276
+ /** Cleans a removed target and reconciles active state against the surviving DOM. */
277
+ optionTargetDisconnected(option) {
278
+ if (!this.#connected) return;
279
+ option.removeAttribute("data-active");
280
+ this.#scheduleOptionReconcile();
281
+ }
282
+ /** Schedules one reconciliation after all callbacks in the mutation batch. */
283
+ #scheduleOptionReconcile() {
284
+ this.#reconcile.schedule();
285
+ }
286
+ /**
287
+ * Keeps a surviving/same-id active target, otherwise falls back to the first
288
+ * visible one — and brings the derived state back in line with the new option set.
289
+ *
290
+ * The baseline pass fills in any missing `aria-selected`, and the chips and
291
+ * hidden fields are re-derived from it, because the options are the truth source
292
+ * for the selection. The chips are rebuilt **only when the selected value set
293
+ * actually moved**: the rebuild removes and recreates every chip, so running it
294
+ * for an unrelated option would drop focus from a chip's remove button to
295
+ * `<body>`, losing the keyboard user's place for something that did not concern
296
+ * them.
297
+ */
298
+ #reconcileOptions() {
299
+ const visible = this.#visibleOptions;
300
+ const active = this.#activeOption;
301
+ const next = this.#isClosed ? null : active && !active.hidden ? active : visible[0] ?? null;
302
+ this.#setActive(next);
303
+ this.#reflectEmpty();
304
+ this.#normalizeSelection();
305
+ const selected = this.#selectedOptions;
306
+ const nextValues = selected.map((option) => this.#optionValue(option)).sort();
307
+ const tagValues = this.tagTargets.map((tag) => tag.dataset.value ?? "").sort();
308
+ const unchanged = nextValues.length === tagValues.length && nextValues.every((value, index) => value === tagValues[index]);
309
+ if (unchanged) this.#refreshTagLabels(selected);
310
+ else this.#rebuildTags();
311
+ this.#syncFields();
312
+ }
313
+ /**
314
+ * Gives every option an explicit `aria-selected`, without changing which ones
315
+ * the author chose. An absent value means "not selectable" in ARIA, so a
316
+ * forgotten attribute hides a selectable option. Several `true` is the normal
317
+ * case here — the list is `aria-multiselectable` — so nothing is dropped.
318
+ */
319
+ #normalizeSelection() {
320
+ for (const option of this.optionTargets) {
321
+ if (option.getAttribute("aria-selected") !== "true") {
322
+ option.setAttribute("aria-selected", "false");
323
+ }
324
+ }
325
+ }
326
+ /**
327
+ * Tracks an input added initially or after connect, and makes it describe the
328
+ * widget that is actually on screen: a swapped-in input arrives with the
329
+ * authored ARIA of a fresh node while this controller still holds the popup
330
+ * state, and the open path cannot repair that (it seeds an active option only
331
+ * when there is none), so a live list would go unannounced.
332
+ */
333
+ inputTargetConnected(input) {
334
+ this.#composition.observe(input);
335
+ input.setAttribute("aria-expanded", String(!this.#isClosed));
336
+ const active = this.#activeOption;
337
+ if (active) input.setAttribute("aria-activedescendant", ensureId(active, "stimeo-ms-opt"));
338
+ else input.removeAttribute("aria-activedescendant");
339
+ }
340
+ /** Removes composition listeners when the active input is replaced or removed. */
341
+ inputTargetDisconnected(input) {
342
+ this.#composition.unobserve(input);
343
+ this.#ignorePostCompositionInput = false;
344
+ }
345
+ /** Filters confirmed input text, opens, and re-seeds the active option. */
346
+ filter(event) {
347
+ if (event && this.#ignorePostCompositionInput) {
348
+ this.#ignorePostCompositionInput = false;
349
+ return;
350
+ }
351
+ if (this.#composition.isComposing(event)) return;
352
+ const query = this.inputTarget.value.trim().toLowerCase();
353
+ for (const option of this.optionTargets) {
354
+ const label = (option.textContent ?? "").trim().toLowerCase();
355
+ option.hidden = query !== "" && !label.includes(query);
356
+ }
357
+ this.open();
358
+ const visible = this.#visibleOptions;
359
+ this.#reflectEmpty();
360
+ this.#setActive(visible[0] ?? null);
361
+ this.dispatch("filter", { detail: { query } });
362
+ }
363
+ /**
364
+ * Opens the list and activates the first visible option when none is active.
365
+ *
366
+ * Needs the input, which owns `aria-expanded` and `aria-activedescendant`: a
367
+ * list shown without one is a popup no assistive technology is told about. So
368
+ * opening is skipped entirely, where {@link close} still closes.
369
+ */
370
+ open() {
371
+ if (!this.hasListTarget || !this.hasInputTarget) return;
372
+ this.listTarget.hidden = false;
373
+ this.inputTarget.setAttribute("aria-expanded", "true");
374
+ if (!this.#activeOption) this.#setActive(this.#visibleOptions[0] ?? null);
375
+ }
376
+ /**
377
+ * Closes the list and clears the active option.
378
+ *
379
+ * Survives a missing input in both directions. `connect()` calls this second,
380
+ * so dereferencing the input here would throw before the chips, the roving
381
+ * seed, the chip listeners, the hidden fields and the outside-click listener —
382
+ * and Stimulus keeps the controller alive after that throw, so none of them
383
+ * would ever run and the selection would silently stop submitting. An input
384
+ * removed while the list is open must still let it come down *and* forget its
385
+ * active option, so only the `aria-expanded` write is guarded.
386
+ */
387
+ close() {
388
+ if (!this.hasListTarget) return;
389
+ this.listTarget.hidden = true;
390
+ this.#setActive(null);
391
+ if (!this.hasInputTarget) return;
392
+ this.inputTarget.setAttribute("aria-expanded", "false");
393
+ }
394
+ /** Routes input keyboard interaction per the multi-select combobox model. */
395
+ onKeydown(event) {
396
+ if (event.defaultPrevented) return;
397
+ if (isReservedArrowChord(event)) return;
398
+ if (this.#composition.isComposing(event)) return;
399
+ this.#reconcileActiveForInteraction();
400
+ switch (logicalArrowKey(event.key, this.element)) {
401
+ case "ArrowDown":
402
+ event.preventDefault();
403
+ if (this.#isClosed) this.open();
404
+ else this.#moveActive(1);
405
+ break;
406
+ case "ArrowUp":
407
+ event.preventDefault();
408
+ if (this.#isClosed) this.open();
409
+ else this.#moveActive(-1);
410
+ break;
411
+ case "Home":
412
+ if (!this.#isClosed) {
413
+ event.preventDefault();
414
+ this.#setActive(this.#visibleOptions[0] ?? null);
415
+ }
416
+ break;
417
+ case "End": {
418
+ if (!this.#isClosed) {
419
+ event.preventDefault();
420
+ const visible = this.#visibleOptions;
421
+ this.#setActive(visible[visible.length - 1] ?? null);
422
+ }
423
+ break;
424
+ }
425
+ case "Enter": {
426
+ const active = this.#activeOption;
427
+ if (!this.#isClosed && active) {
428
+ event.preventDefault();
429
+ this.#toggleSelection(active);
430
+ }
431
+ break;
432
+ }
433
+ case "Escape":
434
+ if (this.#isClosed) break;
435
+ event.preventDefault();
436
+ this.close();
437
+ break;
438
+ case "Backspace":
439
+ if (this.inputTarget.value === "") {
440
+ const buttons = this.#removeButtons;
441
+ if (buttons.length > 0) {
442
+ event.preventDefault();
443
+ this.#removeTagAt(buttons.length - 1);
444
+ }
445
+ }
446
+ break;
447
+ case "ArrowLeft":
448
+ if (this.inputTarget.value === "" && this.#removeButtons.length > 0) {
449
+ event.preventDefault();
450
+ this.#roving.setActive(this.#removeButtons.length - 1, { focus: true });
451
+ }
452
+ break;
453
+ case "Tab":
454
+ this.close();
455
+ break;
456
+ }
457
+ }
458
+ /**
459
+ * Toggles the clicked option's selection. Bound via `data-action`. Focus is
460
+ * re-homed to the input afterwards: options are non-focusable, so the click blurs
461
+ * the input to `body` — and with the list deliberately staying open, every
462
+ * keyboard affordance (Escape, arrows, typing) is bound to the input and would
463
+ * otherwise go dead until the user clicks back in ("focus stays on the input").
464
+ */
465
+ toggleOption(event) {
466
+ const option = event.currentTarget.closest('[role="option"]');
467
+ if (!option || !this.optionTargets.includes(option)) return;
468
+ this.#toggleSelection(option);
469
+ this.#focusInput();
470
+ }
471
+ /**
472
+ * Re-homes focus to the input, or leaves it where it is when there is none.
473
+ *
474
+ * All three callers run *after* an option or a chip already took focus, and all
475
+ * three are reachable without an input — options and chips carry their own
476
+ * `data-action`. Throwing here would leave the chip removed but focus stranded
477
+ * on a detached button.
478
+ */
479
+ #focusInput() {
480
+ if (this.hasInputTarget) this.inputTarget.focus();
481
+ }
482
+ /**
483
+ * Re-homes focus after the last chip was removed: to the input, else the root.
484
+ *
485
+ * Unlike the other {@link #focusInput} callers, the element that held focus has
486
+ * just left the DOM, so "leave it alone" is not an option — the browser already
487
+ * dropped it to `<body>`. The root borrows a `tabindex="-1"` just-in-time (not a
488
+ * Tab stop, handed back on teardown). Focus that landed on a real element is
489
+ * left alone, so a chip removed out of band never steals it.
490
+ */
491
+ #focusAfterLastTag() {
492
+ if (this.hasInputTarget) {
493
+ this.inputTarget.focus();
494
+ return;
495
+ }
496
+ const doc = this.element.ownerDocument;
497
+ const active = doc.activeElement;
498
+ if (active && active !== doc.body && active !== doc.documentElement && active.isConnected) {
499
+ return;
500
+ }
501
+ this.#tabindex.lend(this.element);
502
+ this.element.focus();
503
+ }
504
+ /**
505
+ * Returns the borrowed tab stop. Owning the borrow is not enough — the value
506
+ * has to still be the one this instance wrote, since a consumer that changed it
507
+ * afterwards owns it now.
508
+ */
509
+ #releaseTabindex() {
510
+ this.#tabindex.returnAll();
511
+ }
512
+ /**
513
+ * Removes the chip whose remove button was clicked, deselecting its option.
514
+ * Delegated on the tags container (like `#onTagKeydown`) rather than bound
515
+ * per chip via `data-action`, so it works the instant a chip is appended without
516
+ * waiting on Stimulus to wire a freshly created element.
517
+ */
518
+ #onTagClick = (event) => {
519
+ const button = event.target.closest("button");
520
+ if (!button || !this.tagsTarget.contains(button)) return;
521
+ const index = this.#removeButtons.indexOf(button);
522
+ if (index !== -1) this.#removeTagAt(index);
523
+ };
524
+ /** Moves the active option by `delta` among visible options, wrapping. */
525
+ #moveActive(delta) {
526
+ const visible = this.#visibleOptions;
527
+ if (visible.length === 0) return;
528
+ const current = this.#activeOption ? visible.indexOf(this.#activeOption) : -1;
529
+ const candidate = current === -1 ? delta > 0 ? 0 : visible.length - 1 : current + delta;
530
+ const next = (candidate + visible.length) % visible.length;
531
+ this.#setActive(visible[next] ?? null);
532
+ }
533
+ /** Selects/deselects `option`, honoring `max`, and syncs chip + live region. */
534
+ #toggleSelection(option) {
535
+ const selected = option.getAttribute("aria-selected") === "true";
536
+ if (!selected && this.maxValue > 0 && this.#selectedOptions.length >= this.maxValue) {
537
+ return;
538
+ }
539
+ option.setAttribute("aria-selected", String(!selected));
540
+ if (selected) {
541
+ this.#removeTagFor(option);
542
+ } else {
543
+ this.#appendTag(option);
544
+ }
545
+ this.#announce(this.#optionLabel(option));
546
+ this.#refreshRoving();
547
+ this.#syncFields();
548
+ this.dispatch("change", { detail: { values: this.#values } });
549
+ }
550
+ /**
551
+ * Re-reads each chip's label from its option, in place.
552
+ *
553
+ * The value order alone does not say the chips are still correct: a server can
554
+ * re-render the same candidate with a new label ("Apple" → "Green Apple"), and
555
+ * the chip text and its `Remove {label}` name are both derived from the option.
556
+ * Updating them here keeps the rebuild — which would drop focus — for the case
557
+ * that actually needs it, a changed selection.
558
+ */
559
+ #refreshTagLabels(selected) {
560
+ const options = new Map(selected.map((option) => [this.#optionValue(option), option]));
561
+ for (const tag of this.tagTargets) {
562
+ const option = options.get(tag.dataset.value ?? "");
563
+ if (!option) continue;
564
+ const text = this.#optionLabel(option);
565
+ const label = tag.querySelector('[data-multi-select-slot="label"]');
566
+ if (label && label.textContent !== text) label.textContent = text;
567
+ const button = tag.querySelector("button");
568
+ const name = `Remove ${text}`;
569
+ if (button && button.getAttribute("aria-label") !== name) {
570
+ button.setAttribute("aria-label", name);
571
+ }
572
+ }
573
+ }
574
+ /** Builds one chip from the template for `option`. */
575
+ #appendTag(option) {
576
+ if (!this.hasTagTemplateTarget || !this.hasTagsTarget) return;
577
+ const fragment = this.tagTemplateTarget.content.cloneNode(true);
578
+ const tag = fragment.querySelector('[data-stimeo--multi-select-target="tag"]');
579
+ const label = fragment.querySelector('[data-multi-select-slot="label"]');
580
+ const button = fragment.querySelector("button");
581
+ if (!tag || !button) return;
582
+ const text = this.#optionLabel(option);
583
+ tag.dataset.value = this.#optionValue(option);
584
+ if (label) label.textContent = text;
585
+ button.setAttribute("aria-label", `Remove ${text}`);
586
+ button.tabIndex = -1;
587
+ this.tagsTarget.appendChild(fragment);
588
+ }
589
+ /** Removes the chip mirroring `option`, if present. */
590
+ #removeTagFor(option) {
591
+ const value = this.#optionValue(option);
592
+ const tag = this.tagTargets.find((candidate) => candidate.dataset.value === value);
593
+ tag?.remove();
594
+ }
595
+ /** Removes chip `index` and deselects its option, re-homing focus. */
596
+ #removeTagAt(index) {
597
+ const tag = this.tagTargets[index];
598
+ if (!tag) return;
599
+ const value = tag.dataset.value ?? "";
600
+ const option = this.optionTargets.find((candidate) => this.#optionValue(candidate) === value);
601
+ if (option) option.setAttribute("aria-selected", "false");
602
+ tag.remove();
603
+ this.#announce(option ? this.#optionLabel(option) : value);
604
+ this.#refreshRoving();
605
+ this.#syncFields();
606
+ this.dispatch("change", { detail: { values: this.#values } });
607
+ const remaining = this.#removeButtons;
608
+ if (remaining.length === 0) {
609
+ this.#focusAfterLastTag();
610
+ } else {
611
+ this.#roving.setActive(Math.min(index, remaining.length - 1), { focus: true });
612
+ }
613
+ }
614
+ /** Arrow navigation and deletion within the chip list (delegated). */
615
+ #onTagKeydown = (event) => {
616
+ if (event.defaultPrevented) return;
617
+ if (isReservedArrowChord(event)) return;
618
+ const button = event.target.closest("button");
619
+ if (!button) return;
620
+ const buttons = this.#removeButtons;
621
+ const index = buttons.indexOf(button);
622
+ if (index === -1) return;
623
+ switch (logicalArrowKey(event.key, this.element)) {
624
+ case "ArrowLeft":
625
+ if (index > 0) {
626
+ event.preventDefault();
627
+ this.#roving.setActive(index - 1, { focus: true });
628
+ }
629
+ break;
630
+ case "ArrowRight":
631
+ event.preventDefault();
632
+ if (index < buttons.length - 1) this.#roving.setActive(index + 1, { focus: true });
633
+ else this.#focusInput();
634
+ break;
635
+ case "Delete":
636
+ case "Backspace":
637
+ event.preventDefault();
638
+ this.#removeTagAt(index);
639
+ break;
640
+ }
641
+ };
642
+ /**
643
+ * Marks `option` active via `data-active` and the input's
644
+ * `aria-activedescendant` (the attribute is removed, not emptied, when null).
645
+ *
646
+ * The state half runs even with no input, so a `close()` that cannot touch ARIA
647
+ * still clears it: {@link open} seeds an active option only when there is none,
648
+ * so a stale one makes the next open skip the seeding and a replacement input
649
+ * gets no `aria-activedescendant` at all.
650
+ */
651
+ #setActive(option) {
652
+ const activeId = option ? ensureId(option, "stimeo-ms-opt") : null;
653
+ this.#activeOptionId = activeId;
654
+ for (const candidate of this.optionTargets) {
655
+ candidate.toggleAttribute("data-active", candidate === option);
656
+ }
657
+ if (option && this.hasListTarget) scrollOptionIntoView(this.listTarget, option);
658
+ if (!this.hasInputTarget) return;
659
+ if (activeId !== null) {
660
+ this.inputTarget.setAttribute("aria-activedescendant", activeId);
661
+ } else {
662
+ this.inputTarget.removeAttribute("aria-activedescendant");
663
+ }
664
+ }
665
+ /** Repairs only active identity before a key; the fallback waits for the target callback. */
666
+ #reconcileActiveForInteraction() {
667
+ const activeId = this.#activeOptionId;
668
+ if (activeId === null) return;
669
+ const resolved = this.#activeOption;
670
+ const active = resolved && !resolved.hidden ? resolved : null;
671
+ const marked = this.optionTargets.filter((candidate) => candidate.hasAttribute("data-active"));
672
+ const idref = this.hasInputTarget ? this.inputTarget.getAttribute("aria-activedescendant") : null;
673
+ if (!active || marked.length !== 1 || marked[0] !== active || idref !== activeId) {
674
+ this.#setActive(active);
675
+ }
676
+ }
677
+ /** Reflects whether the open list currently has no visible option targets. */
678
+ #reflectEmpty() {
679
+ this.element.toggleAttribute(
680
+ "data-stimeo--multi-select-empty",
681
+ !this.#isClosed && this.#visibleOptions.length === 0
682
+ );
683
+ }
684
+ /**
685
+ * Mirrors the selected values into named hidden inputs under the `fields`
686
+ * target so the selection submits with a normal form (parity with tags-input).
687
+ * No-ops without a `fields` target. When the
688
+ * `form` value is set, each input gets a matching `form` attribute so the picker
689
+ * can submit with a `<form>` it lives outside of.
690
+ */
691
+ #syncFields() {
692
+ if (!this.hasFieldsTarget) return;
693
+ this.fieldsTarget.replaceChildren(
694
+ ...this.#values.map((value) => {
695
+ const input = document.createElement("input");
696
+ input.type = "hidden";
697
+ input.name = this.nameValue;
698
+ input.value = value;
699
+ if (this.hasFormValue && this.formValue !== "") input.setAttribute("form", this.formValue);
700
+ return input;
701
+ })
702
+ );
703
+ }
704
+ /** Keeps exactly one chip remove button tabbable after the set changes. */
705
+ #refreshRoving() {
706
+ if (this.#removeButtons.length > 0 && this.#roving.activeIndex === -1)
707
+ this.#roving.setActive(0);
708
+ }
709
+ /** Mirrors the changed option label into the live region. */
710
+ #announce(text) {
711
+ if (this.hasStatusTarget) this.statusTarget.textContent = text;
712
+ }
713
+ /** Closes the list on a click outside the controller element. */
714
+ #onOutsideClick = (event) => {
715
+ if (!this.#isClosed && !this.element.contains(event.target)) this.close();
716
+ };
717
+ /** Trimmed visible label of an option. */
718
+ #optionLabel(option) {
719
+ return (option.textContent ?? "").trim();
720
+ }
721
+ /** An option's stable value: its `data-value`, else its display label. */
722
+ #optionValue(option) {
723
+ return option.dataset.value ?? this.#optionLabel(option);
724
+ }
725
+ /** Options not hidden by the current filter. */
726
+ get #visibleOptions() {
727
+ return this.optionTargets.filter((option) => !option.hidden);
728
+ }
729
+ /** Current active target resolved by stable id, never a detached node reference. */
730
+ get #activeOption() {
731
+ const activeId = this.#activeOptionId;
732
+ if (activeId === null) return null;
733
+ return this.optionTargets.find((option) => option.id === activeId) ?? null;
734
+ }
735
+ /** Options currently selected. */
736
+ get #selectedOptions() {
737
+ return this.optionTargets.filter((option) => option.getAttribute("aria-selected") === "true");
738
+ }
739
+ /** Selected values in option order. */
740
+ get #values() {
741
+ return this.#selectedOptions.map((option) => this.#optionValue(option));
742
+ }
743
+ /** The chip remove buttons in order (the roving navigation set). */
744
+ get #removeButtons() {
745
+ return this.hasTagsTarget ? Array.from(this.tagsTarget.querySelectorAll("button")) : [];
746
+ }
747
+ /** Whether the list is currently hidden. */
748
+ get #isClosed() {
749
+ return !this.hasListTarget || this.listTarget.hidden !== false;
750
+ }
751
+ };
752
+
753
+ export { MultiSelectController };
754
+ //# sourceMappingURL=multi_select_controller.js.map
755
+ //# sourceMappingURL=multi_select_controller.js.map