stimeo-ui 0.14.0 → 0.16.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 (107) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +212 -0
  3. data/README.md +120 -0
  4. data/dist/cable/index.js +123 -29
  5. data/dist/controllers/accordion_controller.js +98 -9
  6. data/dist/controllers/announcer_controller.js +96 -62
  7. data/dist/controllers/auto_submit_controller.js +83 -7
  8. data/dist/controllers/avatar_controller.js +1 -1
  9. data/dist/controllers/breadcrumb_controller.js +38 -11
  10. data/dist/controllers/bulk_select_controller.js +7 -6
  11. data/dist/controllers/calendar_controller.js +340 -123
  12. data/dist/controllers/carousel_controller.js +263 -38
  13. data/dist/controllers/character_counter_controller.js +40 -2
  14. data/dist/controllers/checkbox_controller.js +81 -12
  15. data/dist/controllers/clipboard_controller.js +63 -5
  16. data/dist/controllers/collapsible_controller.js +99 -14
  17. data/dist/controllers/color_picker_controller.js +80 -34
  18. data/dist/controllers/combobox_controller.js +106 -16
  19. data/dist/controllers/command_palette_controller.js +35 -3
  20. data/dist/controllers/conditional_fields_controller.js +85 -17
  21. data/dist/controllers/confirm_controller.js +3 -0
  22. data/dist/controllers/context_menu_controller.js +32 -12
  23. data/dist/controllers/countdown_controller.js +129 -26
  24. data/dist/controllers/currency_input_controller.js +221 -67
  25. data/dist/controllers/data_grid_controller.js +195 -29
  26. data/dist/controllers/date_range_picker_controller.js +151 -30
  27. data/dist/controllers/dialog_controller.js +35 -8
  28. data/dist/controllers/direct_upload_controller.js +22 -4
  29. data/dist/controllers/dirty_form_controller.js +46 -13
  30. data/dist/controllers/dismissible_controller.js +1 -0
  31. data/dist/controllers/drawer_controller.js +54 -19
  32. data/dist/controllers/dropdown_controller.js +36 -9
  33. data/dist/controllers/editable_controller.js +34 -0
  34. data/dist/controllers/file_dropzone_controller.js +144 -51
  35. data/dist/controllers/filter_controller.js +20 -6
  36. data/dist/controllers/flash_controller.js +432 -71
  37. data/dist/controllers/focus_controller.js +1 -0
  38. data/dist/controllers/form_field_controller.js +7 -5
  39. data/dist/controllers/form_validation_controller.js +19 -13
  40. data/dist/controllers/frame_loading_controller.js +45 -8
  41. data/dist/controllers/highlight_controller.js +82 -25
  42. data/dist/controllers/hover_card_controller.js +40 -14
  43. data/dist/controllers/idle_controller.js +90 -5
  44. data/dist/controllers/input_mask_controller.js +65 -9
  45. data/dist/controllers/intersection_controller.js +3 -0
  46. data/dist/controllers/lazy_frame_controller.js +11 -2
  47. data/dist/controllers/listbox_controller.js +203 -45
  48. data/dist/controllers/local_time_controller.js +10 -5
  49. data/dist/controllers/masonry_controller.js +31 -15
  50. data/dist/controllers/menu_controller.js +45 -16
  51. data/dist/controllers/menubar_controller.js +58 -24
  52. data/dist/controllers/meter_controller.js +9 -5
  53. data/dist/controllers/multi_select_controller.js +278 -104
  54. data/dist/controllers/navigation_menu_controller.js +48 -15
  55. data/dist/controllers/nested_form_controller.js +37 -8
  56. data/dist/controllers/network_status_controller.js +9 -1
  57. data/dist/controllers/number_input_controller.js +124 -21
  58. data/dist/controllers/optimistic_controller.js +42 -5
  59. data/dist/controllers/otp_controller.js +198 -55
  60. data/dist/controllers/overflow_indicator_controller.js +115 -21
  61. data/dist/controllers/overflow_menu_controller.js +141 -44
  62. data/dist/controllers/pagination_controller.js +74 -28
  63. data/dist/controllers/password_reveal_controller.js +59 -2
  64. data/dist/controllers/persist_controller.js +30 -8
  65. data/dist/controllers/pointer_drag_controller.js +131 -52
  66. data/dist/controllers/popover_controller.js +45 -11
  67. data/dist/controllers/portal_controller.js +6 -2
  68. data/dist/controllers/preview_guard_controller.js +16 -1
  69. data/dist/controllers/progress_controller.js +8 -4
  70. data/dist/controllers/radio_group_controller.js +42 -17
  71. data/dist/controllers/range_slider_controller.js +88 -42
  72. data/dist/controllers/rating_controller.js +39 -15
  73. data/dist/controllers/read_more_controller.js +100 -7
  74. data/dist/controllers/reading_progress_controller.js +65 -19
  75. data/dist/controllers/relative_time_controller.js +10 -5
  76. data/dist/controllers/resizable_controller.js +82 -22
  77. data/dist/controllers/scroll_area_controller.js +75 -27
  78. data/dist/controllers/scroll_restore_controller.js +37 -16
  79. data/dist/controllers/scroll_visibility_controller.js +49 -30
  80. data/dist/controllers/scrollspy_controller.js +71 -26
  81. data/dist/controllers/separator_controller.js +66 -37
  82. data/dist/controllers/sidebar_controller.js +77 -18
  83. data/dist/controllers/skeleton_controller.js +6 -1
  84. data/dist/controllers/slider_controller.js +82 -47
  85. data/dist/controllers/smart_sticky_header_controller.js +60 -26
  86. data/dist/controllers/sortable_controller.js +17 -2
  87. data/dist/controllers/spinner_controller.js +10 -2
  88. data/dist/controllers/step_indicator_controller.js +18 -17
  89. data/dist/controllers/stepper_controller.js +101 -19
  90. data/dist/controllers/stick_to_bottom_controller.js +104 -8
  91. data/dist/controllers/submit_once_controller.js +45 -9
  92. data/dist/controllers/switch_controller.js +101 -10
  93. data/dist/controllers/tabs_controller.js +21 -2
  94. data/dist/controllers/tags_input_controller.js +209 -59
  95. data/dist/controllers/textarea_autosize_controller.js +29 -3
  96. data/dist/controllers/theme_controller.js +64 -14
  97. data/dist/controllers/time_picker_controller.js +23 -8
  98. data/dist/controllers/toast_controller.js +451 -105
  99. data/dist/controllers/toggle_group_controller.js +159 -23
  100. data/dist/controllers/toolbar_controller.js +32 -0
  101. data/dist/controllers/tooltip_controller.js +39 -13
  102. data/dist/controllers/transition_controller.js +4 -0
  103. data/dist/controllers/tree_view_controller.js +169 -16
  104. data/dist/index.js +5002 -1911
  105. data/dist/positioning/index.js +2 -0
  106. data/lib/stimeo/ui/version.rb +1 -1
  107. metadata +2 -2
