stimeo-ui 0.15.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 (102) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +178 -0
  3. data/README.md +120 -0
  4. data/dist/cable/index.js +63 -15
  5. data/dist/controllers/accordion_controller.js +98 -9
  6. data/dist/controllers/announcer_controller.js +8 -1
  7. data/dist/controllers/auto_submit_controller.js +43 -2
  8. data/dist/controllers/avatar_controller.js +1 -1
  9. data/dist/controllers/breadcrumb_controller.js +9 -8
  10. data/dist/controllers/bulk_select_controller.js +7 -6
  11. data/dist/controllers/calendar_controller.js +323 -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 +54 -10
  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 +56 -11
  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 +205 -58
  25. data/dist/controllers/data_grid_controller.js +195 -29
  26. data/dist/controllers/date_range_picker_controller.js +139 -27
  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 +14 -1
  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 +305 -46
  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 +16 -2
  41. data/dist/controllers/highlight_controller.js +19 -2
  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/masonry_controller.js +0 -2
  49. data/dist/controllers/menu_controller.js +45 -16
  50. data/dist/controllers/menubar_controller.js +58 -24
  51. data/dist/controllers/meter_controller.js +9 -5
  52. data/dist/controllers/multi_select_controller.js +278 -104
  53. data/dist/controllers/navigation_menu_controller.js +48 -15
  54. data/dist/controllers/nested_form_controller.js +37 -8
  55. data/dist/controllers/network_status_controller.js +9 -1
  56. data/dist/controllers/number_input_controller.js +124 -21
  57. data/dist/controllers/optimistic_controller.js +14 -1
  58. data/dist/controllers/otp_controller.js +198 -55
  59. data/dist/controllers/overflow_indicator_controller.js +84 -13
  60. data/dist/controllers/overflow_menu_controller.js +112 -41
  61. data/dist/controllers/pagination_controller.js +74 -28
  62. data/dist/controllers/password_reveal_controller.js +59 -2
  63. data/dist/controllers/persist_controller.js +30 -8
  64. data/dist/controllers/pointer_drag_controller.js +131 -52
  65. data/dist/controllers/popover_controller.js +45 -11
  66. data/dist/controllers/portal_controller.js +6 -2
  67. data/dist/controllers/preview_guard_controller.js +16 -1
  68. data/dist/controllers/progress_controller.js +8 -4
  69. data/dist/controllers/radio_group_controller.js +42 -17
  70. data/dist/controllers/range_slider_controller.js +88 -42
  71. data/dist/controllers/rating_controller.js +39 -15
  72. data/dist/controllers/read_more_controller.js +69 -2
  73. data/dist/controllers/resizable_controller.js +82 -22
  74. data/dist/controllers/scroll_area_controller.js +5 -1
  75. data/dist/controllers/scroll_visibility_controller.js +0 -1
  76. data/dist/controllers/scrollspy_controller.js +6 -0
  77. data/dist/controllers/separator_controller.js +66 -37
  78. data/dist/controllers/sidebar_controller.js +77 -18
  79. data/dist/controllers/skeleton_controller.js +6 -1
  80. data/dist/controllers/slider_controller.js +82 -47
  81. data/dist/controllers/smart_sticky_header_controller.js +11 -1
  82. data/dist/controllers/sortable_controller.js +17 -2
  83. data/dist/controllers/spinner_controller.js +10 -2
  84. data/dist/controllers/step_indicator_controller.js +18 -17
  85. data/dist/controllers/stepper_controller.js +101 -19
  86. data/dist/controllers/stick_to_bottom_controller.js +75 -5
  87. data/dist/controllers/submit_once_controller.js +16 -3
  88. data/dist/controllers/switch_controller.js +101 -10
  89. data/dist/controllers/tabs_controller.js +21 -2
  90. data/dist/controllers/tags_input_controller.js +209 -59
  91. data/dist/controllers/theme_controller.js +64 -14
  92. data/dist/controllers/time_picker_controller.js +23 -8
  93. data/dist/controllers/toast_controller.js +337 -54
  94. data/dist/controllers/toggle_group_controller.js +159 -23
  95. data/dist/controllers/toolbar_controller.js +32 -0
  96. data/dist/controllers/tooltip_controller.js +39 -13
  97. data/dist/controllers/transition_controller.js +4 -0
  98. data/dist/controllers/tree_view_controller.js +169 -16
  99. data/dist/index.js +4588 -1535
  100. data/dist/positioning/index.js +2 -0
  101. data/lib/stimeo/ui/version.rb +1 -1
  102. metadata +2 -2
