stimeo-ui 0.2.0 → 0.2.1

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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +59 -0
  3. data/dist/controllers/alert_dialog_controller.js +318 -0
  4. data/dist/controllers/carousel_controller.js +272 -0
  5. data/dist/controllers/clipboard_controller.js +144 -0
  6. data/dist/controllers/collapsible_controller.js +327 -0
  7. data/dist/controllers/color_picker_controller.js +213 -0
  8. data/dist/controllers/count_up_controller.js +8 -1
  9. data/dist/controllers/currency_input_controller.js +147 -0
  10. data/dist/controllers/data_grid_controller.js +168 -0
  11. data/dist/controllers/date_range_picker_controller.js +417 -0
  12. data/dist/controllers/dismissible_controller.js +117 -0
  13. data/dist/controllers/drawer_controller.js +630 -0
  14. data/dist/controllers/editable_controller.js +168 -0
  15. data/dist/controllers/file_dropzone_controller.js +165 -0
  16. data/dist/controllers/filter_controller.js +86 -0
  17. data/dist/controllers/flash_controller.js +36 -5
  18. data/dist/controllers/highlight_controller.js +6 -4
  19. data/dist/controllers/intersection_controller.js +41 -18
  20. data/dist/controllers/lazy_frame_controller.js +33 -11
  21. data/dist/controllers/masonry_controller.js +142 -0
  22. data/dist/controllers/menubar_controller.js +433 -0
  23. data/dist/controllers/multi_select_controller.js +472 -0
  24. data/dist/controllers/navigation_menu_controller.js +384 -0
  25. data/dist/controllers/overflow_indicator_controller.js +178 -27
  26. data/dist/controllers/password_reveal_controller.js +117 -0
  27. data/dist/controllers/range_slider_controller.js +166 -0
  28. data/dist/controllers/read_more_controller.js +194 -0
  29. data/dist/controllers/scroll_area_controller.js +15 -2
  30. data/dist/controllers/scroll_restore_controller.js +93 -0
  31. data/dist/controllers/scroll_visibility_controller.js +8 -4
  32. data/dist/controllers/scrollspy_controller.js +33 -11
  33. data/dist/controllers/separator_controller.js +87 -0
  34. data/dist/controllers/sidebar_controller.js +761 -0
  35. data/dist/controllers/stepper_controller.js +28 -12
  36. data/dist/controllers/stick_to_bottom_controller.js +8 -4
  37. data/dist/controllers/sticky_observer_controller.js +88 -20
  38. data/dist/controllers/tags_input_controller.js +275 -0
  39. data/dist/controllers/theme_controller.js +20 -10
  40. data/dist/controllers/time_picker_controller.js +212 -0
  41. data/dist/controllers/toast_controller.js +36 -9
  42. data/dist/controllers/transition_controller.js +153 -38
  43. data/dist/controllers/tree_view_controller.js +275 -0
  44. data/dist/index.js +811 -295
  45. data/lib/stimeo/ui/version.rb +1 -1
  46. metadata +28 -2