@@ -103,6 +103,25 @@ var DetachGate = class _DetachGate {
103
103
  }
104
104
  };
105
105
 
106
+ // src/utils/element_part.ts
107
+ function matchingPart(root, selector) {
108
+ return root.matches(selector) ? root : root.querySelector(selector);
109
+ }
110
+ function writeLabel(slot, text) {
111
+ if (slot.childElementCount === 0) {
112
+ slot.textContent = text;
113
+ return;
114
+ }
115
+ let first = null;
116
+ for (const node of Array.from(slot.childNodes)) {
117
+ if (node.nodeType !== Node.TEXT_NODE) continue;
118
+ if (first) node.remove();
119
+ else first = node;
120
+ }
121
+ if (first) first.nodeValue = text;
122
+ else slot.prepend(text);
123
+ }
124
+
106
125
  // src/utils/focus_candidate.ts
107
126
  function inheritsFieldsetDisabled(control) {
108
127
  let fieldset = control.closest("fieldset[disabled]");
@@ -114,10 +133,93 @@ function inheritsFieldsetDisabled(control) {
114
133
  return false;
115
134
  }
116
135
 
136
+ // src/utils/target_selector.ts
137
+ function targetSelector(identifier, name) {
138
+ return `[data-${identifier}-target~="${name}"]`;
139
+ }
140
+
141
+ // src/utils/template_row.ts
142
+ function cloneTemplateRoot(template) {
143
+ const root = template.content.firstElementChild;
144
+ return root instanceof HTMLElement ? root.cloneNode(true) : null;
145
+ }
146
+ var TemplateRow = class {
147
+ #options;
148
+ #warned = false;
149
+ constructor(options) {
150
+ this.#options = options;
151
+ }
152
+ /** The attribute selector for one declared part, in this controller's namespace. */
153
+ selector(name) {
154
+ return targetSelector(this.#options.identifier, name);
155
+ }
156
+ /** Re-arms the once-per-connection diagnostic. */
157
+ connect() {
158
+ this.#warned = false;
159
+ }
160
+ /**
161
+ * Names one missing part on the console, at most once per connection, and
162
+ * returns `null` so a caller can hand it straight back.
163
+ */
164
+ report(missing) {
165
+ return this.#say(`lacks ${missing}`);
166
+ }
167
+ /** Writes one diagnostic per connection, saying what the template got wrong. */
168
+ #say(problem) {
169
+ if (this.#warned) return null;
170
+ this.#warned = true;
171
+ const { identifier, outcome, noun } = this.#options;
172
+ console.warn(`Stimeo UI: "${identifier}" ${outcome} because its ${noun} ${problem}.`);
173
+ return null;
174
+ }
175
+ /**
176
+ * Clones the row and resolves its parts, or names what the template got wrong
177
+ * and returns `null`. `values` fills the button's authored `aria-label`.
178
+ */
179
+ instantiate(template, values) {
180
+ const { root: rootName, required, optional, button: buttonName } = this.#options;
181
+ const root = cloneTemplateRoot(template);
182
+ if (!root?.matches(this.selector(rootName))) {
183
+ return this.report(`${this.#article(rootName)} "${rootName}" root`);
184
+ }
185
+ const count = template.content.children.length;
186
+ if (count !== 1) {
187
+ return this.#say(`holds ${count} elements, and the row has to be its only one`);
188
+ }
189
+ const slots = {};
190
+ for (const name2 of required) {
191
+ const part = this.#resolve(root, name2);
192
+ if (!part) return this.report(`${this.#article(name2)} "${name2}" target`);
193
+ slots[name2] = part;
194
+ }
195
+ for (const name2 of optional ?? []) slots[name2] = this.#resolve(root, name2);
196
+ const named = this.#resolve(root, buttonName);
197
+ if (!(named instanceof HTMLButtonElement)) {
198
+ return this.report(`${this.#article(buttonName)} "${buttonName}" target <button>`);
199
+ }
200
+ const button = named;
201
+ const name = button.getAttribute("aria-label")?.trim() ?? "";
202
+ if (name === "") return this.report(`a non-empty aria-label on its "${buttonName}" target`);
203
+ button.setAttribute("aria-label", fillTemplate(name, values));
204
+ return { root, slots, button };
205
+ }
206
+ /** The row may be its own part; otherwise the first descendant carrying the target. */
207
+ #resolve(root, name) {
208
+ return matchingPart(root, this.selector(name));
209
+ }
210
+ #article(name) {
211
+ return /^[aeiou]/i.test(name) ? "an" : "a";
212
+ }
213
+ };
214
+
117
215
  // src/controllers/file_dropzone_controller.ts