@@ -8,36 +8,125 @@ function isReservedArrowChord(event, allow = []) {
8
8
  return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
9
9
  }
10
10
 
11
+ // src/utils/state_reason.ts
12
+ var FOCUS_EVENTS = /* @__PURE__ */ new Set(["blur", "focus", "focusin", "focusout"]);
13
+ var POINTER_EVENTS = /* @__PURE__ */ new Set(["mouseenter", "mouseleave", "pointerenter", "pointerleave"]);
14
+ function stateReasonFor(event) {
15
+ if (!event) return "api";
16
+ if (FOCUS_EVENTS.has(event.type)) return "focus";
17
+ if (POINTER_EVENTS.has(event.type)) return "pointer";
18
+ return "user";
19
+ }
20
+
21
+ // src/utils/state_regions.ts
22
+ var StateRegions = class {
23
+ #whenTrue;
24
+ #whenFalse;
25
+ #taken = /* @__PURE__ */ new WeakSet();
26
+ constructor(options) {
27
+ this.#whenTrue = options.whenTrue;
28
+ this.#whenFalse = options.whenFalse ?? null;
29
+ }
30
+ /** Shows the regions inside `host` that belong to `isTrue` and hides the others. */
31
+ reflect(host, isTrue) {
32
+ const shown = this.#inside(host, this.#whenTrue());
33
+ if (!this.#whenFalse) {
34
+ for (const region of shown) this.#write(region, !isTrue);
35
+ return;
36
+ }
37
+ const hiddenSide = this.#inside(host, this.#whenFalse());
38
+ if (shown.length === 0 || hiddenSide.length === 0) {
39
+ for (const region of shown) this.#give(region);
40
+ for (const region of hiddenSide) this.#give(region);
41
+ return;
42
+ }
43
+ for (const region of shown) this.#write(region, !isTrue);
44
+ for (const region of hiddenSide) this.#write(region, isTrue);
45
+ }
46
+ /** Writes `hidden` where it moves, noting which regions it takes out of view. */
47
+ #write(region, hidden) {
48
+ if (hidden) this.#taken.add(region);
49
+ else this.#taken.delete(region);
50
+ if (region.hidden !== hidden) region.hidden = hidden;
51
+ }
52
+ /** Returns a region this instance hid; one it never hid keeps what it carries. */
53
+ #give(region) {
54
+ if (this.#taken.delete(region)) region.hidden = false;
55
+ }
56
+ /** The declared regions that sit within `host`, which may be the host itself. */
57
+ #inside(host, regions) {
58
+ return regions.filter((region) => host.contains(region));
59
+ }
60
+ };
61
+
11
62
  // src/controllers/accordion_controller.ts
12
63
  var AccordionController = class extends Controller {
13
- static targets = ["trigger", "panel"];
64
+ static targets = ["trigger", "panel", "expandedLabel", "collapsedLabel"];
14
65
  static actions = ["collapseAll", "expandAll", "onKeydown", "toggle"];
66
+ static events = ["close", "open"];
67
+ /** Owns `hidden` on the label pair a header declares, narrowed to that header. */
68
+ #labels = new StateRegions({
69
+ whenTrue: () => this.expandedLabelTargets,
70
+ whenFalse: () => this.collapsedLabelTargets
71
+ });
72
+ /** Settles the label pair of a header as it joins the group. */
73
+ triggerTargetConnected(trigger) {
74
+ this.#reflectLabels(trigger);
75
+ }
76
+ /** Settles the pair of the header an arriving expanded half belongs to. */
77
+ expandedLabelTargetConnected(label) {
78
+ this.#settleLabelHost(label);
79
+ }
80
+ /** Settles the pair of the header an arriving collapsed half belongs to. */
81
+ collapsedLabelTargetConnected(label) {
82
+ this.#settleLabelHost(label);
83
+ }
84
+ /** Reflects the pair of the header a label half sits in, where a header holds it. */
85
+ #settleLabelHost(label) {
86
+ const trigger = this.triggerTargets.find((candidate) => candidate.contains(label));
87
+ if (trigger) this.#reflectLabels(trigger);
88
+ }
15
89
  /** Toggles the panel controlled by the activated header. */
16
90
  toggle(event) {
17
91
  const trigger = event.currentTarget;
18
92
  const panel = this.#panelFor(trigger);
19
93
  if (!panel) return;
20
- this.#setExpanded(trigger, panel, trigger.getAttribute("aria-expanded") !== "true");
94
+ this.#setExpanded(
95
+ trigger,
96
+ panel,
97
+ trigger.getAttribute("aria-expanded") !== "true",
98
+ stateReasonFor(event)
99
+ );
21
100
  }