@@ -0,0 +1,472 @@
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/composition_tracker.ts
23
+ var CompositionTracker = class {
24
+ #observedTargets = /* @__PURE__ */ new Set();
25
+ #activeTargets = /* @__PURE__ */ new Set();
26
+ #onStart;
27
+ #onEnd;
28
+ constructor(options = {}) {
29
+ this.#onStart = options.onStart;
30
+ this.#onEnd = options.onEnd;
31
+ }
32
+ /** Starts lifecycle tracking for `target`; repeated calls are idempotent. */
33
+ observe(target) {
34
+ if (this.#observedTargets.has(target)) return;
35
+ target.addEventListener("compositionstart", this.#handleStart);
36
+ target.addEventListener("compositionend", this.#handleEnd);
37
+ this.#observedTargets.add(target);
38
+ }
39
+ /** Stops tracking one target and clears any active composition it owned. */
40
+ unobserve(target) {
41
+ if (!this.#observedTargets.delete(target)) return;
42
+ target.removeEventListener("compositionstart", this.#handleStart);
43
+ target.removeEventListener("compositionend", this.#handleEnd);
44
+ this.#activeTargets.delete(target);
45
+ }
46
+ /** Releases every listener and clears state so reconnect starts cleanly. */
47
+ disconnect() {
48
+ for (const target of this.#observedTargets) {
49
+ target.removeEventListener("compositionstart", this.#handleStart);
50
+ target.removeEventListener("compositionend", this.#handleEnd);
51
+ }
52
+ this.#observedTargets.clear();
53
+ this.#activeTargets.clear();
54
+ }
55
+ /** True when lifecycle tracking or the current event reports composition. */
56
+ isComposing(event) {
57
+ return this.#activeTargets.size > 0 || event?.isComposing === true;
58
+ }
59
+ #handleStart = (event) => {
60
+ if (event.currentTarget) this.#activeTargets.add(event.currentTarget);
61
+ this.#onStart?.(event);
62
+ };
63
+ #handleEnd = (event) => {
64
+ if (event.currentTarget) this.#activeTargets.delete(event.currentTarget);
65
+ this.#onEnd?.(event);
66
+ };
67
+ };
68
+
69
+ // src/utils/option_scroll.ts
70
+ function scrollOptionIntoView(list, option) {
71
+ if (list.scrollHeight <= list.clientHeight) return;
72
+ const listRect = list.getBoundingClientRect();
73
+ const optionRect = option.getBoundingClientRect();
74
+ if (optionRect.top < listRect.top) {
75
+ list.scrollTop -= listRect.top - optionRect.top;
76
+ } else if (optionRect.bottom > listRect.bottom) {
77
+ list.scrollTop += optionRect.bottom - listRect.bottom;
78
+ }
79
+ }
80
+
81
+ // src/utils/roving_tabindex.ts
82
+ var RovingTabindex = class {
83
+ /** Returns the current ordered item elements; called on every operation. */
84
+ #getItems;
85
+ /**
86
+ * @param getItems - Returns the current ordered item elements. Called on every
87
+ * operation so the live target list is always used.
88
+ */
89
+ constructor(getItems) {
90
+ this.#getItems = getItems;
91
+ }
92
+ /** Index of the currently tabbable item (`tabindex="0"`), or `-1` if none. */
93
+ get activeIndex() {
94
+ return this.#getItems().findIndex((item) => item.tabIndex === 0);
95
+ }
96
+ /**
97
+ * Makes exactly the item at `index` tabbable (`tabindex="0"`) and removes every
98
+ * other item from the Tab sequence (`tabindex="-1"`). An out-of-range `index`
99
+ * (e.g. `-1`) leaves all items at `-1`, which a controller can use to express
100
+ * "nothing is currently tabbable".
101
+ *
102
+ * @param index - Position of the item to make tabbable.
103
+ * @param options - Pass `{ focus: true }` to also move DOM focus to that item.
104
+ */
105
+ setActive(index, { focus = false } = {}) {
106
+ const items = this.#getItems();
107
+ items.forEach((item, i) => {
108
+ item.tabIndex = i === index ? 0 : -1;
109
+ });
110
+ if (focus) items[index]?.focus();
111
+ }
112
+ };
113
+
114
+ // src/controllers/multi_select_controller.ts
115
+ var MultiSelectController = class extends Controller {
116
+ static targets = [
117
+ "input",
118
+ "list",
119
+ "option",
120
+ "tags",
121
+ "tag",
122
+ "tagTemplate",
123
+ "status",
124
+ "fields"
125
+ ];
126
+ static values = {
127
+ max: { type: Number, default: 0 },
128
+ name: { type: String, default: "options[]" },
129
+ form: { type: String, default: "" }
130
+ };
131
+ static actions = ["close", "filter", "onKeydown", "open", "toggleOption"];
132
+ static events = ["change", "filter"];
133
+ /** The active option (tracked via `aria-activedescendant`), or null. */
134
+ #activeOption = null;
135
+ /** Absorbs the browser's redundant final input after compositionend. */
136
+ #ignorePostCompositionInput = false;
137
+ /** Owns IME lifecycle state; confirmed text emits one filter result. */
138
+ #composition = new CompositionTracker({
139
+ onStart: () => {
140
+ this.#ignorePostCompositionInput = false;
141
+ },
142
+ onEnd: () => {
143
+ this.#ignorePostCompositionInput = true;
144
+ queueMicrotask(() => {
145
+ this.#ignorePostCompositionInput = false;
146
+ });
147
+ this.filter();
148
+ }
149
+ });
150
+ #roving = new RovingTabindex(() => this.#removeButtons);
151
+ /** Starts closed, syncs chips for any pre-selected options, and listens out. */
152
+ connect() {
153
+ if (this.hasInputTarget) this.#composition.observe(this.inputTarget);
154
+ this.close();
155
+ if (this.hasTagsTarget) {
156
+ this.tagsTarget.addEventListener("keydown", this.#onTagKeydown);
157
+ this.tagsTarget.addEventListener("click", this.#onTagClick);
158
+ for (const tag of this.tagTargets) tag.remove();
159
+ for (const option of this.#selectedOptions) this.#appendTag(option);
160
+ if (this.#removeButtons.length > 0) this.#roving.setActive(0);
161
+ }
162
+ this.#syncFields();
163
+ document.addEventListener("click", this.#onOutsideClick);
164
+ }
165
+ /** Tears down document and chip listeners on disconnect (Turbo included). */
166
+ disconnect() {
167
+ this.#composition.disconnect();
168
+ this.#ignorePostCompositionInput = false;
169
+ if (this.hasTagsTarget) {
170
+ this.tagsTarget.removeEventListener("keydown", this.#onTagKeydown);
171
+ this.tagsTarget.removeEventListener("click", this.#onTagClick);
172
+ }
173
+ document.removeEventListener("click", this.#onOutsideClick);
174
+ }
175
+ /** Tracks an input added initially or after connect. */
176
+ inputTargetConnected(input) {
177
+ this.#composition.observe(input);
178
+ }
179
+ /** Removes composition listeners when the active input is replaced or removed. */
180
+ inputTargetDisconnected(input) {
181
+ this.#composition.unobserve(input);
182
+ this.#ignorePostCompositionInput = false;
183
+ }
184
+ /** Filters confirmed input text, opens, and re-seeds the active option. */
185
+ filter(event) {
186
+ if (event && this.#ignorePostCompositionInput) {
187
+ this.#ignorePostCompositionInput = false;
188
+ return;
189
+ }
190
+ if (this.#composition.isComposing(event)) return;
191
+ const query = this.inputTarget.value.trim().toLowerCase();
192
+ for (const option of this.optionTargets) {
193
+ const label = (option.textContent ?? "").trim().toLowerCase();
194
+ option.hidden = query !== "" && !label.includes(query);
195
+ }
196
+ this.open();
197
+ const visible = this.#visibleOptions;
198
+ this.element.toggleAttribute("data-stimeo--multi-select-empty", visible.length === 0);
199
+ this.#setActive(visible[0] ?? null);
200
+ this.dispatch("filter", { detail: { query } });
201
+ }
202
+ /** Opens the list and activates the first visible option when none is active. */
203
+ open() {
204
+ if (!this.hasListTarget) return;
205
+ this.listTarget.hidden = false;
206
+ this.inputTarget.setAttribute("aria-expanded", "true");
207
+ if (!this.#activeOption) this.#setActive(this.#visibleOptions[0] ?? null);
208
+ }
209
+ /** Closes the list and clears the active option. */
210
+ close() {
211
+ if (!this.hasListTarget) return;
212
+ this.listTarget.hidden = true;
213
+ this.inputTarget.setAttribute("aria-expanded", "false");
214
+ this.#setActive(null);
215
+ }
216
+ /** Routes input keyboard interaction per the multi-select combobox model. */
217
+ onKeydown(event) {
218
+ if (this.#composition.isComposing(event)) return;
219
+ switch (event.key) {
220
+ case "ArrowDown":
221
+ event.preventDefault();
222
+ if (this.#isClosed) this.open();
223
+ else this.#moveActive(1);
224
+ break;
225
+ case "ArrowUp":
226
+ event.preventDefault();
227
+ if (this.#isClosed) this.open();
228
+ else this.#moveActive(-1);
229
+ break;
230
+ case "Home":
231
+ if (!this.#isClosed) {
232
+ event.preventDefault();
233
+ this.#setActive(this.#visibleOptions[0] ?? null);
234
+ }
235
+ break;
236
+ case "End": {
237
+ if (!this.#isClosed) {
238
+ event.preventDefault();
239
+ const visible = this.#visibleOptions;
240
+ this.#setActive(visible[visible.length - 1] ?? null);
241
+ }
242
+ break;
243
+ }
244
+ case "Enter":
245
+ if (!this.#isClosed && this.#activeOption) {
246
+ event.preventDefault();
247
+ this.#toggleSelection(this.#activeOption);
248
+ }
249
+ break;
250
+ case "Escape":
251
+ if (event.defaultPrevented || this.#isClosed) break;
252
+ event.preventDefault();
253
+ this.close();
254
+ break;
255
+ case "Backspace":
256
+ if (this.inputTarget.value === "") {
257
+ const buttons = this.#removeButtons;
258
+ if (buttons.length > 0) {
259
+ event.preventDefault();
260
+ this.#removeTagAt(buttons.length - 1);
261
+ }
262
+ }
263
+ break;
264
+ case "ArrowLeft":
265
+ if (this.inputTarget.value === "" && this.#removeButtons.length > 0) {
266
+ event.preventDefault();
267
+ this.#roving.setActive(this.#removeButtons.length - 1, { focus: true });
268
+ }
269
+ break;
270
+ case "Tab":
271
+ this.close();
272
+ break;
273
+ }
274
+ }
275
+ /**
276
+ * Toggles the clicked option's selection. Bound via `data-action`. Focus is
277
+ * re-homed to the input afterwards: options are non-focusable, so the click blurs
278
+ * the input to `body` — and with the list deliberately staying open, every
279
+ * keyboard affordance (Escape, arrows, typing) is bound to the input and would
280
+ * otherwise go dead until the user clicks back in ("focus stays on the input").
281
+ */
282
+ toggleOption(event) {
283
+ const option = event.currentTarget.closest('[role="option"]');
284
+ if (!option) return;
285
+ this.#toggleSelection(option);
286
+ this.inputTarget.focus();
287
+ }
288
+ /**
289
+ * Removes the chip whose remove button was clicked, deselecting its option.
290
+ * Delegated on the tags container (like `#onTagKeydown`) rather than bound
291
+ * per chip via `data-action`, so it works the instant a chip is appended without
292
+ * waiting on Stimulus to wire a freshly created element.
293
+ */
294
+ #onTagClick = (event) => {
295
+ const button = event.target.closest("button");
296
+ if (!button || !this.tagsTarget.contains(button)) return;
297
+ const index = this.#removeButtons.indexOf(button);
298
+ if (index !== -1) this.#removeTagAt(index);
299
+ };
300
+ /** Moves the active option by `delta` among visible options, wrapping. */
301
+ #moveActive(delta) {
302
+ const visible = this.#visibleOptions;
303
+ if (visible.length === 0) return;
304
+ const current = this.#activeOption ? visible.indexOf(this.#activeOption) : -1;
305
+ const next = (current + delta + visible.length) % visible.length;
306
+ this.#setActive(visible[next] ?? null);
307
+ }
308
+ /** Selects/deselects `option`, honoring `max`, and syncs chip + live region. */
309
+ #toggleSelection(option) {
310
+ const selected = option.getAttribute("aria-selected") === "true";
311
+ if (!selected && this.maxValue > 0 && this.#selectedOptions.length >= this.maxValue) {
312
+ return;
313
+ }
314
+ option.setAttribute("aria-selected", String(!selected));
315
+ if (selected) {
316
+ this.#removeTagFor(option);
317
+ } else {
318
+ this.#appendTag(option);
319
+ }
320
+ this.#announce(this.#optionLabel(option));
321
+ this.#refreshRoving();
322
+ this.#syncFields();
323
+ this.dispatch("change", { detail: { values: this.#values } });
324
+ }
325
+ /** Builds one chip from the template for `option`. */
326
+ #appendTag(option) {
327
+ if (!this.hasTagTemplateTarget || !this.hasTagsTarget) return;
328
+ const fragment = this.tagTemplateTarget.content.cloneNode(true);
329
+ const tag = fragment.querySelector('[data-stimeo--multi-select-target="tag"]');
330
+ const label = fragment.querySelector('[data-multi-select-slot="label"]');
331
+ const button = fragment.querySelector("button");
332
+ if (!tag || !button) return;
333
+ const text = this.#optionLabel(option);
334
+ tag.dataset.value = this.#optionValue(option);
335
+ if (label) label.textContent = text;
336
+ button.setAttribute("aria-label", `Remove ${text}`);
337
+ button.tabIndex = -1;
338
+ this.tagsTarget.appendChild(fragment);
339
+ }
340
+ /** Removes the chip mirroring `option`, if present. */
341
+ #removeTagFor(option) {
342
+ const value = this.#optionValue(option);
343
+ const tag = this.tagTargets.find((candidate) => candidate.dataset.value === value);
344
+ tag?.remove();
345
+ }
346
+ /** Removes chip `index` and deselects its option, re-homing focus. */
347
+ #removeTagAt(index) {
348
+ const tag = this.tagTargets[index];
349
+ if (!tag) return;
350
+ const value = tag.dataset.value ?? "";
351
+ const option = this.optionTargets.find((candidate) => this.#optionValue(candidate) === value);
352
+ if (option) option.setAttribute("aria-selected", "false");
353
+ tag.remove();
354
+ this.#announce(option ? this.#optionLabel(option) : value);
355
+ this.#refreshRoving();
356
+ this.#syncFields();
357
+ this.dispatch("change", { detail: { values: this.#values } });
358
+ const remaining = this.#removeButtons;
359
+ if (remaining.length === 0) {
360
+ this.inputTarget.focus();
361
+ } else {
362
+ this.#roving.setActive(Math.min(index, remaining.length - 1), { focus: true });
363
+ }
364
+ }
365
+ /** Arrow navigation and deletion within the chip list (delegated). */
366
+ #onTagKeydown = (event) => {
367
+ const button = event.target.closest("button");
368
+ if (!button) return;
369
+ const buttons = this.#removeButtons;
370
+ const index = buttons.indexOf(button);
371
+ if (index === -1) return;
372
+ switch (event.key) {
373
+ case "ArrowLeft":
374
+ if (index > 0) {
375
+ event.preventDefault();
376
+ this.#roving.setActive(index - 1, { focus: true });
377
+ }
378
+ break;
379
+ case "ArrowRight":
380
+ event.preventDefault();
381
+ if (index < buttons.length - 1) this.#roving.setActive(index + 1, { focus: true });
382
+ else this.inputTarget.focus();
383
+ break;
384
+ case "Delete":
385
+ case "Backspace":
386
+ event.preventDefault();
387
+ this.#removeTagAt(index);
388
+ break;
389
+ }
390
+ };
391
+ /**
392
+ * Marks `option` active via `data-active` and the input's
393
+ * `aria-activedescendant` (the attribute is removed, not emptied, when null).
394
+ */
395
+ #setActive(option) {
396
+ this.#activeOption = option;
397
+ for (const candidate of this.optionTargets) {
398
+ candidate.toggleAttribute("data-active", candidate === option);
399
+ }
400
+ if (option) {
401
+ this.inputTarget.setAttribute("aria-activedescendant", ensureId(option, "stimeo-ms-opt"));
402
+ if (this.hasListTarget) scrollOptionIntoView(this.listTarget, option);
403
+ } else {
404
+ this.inputTarget.removeAttribute("aria-activedescendant");
405
+ }
406
+ }
407
+ /**
408
+ * Mirrors the selected values into named hidden inputs under the `fields`
409
+ * target so the selection submits with a normal form (parity with tags-input).
410
+ * No-ops without a `fields` target, keeping the control back-compat. When the
411
+ * `form` value is set, each input gets a matching `form` attribute so the picker
412
+ * can submit with a `<form>` it lives outside of.
413
+ */
414
+ #syncFields() {
415
+ if (!this.hasFieldsTarget) return;
416
+ this.fieldsTarget.replaceChildren(
417
+ ...this.#values.map((value) => {
418
+ const input = document.createElement("input");
419
+ input.type = "hidden";
420
+ input.name = this.nameValue;
421
+ input.value = value;
422
+ if (this.hasFormValue && this.formValue !== "") input.setAttribute("form", this.formValue);
423
+ return input;
424
+ })
425
+ );
426
+ }
427
+ /** Keeps exactly one chip remove button tabbable after the set changes. */
428
+ #refreshRoving() {
429
+ if (this.#removeButtons.length > 0 && this.#roving.activeIndex === -1)
430
+ this.#roving.setActive(0);
431
+ }
432
+ /** Mirrors the changed option label into the live region. */
433
+ #announce(text) {
434
+ if (this.hasStatusTarget) this.statusTarget.textContent = text;
435
+ }
436
+ /** Closes the list on a click outside the controller element. */
437
+ #onOutsideClick = (event) => {
438
+ if (!this.#isClosed && !this.element.contains(event.target)) this.close();
439
+ };
440
+ /** Trimmed visible label of an option. */
441
+ #optionLabel(option) {
442
+ return (option.textContent ?? "").trim();
443
+ }
444
+ /** An option's stable value: its `data-value`, else its display label. */
445
+ #optionValue(option) {
446
+ return option.dataset.value ?? this.#optionLabel(option);
447
+ }
448
+ /** Options not hidden by the current filter. */
449
+ get #visibleOptions() {
450
+ return this.optionTargets.filter((option) => !option.hidden);
451
+ }
452
+ /** Options currently selected. */
453
+ get #selectedOptions() {
454
+ return this.optionTargets.filter((option) => option.getAttribute("aria-selected") === "true");
455
+ }
456
+ /** Selected values in option order. */
457
+ get #values() {
458
+ return this.#selectedOptions.map((option) => this.#optionValue(option));
459
+ }
460
+ /** The chip remove buttons in order (the roving navigation set). */
461
+ get #removeButtons() {
462
+ return this.hasTagsTarget ? Array.from(this.tagsTarget.querySelectorAll("button")) : [];
463
+ }
464
+ /** Whether the list is currently hidden. */
465
+ get #isClosed() {
466
+ return !this.hasListTarget || this.listTarget.hidden !== false;
467
+ }
468
+ };
469
+
470
+ export { MultiSelectController };
471
+ //# sourceMappingURL=multi_select_controller.js.map
472
+ //# sourceMappingURL=multi_select_controller.js.map