118
216
  var DRAGOVER_ATTRIBUTE = "data-dragover";
119
- var INVALID_ATTRIBUTE = "data-stimeo--file-dropzone-invalid";
217
+ var INVALID_ATTRIBUTE = "invalid";
120
218
  var FileDropzoneController = class extends Controller {
219
+ /** The hook above, in the namespace this controller is registered under. */
220
+ get #invalidAttribute() {
221
+ return `data-${this.identifier}-${INVALID_ATTRIBUTE}`;
222
+ }
121
223
  static targets = [
122
224
  "zone",
123
225
  "trigger",
@@ -147,14 +249,22 @@ var FileDropzoneController = class extends Controller {
147
249
  #entries = [];
148
250
  /** Whether a drag is currently over the zone; the source for `data-dragover`. */
149
251
  #dragging = false;
150
- /** Whether this connection already reported its unusable item template. */
151
- #warnedTemplate = false;
252
+ /** Builds one preview item from the authored template and owns its diagnostic. */
253
+ #rows = new TemplateRow({
254
+ identifier: this.identifier,
255
+ root: "item",
256
+ required: ["name"],
257
+ optional: ["thumb"],
258
+ button: "remove",
259
+ outcome: "added no file",
260
+ noun: "item template"
261
+ });
152
262
  #gate = new DetachGate();