22
101
  /** Opens every panel. Bound via `data-action` on an "expand all" control. */
23
- expandAll() {
24
- this.#setAll(true);
102
+ expandAll(event) {
103
+ this.#setAll(true, stateReasonFor(event));
25
104
  }
26
105
  /** Closes every panel. Bound via `data-action` on a "collapse all" control. */
27
- collapseAll() {
28
- this.#setAll(false);
106
+ collapseAll(event) {
107
+ this.#setAll(false, stateReasonFor(event));
29
108
  }
30
109
  /** Drives every header/panel pair to the same expanded state. */
31
- #setAll(open) {
110
+ #setAll(open, reason) {
32
111
  for (const trigger of this.triggerTargets) {
33
112
  const panel = this.#panelFor(trigger);
34
- if (panel) this.#setExpanded(trigger, panel, open);
113
+ if (panel) this.#setExpanded(trigger, panel, open, reason);
35
114
  }
36
115
  }
37
116
  /** Reflects one header/panel pair's state through `aria-expanded` + `hidden`. */
38
- #setExpanded(trigger, panel, open) {
117
+ #setExpanded(trigger, panel, open, reason) {
118
+ const was = trigger.getAttribute("aria-expanded") === "true";
39
119
  trigger.setAttribute("aria-expanded", open ? "true" : "false");
40
120
  panel.hidden = !open;
121
+ this.#labels.reflect(trigger, open);
122
+ if (was === open) return;
123
+ const detail = { reason, index: this.triggerTargets.indexOf(trigger), trigger, panel };
124
+ if (open) this.dispatch("open", { detail, cancelable: false });
125
+ else this.dispatch("close", { detail, cancelable: false });
126
+ }
127
+ /** Shows the half of a header's label pair that belongs to its `aria-expanded`. */
128
+ #reflectLabels(trigger) {
129
+ this.#labels.reflect(trigger, trigger.getAttribute("aria-expanded") === "true");
41
130
  }
42
131
  /**
43
132
  * Moves focus between headers per the APG keyboard model, skipping any header
@@ -346,6 +346,9 @@ var AnnouncerController = class extends Controller {
346
346
  * region (it has to be in the accessibility tree before the text arrives) and
347
347
  * emptying a region that already holds this exact text (an unchanged node is not
348
348
  * re-read, so `dedupeReannounce` clears first and writes on the following pass).
349
+ *
350
+ * @stimeoRuntimeOnly `dedupeReannounce` decides whether one queued message is spoken again; the
351
+ * region's text is the message itself.
349
352
  */
350
353
  #drain(level) {
351
354
  const queue = this.#queues.get(level);
@@ -368,7 +371,11 @@ var AnnouncerController = class extends Controller {
368
371
  this.#scheduleClear(region, message);
369
372
  if (queue.length > 0) this.#scheduleDrain(level);
370
373
  }
371
- /** Clears the region after `clearAfter` ms, unless a newer message replaced it. */
374
+ /**
375
+ * Clears the region after `clearAfter` ms, unless a newer message replaced it.
376
+ *
377
+ * @stimeoRuntimeOnly `clearAfter` is the delay of the one clearing timer this call arms.
378
+ */
372
379
  #scheduleClear(region, message) {
373
380
  if (this.clearAfterValue <= 0) return;
374
381
  this.#regionTimers.set(
@@ -43,11 +43,19 @@ var BeforeCacheReset = class _BeforeCacheReset {
43
43
  };
44
44
 
45
45
  // src/utils/composition_tracker.ts