153
263
  #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
154
264
  /** Subscribes to the Turbo cache rewind and re-arms the template diagnostic. */
155
265
  connect() {
156
266
  this.#gate.cancel();
157
- this.#warnedTemplate = false;
267
+ this.#rows.connect();
158
268
  this.#beforeCache.activate();
159
269
  }
160
270
  /**
@@ -246,15 +356,20 @@ var FileDropzoneController = class extends Controller {
246
356
  */
247
357
  #onItemClick = (event) => {
248
358
  const button = event.target.closest(
249
- 'button[data-stimeo--file-dropzone-target~="remove"]'
359
+ `button${this.#rows.selector("remove")}`
250
360
  );
251
361
  const index = this.#entries.findIndex((entry) => entry.item.contains(button));
252
362
  if (index !== -1) this.#removeAt(index);
253
363
  };
254
- /** Validates each incoming file, renders the accepted ones, and reports the batch. */
364
+ /**
365
+ * Validates each incoming file, renders the accepted ones, and reports the batch.
366
+ *
367
+ * @stimeoRuntimeOnly The limits decide which files of this one drop are taken, and the texts word
368
+ * the announcements it makes.
369
+ */
255
370
  #addFiles(files) {
256
371
  this.#rehome();
257
- if (this.hasZoneTarget) this.zoneTarget.removeAttribute(INVALID_ATTRIBUTE);
372
+ if (this.hasZoneTarget) this.zoneTarget.removeAttribute(this.#invalidAttribute);
258
373
  const rejected = /* @__PURE__ */ new Map();
259
374
  const turnedAway = [];
260
375
  let addedName = "";
@@ -262,7 +377,7 @@ var FileDropzoneController = class extends Controller {
262
377
  for (const file of Array.from(files)) {
263
378
  const reason = this.#validate(file);
264
379
  if (reason !== null) {
265
- if (this.hasZoneTarget) this.zoneTarget.setAttribute(INVALID_ATTRIBUTE, "");
380
+ if (this.hasZoneTarget) this.zoneTarget.setAttribute(this.#invalidAttribute, "");
266
381
  const batch = rejected.get(reason);
267
382
  if (batch) batch.count += 1;
268
383
  else rejected.set(reason, { name: file.name, count: 1 });
@@ -310,26 +425,25 @@ var FileDropzoneController = class extends Controller {
310
425
  /**
311
426
  * Builds one preview item (name, optional thumbnail, remove button) and reports
312
427
  * whether it was rendered.
428
+ *
429
+ * A refused row leaves the addition a no-op — nothing about the selection, the
430
+ * native input, the announcements, or the events changes — and the row reports
431
+ * why on the console once per connection.
313
432
  */
314
433
  #appendFile(file) {
315
- if (!this.hasListTarget) return this.#warnTemplate('a "list" target to render into');
316
- if (!this.hasItemTemplateTarget) return this.#warnTemplate('an "itemTemplate" target');
317
- const fragment = this.itemTemplateTarget.content.cloneNode(true);
318
- const item = this.#slot(fragment, "item");
319
- const name = this.#slot(fragment, "name");
320
- const thumb = this.#slot(fragment, "thumb");
321
- const button = fragment.querySelector(
322
- 'button[data-stimeo--file-dropzone-target~="remove"]'
323
- );
324
- const removeName = button?.getAttribute("aria-label")?.trim() ?? "";
325
- if (!item) return this.#warnTemplate('an "item" root');
326
- if (!name) return this.#warnTemplate('a "name" element');
327
- if (!button) return this.#warnTemplate('a "remove" target <button>');
328
- if (removeName === "") {
329
- return this.#warnTemplate('a non-empty aria-label on its "remove" target');
434
+ if (!this.hasListTarget) {
435
+ this.#rows.report('a "list" target to render into');
436
+ return false;
437
+ }
438
+ if (!this.hasItemTemplateTarget) {
439
+ this.#rows.report('an "itemTemplate" target');
440
+ return false;
330
441
  }
331
- name.textContent = file.name;
332
- button.setAttribute("aria-label", fillTemplate(removeName, { name: file.name }));
442
+ const row = this.#rows.instantiate(this.itemTemplateTarget, { name: file.name });
443
+ if (!row) return false;
444
+ const item = row.root;
445
+ writeLabel(row.slots.name, file.name);
446
+ const thumb = row.slots.thumb;
333
447
  let url;
334
448
  if (thumb && file.type.startsWith("image/")) {
335
449
  url = URL.createObjectURL(file);
@@ -339,32 +453,10 @@ var FileDropzoneController = class extends Controller {
339
453
  } else if (thumb) {
340
454
  thumb.hidden = true;
341
455
  }
342
- this.listTarget.appendChild(fragment);
456
+ this.listTarget.appendChild(item);
343
457
  this.#entries.push({ file, item, url });
344
458
  return true;
345
459
  }
346
- /** Resolves one declared part inside a cloned item template. */
347
- #slot(fragment, name) {
348
- return fragment.querySelector(`[data-stimeo--file-dropzone-target~="${name}"]`);
349
- }
350
- /**
351
- * Reports an unusable item template to the author, once per connection.
352
- *
353
- * The addition itself stays a no-op — nothing about the selection, the native
354
- * input, the announcements, or the events changes. Without this line the only
355
- * symptom is a picker that accepts no file at all, and the causes the Inspector
356
- * cannot see statically (a server-rendered template, a name that renders empty
357
- * from a missing translation) would have no diagnostic anywhere.
358
- */
359
- #warnTemplate(missing) {
360
- if (!this.#warnedTemplate) {
361
- this.#warnedTemplate = true;
362
- console.warn(
363
- `Stimeo UI: "${this.identifier}" added no file because its item template lacks ${missing}.`
364
- );
365
- }
366
- return false;
367
- }
368
460
  /** Removes entry `index`, revokes its preview, and re-homes focus. */
369
461
  #removeAt(index) {
370
462
  const entry = this.#entries[index];
@@ -473,7 +565,7 @@ var FileDropzoneController = class extends Controller {
473
565
  this.#entries.length = 0;
474
566
  this.#syncInput();
475
567
  this.#endDrag();
476
- if (this.hasZoneTarget) this.zoneTarget.removeAttribute(INVALID_ATTRIBUTE);
568
+ if (this.hasZoneTarget) this.zoneTarget.removeAttribute(this.#invalidAttribute);
477
569
  }
478
570
  /** Releases the selection once the disconnect is known to be a real detach. */
479
571
  #teardown() {
@@ -493,8 +585,9 @@ var FileDropzoneController = class extends Controller {
493
585
  get #removeButtons() {
494
586
  const buttons = [];
495
587
  for (const entry of this.#entries) {
496
- const button = entry.item.querySelector(
497
- 'button[data-stimeo--file-dropzone-target~="remove"]'
588
+ const button = matchingPart(
589
+ entry.item,
590
+ `button${this.#rows.selector("remove")}`
498
591
  );
499
592
  if (button) buttons.push(button);
500
593
  }
@@ -9,6 +9,12 @@ var FilterController = class extends Controller {
9
9
  #onChange = () => {
10
10
  this.apply();
11
11
  };
12
+ /**
13
+ * The outcome the last `change` reported, which the next evaluation is compared
14
+ * with; `null` until the first evaluation after `connect()`, which reports
15
+ * whatever it finds.
16
+ */
17
+ #reported = null;
12
18
  /**
13
19
  * Gates the declaration callback to the connected window.
14
20
  *
@@ -18,6 +24,7 @@ var FilterController = class extends Controller {
18
24
  */
19
25
  #connected = false;
20
26
  connect() {
27
+ this.#reported = null;
21
28
  this.#evaluate();
22
29
  this.element.addEventListener("change", this.#onChange);
23
30
  this.#connected = true;
@@ -44,7 +51,10 @@ var FilterController = class extends Controller {
44
51
  }
45
52
  /**
46
53
  * The evaluation itself: every item's visibility from the active tokens, then
47
- * the groups and the empty element, then the `change` event.
54
+ * the groups and the empty element, then `change` when the outcome moved from
55
+ * the one last reported. Several triggers can land on the same outcome — a
56
+ * `clear` evaluates, and a toggle group wired to `apply` reports the presses it
57
+ * turned off — so an evaluation that changes nothing reports nothing.
48
58
  *
49
59
  * @stimeoRenderRoot
50
60
  */
@@ -62,9 +72,13 @@ var FilterController = class extends Controller {
62
72
  for (const empty of this.emptyTargets) {
63
73
  empty.hidden = visibleCount > 0;
64
74
  }
65
- this.dispatch("change", {
66
- detail: { active, visible: visibleCount, total: this.itemTargets.length }
67
- });
75
+ const total = this.itemTargets.length;
76
+ const reported = this.#reported;
77
+ if (reported !== null && reported.visibleCount === visibleCount && reported.total === total && reported.active.length === active.length && reported.active.every((token, index) => token === active[index])) {
78
+ return;
79
+ }
80
+ this.#reported = { active, visibleCount, total };
81
+ this.dispatch("change", { detail: { active: [...active], visibleCount, total } });
68
82
  }
69
83
  /** Turns every control off (uncheck / aria-pressed="false") and re-applies. */
70
84
  clear() {
@@ -92,7 +106,7 @@ var FilterController = class extends Controller {
92
106
  }
93
107
  /** A control's token: explicit `data-stimeo--filter-token`, else `data-value` / value. */
94
108
  #tokenOf(control) {
95
- const explicit = control.getAttribute("data-stimeo--filter-token") ?? control.dataset.value;
109
+ const explicit = control.getAttribute(`data-${this.identifier}-token`) ?? control.dataset.value;
96
110
  if (explicit) return explicit;
97
111
  return control instanceof HTMLInputElement ? control.value : "";
98
112
  }
@@ -104,7 +118,7 @@ var FilterController = class extends Controller {
104
118
  }
105
119
  /** An item's declared tokens (`data-stimeo--filter-tokens`, space-separated). */
106
120
  #tokensOf(item) {
107
- return (item.getAttribute("data-stimeo--filter-tokens") ?? "").split(/\s+/).filter(Boolean);
121
+ return (item.getAttribute(`data-${this.identifier}-tokens`) ?? "").split(/\s+/).filter(Boolean);
108
122
  }
109
123
  /** Whether a group still contains at least one non-hidden item. */
110
124
  #hasVisibleItem(group) {