46
+ var COMPOSITION_INPUT_TYPES = /* @__PURE__ */ new Set([
47
+ "insertCompositionText",
48
+ "insertFromComposition",
49
+ "deleteCompositionText",
50
+ "deleteByComposition"
51
+ ]);
46
52
  var CompositionTracker = class {
47
53
  #observedTargets = /* @__PURE__ */ new Set();
48
54
  #activeTargets = /* @__PURE__ */ new Set();
49
55
  #onStart;
50
56
  #onEnd;
57
+ /** The field whose confirming `input` is still owed, while the window is open. */
58
+ #confirmedTarget = null;
51
59
  constructor(options = {}) {
52
60
  this.#onStart = options.onStart;
53
61
  this.#onEnd = options.onEnd;
@@ -57,6 +65,7 @@ var CompositionTracker = class {
57
65
  if (this.#observedTargets.has(target)) return;
58
66
  target.addEventListener("compositionstart", this.#handleStart);
59
67
  target.addEventListener("compositionend", this.#handleEnd);
68
+ target.addEventListener("keydown", this.#handleKeydown);
60
69
  this.#observedTargets.add(target);
61
70
  }
62
71
  /** Stops tracking one target and clears any active composition it owned. */
@@ -64,29 +73,52 @@ var CompositionTracker = class {
64
73
  if (!this.#observedTargets.delete(target)) return;
65
74
  target.removeEventListener("compositionstart", this.#handleStart);
66
75
  target.removeEventListener("compositionend", this.#handleEnd);
76
+ target.removeEventListener("keydown", this.#handleKeydown);
67
77
  this.#activeTargets.delete(target);
78
+ if (this.#confirmedTarget === target) this.#confirmedTarget = null;
68
79
  }
69
80
  /** Releases every listener and clears state so reconnect starts cleanly. */
70
81
  disconnect() {
71
82
  for (const target of this.#observedTargets) {
72
83
  target.removeEventListener("compositionstart", this.#handleStart);
73
84
  target.removeEventListener("compositionend", this.#handleEnd);
85
+ target.removeEventListener("keydown", this.#handleKeydown);
74
86
  }
75
87
  this.#observedTargets.clear();
76
88
  this.#activeTargets.clear();
89
+ this.#confirmedTarget = null;
77
90
  }
78
91
  /** True when lifecycle tracking or the current event reports composition. */
79
92
  isComposing(event) {
80
93
  return this.#activeTargets.size > 0 || event?.isComposing === true;
81
94
  }
95
+ /**
96
+ * Whether `event` is the `input` echoing the composition just confirmed.
97
+ *
98
+ * Asking closes the window either way, so one confirmation is folded at most
99
+ * once and a consumer asks once per `input`.
100
+ */
101
+ consumesConfirmedInput(event) {
102
+ const confirmed = this.#confirmedTarget;
103
+ this.#confirmedTarget = null;
104
+ if (confirmed === null || confirmed !== event.target) return false;
105
+ const inputType = event.inputType;
106
+ return !inputType || COMPOSITION_INPUT_TYPES.has(inputType);
107
+ }
82
108
  #handleStart = (event) => {
109
+ this.#confirmedTarget = null;
83
110
  if (event.currentTarget) this.#activeTargets.add(event.currentTarget);
84
111
  this.#onStart?.(event);
85
112
  };
86
113
  #handleEnd = (event) => {
87
114
  if (event.currentTarget) this.#activeTargets.delete(event.currentTarget);
115
+ this.#confirmedTarget = event.target;
88
116
  this.#onEnd?.(event);
89
117
  };
118
+ /** A key on an observed field opens an edit of its own, so no echo is owed. */
119
+ #handleKeydown = () => {
120
+ this.#confirmedTarget = null;
121
+ };
90
122
  };
91
123
 
92
124
  // src/utils/listener_set.ts
@@ -187,7 +219,12 @@ var AutoSubmitController = class extends Controller {
187
219
  #boundForm = null;
188
220
  /** Rewinds the transient state hooks just before Turbo snapshots the page. */
189
221
  #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
190
- /** Clears `aria-busy` and emits completion once Turbo finishes the submit. */
222
+ /**
223
+ * Clears `aria-busy` and emits completion once Turbo finishes the submit.
224
+ *
225
+ * @stimeoRuntimeOnly `message` and `announce` shape the event and the announcement of one
226
+ * finished submit; the busy flag it clears does not depend on them.
227
+ */
191
228
  #onSubmitEnd = () => {
192
229
  this.#boundForm?.removeAttribute("aria-busy");
193
230
  const message = this.messageValue;
@@ -229,7 +266,11 @@ var AutoSubmitController = class extends Controller {
229
266
  if (event.type === "input" && this.#composition.isComposing(event)) return;
230
267
  this.#schedule(event.target ?? null);
231
268
  }
232
- /** Schedules (and coalesces) the debounced submit for the given trigger. */
269
+ /**
270
+ * Schedules (and coalesces) the debounced submit for the given trigger.
271
+ *
272
+ * @stimeoRuntimeOnly `debounce` is the delay of the one submit timer this call arms.
273
+ */
233
274
  #schedule(trigger) {
234
275
  const form = this.#boundForm;
235
276
  if (!form) return;
@@ -224,7 +224,7 @@ var AvatarController = class extends Controller {
224
224
  }
225
225
  /** Applies Value precedence and returns the effective raw `src` attribute. */
226
226
  #applySource(image) {
227
- if (!this.element.hasAttribute("data-stimeo--avatar-src-value")) {
227
+ if (!this.element.hasAttribute(`data-${this.identifier}-src-value`)) {
228
228
  this.#src.return(image);
229
229
  return image.getAttribute("src");
230
230
  }
@@ -187,21 +187,24 @@ var BreadcrumbController = class extends Controller {
187
187
  this.#resync();
188
188
  }
189
189
  /**
190
- * Re-measures when the disclosure set gains or loses a member. The set is a
191
- * precondition for collapsing at all, so it needs the same watching the list
192
- * and items get: without these callbacks a swap that breaks or completes the
193
- * set would only take effect at the next resize or list mutation, which may
194
- * never come.
190
+ * Re-measures when an `ellipsis` target arrives. The `ellipsis` and `trigger`
191
+ * targets form the disclosure set, a precondition for collapsing at all, so they
192
+ * need the same watching the list and items get: without their callbacks a swap
193
+ * that breaks or completes the set would only take effect at the next resize or
194
+ * list mutation, which may never come.
195
195
  */
196
196
  ellipsisTargetConnected() {
197
197
  this.#resync();
198
198
  }
199
+ /** Re-measures when an `ellipsis` target leaves the disclosure set. */
199
200
  ellipsisTargetDisconnected() {
200
201
  this.#resync();
201
202
  }
203
+ /** Re-measures when a `trigger` target joins the disclosure set. */
202
204
  triggerTargetConnected() {
203
205
  this.#resync();
204
206
  }
207
+ /** Re-measures when a `trigger` target leaves the disclosure set. */
205
208
  triggerTargetDisconnected() {
206
209
  this.#resync();
207
210
  }
@@ -210,7 +213,7 @@ var BreadcrumbController = class extends Controller {
210
213
  *
211
214
  * The marker is the source of truth for what may be collapsed, so an element
212
215
  * that loses it while the controller is live is an always-visible item again —
213
- * and `#render` only walks the *current* targets, so nothing else would ever
216
+ * and rendering only walks the *current* targets, so nothing else would ever
214
217
  * clear the `hidden` this controller put there.
215
218
  *
216
219
  * The `#connected` guard is load-bearing, not defensive. Stimulus fires this
@@ -315,8 +318,6 @@ var BreadcrumbController = class extends Controller {
315
318
  }
316
319
  /**
317
320
  * Applies the collapsed/expanded state to the items, ellipsis, and trigger.
318
- *
319
- * @stimeoRenderRoot
320
321
  */
321
322
  #render() {
322
323
  const collapsed = this.#overflowing && !this.#expanded;
@@ -108,12 +108,8 @@ var BulkSelectController = class extends Controller {
108
108
  allTargetDisconnected() {
109
109
  this.#reconcile.schedule();
110
110
  }
111
- /** Repaints the count for a total that changed at runtime, rejecting non-finite ones. */
111
+ /** Repaints the count for a total that changed at runtime. */
112
112
  totalCountValueChanged() {
113
- if (!Number.isFinite(this.totalCountValue)) {
114
- this.totalCountValue = 0;
115
- return;
116
- }
117
113
  this.#reconcile.schedule();
118
114
  }
119
115
  /** Repaints so wording changed at runtime is used by the next announcement. */
@@ -175,6 +171,11 @@ var BulkSelectController = class extends Controller {
175
171
  #announce(detail) {
176
172
  announce(fillTemplate(this.announceTextValue, { count: detail.count }));
177
173
  }
174
+ /** The declared `totalCount`, or the default `0` in place of a non-finite one. */
175
+ get #totalCount() {
176
+ const declared = this.totalCountValue;
177
+ return Number.isFinite(declared) ? declared : 0;
178
+ }
178
179
  /**
179
180
  * Recomputes the count, the select-all checked/indeterminate state, and the bar
180
181
  * visibility from the current DOM. Returns the figures when the emitted count or
@@ -191,7 +192,7 @@ var BulkSelectController = class extends Controller {
191
192
  this.allTarget.checked = total > 0 && checked === total;
192
193
  this.allTarget.indeterminate = checked > 0 && checked < total;
193
194
  }
194
- const count = allPages ? this.totalCountValue : checked;
195
+ const count = allPages ? this.#totalCount : checked;
195
196
  const show = allPages || checked > 0;
196
197
  if (this.hasBarTarget) {
197
198
  if (!show && this.hasAllTarget && this.barTarget.contains(document.activeElement)) {