stimeo-ui 0.5.0 → 0.7.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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +178 -0
  3. data/dist/controllers/alert_dialog_controller.js +75 -4
  4. data/dist/controllers/aspect_ratio_controller.js +19 -11
  5. data/dist/controllers/avatar_controller.js +237 -40
  6. data/dist/controllers/carousel_controller.js +85 -9
  7. data/dist/controllers/character_counter_controller.js +338 -63
  8. data/dist/controllers/checkbox_controller.js +136 -25
  9. data/dist/controllers/color_picker_controller.js +35 -9
  10. data/dist/controllers/command_palette_controller.js +75 -4
  11. data/dist/controllers/conditional_fields_controller.js +345 -51
  12. data/dist/controllers/confirm_controller.js +75 -4
  13. data/dist/controllers/date_range_picker_controller.js +157 -30
  14. data/dist/controllers/dialog_controller.js +75 -4
  15. data/dist/controllers/direct_upload_controller.js +201 -45
  16. data/dist/controllers/dirty_form_controller.js +192 -29
  17. data/dist/controllers/dismissible_controller.js +83 -18
  18. data/dist/controllers/drawer_controller.js +75 -4
  19. data/dist/controllers/file_dropzone_controller.js +26 -3
  20. data/dist/controllers/focus_controller.js +75 -4
  21. data/dist/controllers/form_field_controller.js +280 -62
  22. data/dist/controllers/form_validation_controller.js +208 -83
  23. data/dist/controllers/idle_controller.js +27 -5
  24. data/dist/controllers/menubar_controller.js +5 -3
  25. data/dist/controllers/multi_select_controller.js +460 -151
  26. data/dist/controllers/number_input_controller.js +317 -51
  27. data/dist/controllers/overflow_menu_controller.js +6 -1
  28. data/dist/controllers/pagination_controller.js +35 -1
  29. data/dist/controllers/password_strength_controller.js +20 -2
  30. data/dist/controllers/persist_controller.js +449 -120
  31. data/dist/controllers/popover_controller.js +77 -4
  32. data/dist/controllers/radio_group_controller.js +540 -56
  33. data/dist/controllers/rating_controller.js +276 -89
  34. data/dist/controllers/resizable_controller.js +33 -0
  35. data/dist/controllers/roving_controller.js +60 -5
  36. data/dist/controllers/scroll_area_controller.js +557 -125
  37. data/dist/controllers/scroll_visibility_controller.js +33 -0
  38. data/dist/controllers/separator_controller.js +354 -38
  39. data/dist/controllers/sidebar_controller.js +83 -10
  40. data/dist/controllers/submit_once_controller.js +399 -121
  41. data/dist/controllers/tags_input_controller.js +356 -120
  42. data/dist/controllers/theme_controller.js +8 -6
  43. data/dist/controllers/time_picker_controller.js +296 -107
  44. data/dist/controllers/toggle_group_controller.js +378 -55
  45. data/dist/controllers/toolbar_controller.js +5 -3
  46. data/dist/controllers/tree_view_controller.js +7 -4
  47. data/dist/index.js +4963 -1581
  48. data/lib/stimeo/ui/version.rb +1 -1
  49. metadata +2 -2
@@ -1,17 +1,89 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
 
3
+ // src/controllers/checkbox_controller.ts
4
+
5
+ // src/utils/microtask_coalescer.ts
6
+ var MicrotaskCoalescer = class {
7
+ #run;
8
+ #queued = false;
9
+ #active = false;
10
+ #generation = 0;
11
+ /** @param run - the single reconciliation pass, invoked at most once per batch. */
12
+ constructor(run) {
13
+ this.#run = run;
14
+ }
15
+ /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
16
+ activate() {
17
+ this.#active = true;
18
+ }
19
+ /** Closes the window and drops any pending pass; call from `disconnect()`. */
20
+ cancel() {
21
+ this.#active = false;
22
+ this.#queued = false;
23
+ this.#generation += 1;
24
+ }
25
+ /** Requests one pass after the batch settles. Idempotent; inert outside the window. */
26
+ schedule() {
27
+ if (!this.#active || this.#queued) return;
28
+ this.#queued = true;
29
+ const generation = this.#generation;
30
+ queueMicrotask(() => {
31
+ if (generation !== this.#generation || !this.#queued || !this.#active) return;
32
+ this.#queued = false;
33
+ this.#run();
34
+ });
35
+ }
36
+ };
37
+
3
38
  // src/controllers/checkbox_controller.ts
4
39
  var CheckboxController = class extends Controller {
5
40
  static targets = ["parent", "child"];
6
41
  static actions = ["onChildChange", "onParentChange"];
7
- static events = ["change"];
8
- /** Reflects the initial aggregate (e.g. from server-rendered child states). */
9
- connect() {
10
- if (this.childTargets.length > 0) {
11
- this.#syncFromChildren(false);
12
- } else {
13
- this.element.setAttribute("data-state", this.#aggregate());
42
+ static events = ["change", "reconcile"];
43
+ /** Collapses every lifecycle signal from one DOM update into one derived pass. */
44
+ #reconcile = new MicrotaskCoalescer(() => this.#reconcileFromChildren());
45
+ /** Aggregate this root last settled on, so a derived repair is reported once. */
46
+ #committedState = null;
47
+ /** Watches authored checked-attribute changes on retained target elements. */
48
+ #checkedObserver = new MutationObserver((records) => {
49
+ if (records.some((record) => this.#isManagedCheckbox(record.target))) {
50
+ this.#reconcile.schedule();
14
51
  }
52
+ });
53
+ /** Reflects the initial aggregate and starts retained-DOM reconciliation. */
54
+ connect() {
55
+ this.#reconcile.activate();
56
+ this.#syncFromChildren();
57
+ this.#checkedObserver.observe(this.element, {
58
+ attributes: true,
59
+ attributeFilter: ["checked"],
60
+ subtree: true
61
+ });
62
+ this.element.addEventListener("turbo:morph-element", this.#onMorph);
63
+ document.addEventListener("reset", this.#onReset, true);
64
+ }
65
+ /** Releases the observer, global reset listener, and every pending reconciliation. */
66
+ disconnect() {
67
+ this.#reconcile.cancel();
68
+ this.#checkedObserver.disconnect();
69
+ this.element.removeEventListener("turbo:morph-element", this.#onMorph);
70
+ document.removeEventListener("reset", this.#onReset, true);
71
+ }
72
+ /** Reconciles the aggregate for a parent added or replaced at runtime. */
73
+ parentTargetConnected() {
74
+ this.#reconcile.schedule();
75
+ }
76
+ /** Reconciles the aggregate after a parent target leaves the group. */
77
+ parentTargetDisconnected() {
78
+ this.#reconcile.schedule();
79
+ }
80
+ /** Reconciles the aggregate for a child added at runtime. */
81
+ childTargetConnected() {
82
+ this.#reconcile.schedule();
83
+ }
84
+ /** Reconciles the aggregate after a child leaves the group. */
85
+ childTargetDisconnected() {
86
+ this.#reconcile.schedule();
15
87
  }
16
88
  /** Cascades the parent's state to every child. Bound via `data-action` (change). */
17
89
  onParentChange() {
@@ -20,35 +92,74 @@ var CheckboxController = class extends Controller {
20
92
  for (const child of this.childTargets) {
21
93
  child.checked = checked;
22
94
  }
23
- this.parentTarget.indeterminate = false;
24
- const state = checked ? "all" : "none";
25
- this.element.setAttribute("data-state", state);
26
- this.dispatch("change", { detail: { checked, indeterminate: false, state } });
95
+ this.#reflect(checked ? "all" : "none", true);
96
+ const detail = this.#settledDetail();
97
+ if (detail) this.dispatch("change", { detail });
27
98
  }
28
99
  /** Recomputes the parent from its children. Bound via `data-action` (change). */
29
100
  onChildChange() {
30
- this.#syncFromChildren(true);
101
+ this.#syncFromChildren();
102
+ const detail = this.#settledDetail();
103
+ if (detail) this.dispatch("change", { detail });
104
+ }
105
+ /**
106
+ * Announces an aggregate this pass derived rather than the user. `change` stays
107
+ * reserved for the two public actions, so automation never reads a repair as an edit.
108
+ */
109
+ #reconcileFromChildren() {
110
+ const previous = this.#committedState;
111
+ this.#syncFromChildren();
112
+ if (this.#committedState === previous) return;
113
+ const detail = this.#settledDetail();
114
+ if (detail) this.dispatch("reconcile", { detail });
31
115
  }
32
116
  /**
33
117
  * Derives the parent's `checked`/`indeterminate` and the root `data-state` from
34
- * the children, optionally dispatching `change`.
118
+ * the children. Writing state and reporting it are separate so the caller — not
119
+ * a flag threaded through the write — decides which event describes the cause.
35
120
  */
36
- #syncFromChildren(dispatch) {
37
- const state = this.#aggregate();
38
- if (this.hasParentTarget) {
121
+ #syncFromChildren() {
122
+ this.#reflect(this.#aggregate(), this.childTargets.length > 0);
123
+ }
124
+ /**
125
+ * Reflects one aggregate state.
126
+ *
127
+ * @param writeParent - whether the state is authoritative over the `parent`
128
+ * target's own `checked` / `indeterminate`.
129
+ */
130
+ #reflect(state, writeParent) {
131
+ if (writeParent && this.hasParentTarget) {
39
132
  this.parentTarget.checked = state === "all";
40
133
  this.parentTarget.indeterminate = state === "partial";
41
134
  }
42
135
  this.element.setAttribute("data-state", state);
43
- if (dispatch) {
44
- this.dispatch("change", {
45
- detail: {
46
- checked: this.hasParentTarget ? this.parentTarget.checked : state === "all",
47
- indeterminate: this.hasParentTarget ? this.parentTarget.indeterminate : false,
48
- state
49
- }
50
- });
51
- }
136
+ this.#committedState = state;
137
+ }
138
+ /** The settled aggregate as event detail, or `null` before anything has settled. */
139
+ #settledDetail() {
140
+ const state = this.#committedState;
141
+ if (state === null) return null;
142
+ return { checked: state === "all", indeterminate: state === "partial", state };
143
+ }
144
+ /** Reconciles retained targets after Turbo has finished morphing their live state. */
145
+ #onMorph = () => {
146
+ this.#reconcile.schedule();
147
+ };
148
+ /** Reconciles after a non-cancelled reset restores any managed checkbox. */
149
+ #onReset = (event) => {
150
+ const form = event.target;
151
+ if (!(form instanceof HTMLFormElement) || !this.#hasCheckboxOwnedBy(form)) return;
152
+ queueMicrotask(() => {
153
+ if (!event.defaultPrevented) this.#reconcile.schedule();
154
+ });
155
+ };
156
+ /** Whether a form owns at least one current parent or child target. */
157
+ #hasCheckboxOwnedBy(form) {
158
+ return [...this.parentTargets, ...this.childTargets].some((checkbox) => checkbox.form === form);
159
+ }
160
+ /** Whether an observed attribute mutation belongs to this controller's target set. */
161
+ #isManagedCheckbox(node) {
162
+ return this.parentTargets.some((checkbox) => checkbox === node) || this.childTargets.some((checkbox) => checkbox === node);
52
163
  }
53
164
  /**
54
165
  * Computes the aggregate state. With children it counts them; with none it
@@ -74,7 +74,7 @@ var ColorPickerController = class extends Controller {
74
74
  logicalTrack: { type: Boolean, default: false }
75
75
  };
76
76
  static actions = ["onHexInput", "onKeydown", "onPointerDown"];
77
- static events = ["change"];
77
+ static events = ["change", "reconcile"];
78
78
  /** Whether the consumer declared a mirroring track and the direction mirrors it. */
79
79
  get #mirrored() {
80
80
  return this.logicalTrackValue && isRtl(this.element);
@@ -83,14 +83,14 @@ var ColorPickerController = class extends Controller {
83
83
  #color = { hue: 0, saturation: 0, lightness: 0, alpha: 100 };
84
84
  /** Aborts in-progress pointer-drag listeners on drag end / teardown. */
85
85
  #dragAbort = null;
86
- /** Seeds the model from the initial hex value and renders every surface. */
86
+ /** Color the last repaint settled on, so a configuration-driven move is reported once. */
87
+ #committedHex = null;
87
88
  /**
88
89
  * Collapses a morph that swaps render inputs into one repaint, and refuses the
89
90
  * pass Stimulus delivers before `connect()`.
90
91
  */
91
- #repaint = new MicrotaskCoalescer(() => {
92
- this.#render();
93
- });
92
+ #repaint = new MicrotaskCoalescer(() => this.#reconcileColor());
93
+ /** Seeds the model from the initial hex value and renders every surface. */
94
94
  connect() {
95
95
  this.#repaint.activate();
96
96
  const parsed = hexToHsla(this.valueValue);
@@ -181,12 +181,24 @@ var ColorPickerController = class extends Controller {
181
181
  return;
182
182
  }
183
183
  this.#color = this.alphaValue ? parsed : { ...parsed, alpha: 100 };
184
- this.#render();
184
+ this.#commitColor();
185
185
  }
186
186
  /** Clamps and snaps one channel to an integer, then re-renders + emits change. */
187
187
  #setChannel(channel, raw, min, max) {
188
188
  this.#color[channel] = Math.round(Math.min(max, Math.max(min, raw)));
189
+ this.#commitColor();
190
+ }
191
+ /**
192
+ * Renders the model and reports a color the user actually moved. A key pressed
193
+ * at a bound, a pointer that lands on the step already showing, and a re-confirmed
194
+ * hex all leave the committed color where it was, so no `change` describes them.
195
+ */
196
+ #commitColor() {
197
+ const previous = this.#committedHex;
189
198
  this.#render();
199
+ if (this.#committedHex !== previous) {
200
+ this.dispatch("change", { detail: this.#settledDetail() });
201
+ }
190
202
  }
191
203
  /**
192
204
  * Reflects the model onto sliders, the hex input, preview, and form field.
@@ -202,14 +214,28 @@ var ColorPickerController = class extends Controller {
202
214
  slider.setAttribute("aria-valuetext", valueText(channel, value));
203
215
  }
204
216
  const hex = this.#hexString();
217
+ this.#committedHex = hex;
205
218
  if (this.hasHexTarget) this.hexTarget.value = hex;
206
219
  for (const field of this.fieldTargets) field.value = hex;
207
220
  for (const preview of this.previewTargets) preview.style.setProperty(COLOR_PROPERTY, hex);
208
221
  this.element.style.setProperty(COLOR_PROPERTY, hex);
222
+ }
223
+ /**
224
+ * Repaints after `alpha` changed at runtime and reports a color this controller
225
+ * settled on. Disabling alpha drops it from the model, so the committed color can
226
+ * move without a user edit; `change` stays reserved for the picker's own actions.
227
+ */
228
+ #reconcileColor() {
229
+ const previous = this.#committedHex;
230
+ this.#render();
231
+ if (previous !== null && this.#committedHex !== previous) {
232
+ this.dispatch("reconcile", { detail: this.#settledDetail() });
233
+ }
234
+ }
235
+ /** The settled color as event detail, shared by both report paths. */
236
+ #settledDetail() {
209
237
  const rgb = hslToRgb(this.#color.hue, this.#color.saturation, this.#color.lightness);
210
- this.dispatch("change", {
211
- detail: { value: hex, rgba: { ...rgb, a: this.#color.alpha / 100 } }
212
- });
238
+ return { value: this.#hexString(), rgba: { ...rgb, a: this.#color.alpha / 100 } };
213
239
  }
214
240
  /** The current color as `#RRGGBB`, or `#RRGGBBAA` when alpha is enabled. */
215
241
  #hexString() {
@@ -180,8 +180,81 @@ var EscapeLayer = class _EscapeLayer {
180
180
  }
181
181
  };
182
182
 
183
+ // src/utils/focus_candidate.ts
184
+ function inheritsFieldsetDisabled(control) {
185
+ let fieldset = control.closest("fieldset[disabled]");
186
+ while (fieldset) {
187
+ const legend = Array.from(fieldset.children).find((child) => child.tagName === "LEGEND");
188
+ if (!legend?.contains(control)) return true;
189
+ fieldset = fieldset.parentElement?.closest("fieldset[disabled]") ?? null;
190
+ }
191
+ return false;
192
+ }
193
+ function canTakeFocus(element) {
194
+ if (element.closest("[hidden], [inert]")) return false;
195
+ if (element instanceof HTMLInputElement && element.type === "hidden") return false;
196
+ if (!("disabled" in element)) return true;
197
+ if (element.disabled) return false;
198
+ return !inheritsFieldsetDisabled(element);
199
+ }
200
+ var TAB_STOP_CANDIDATE_SELECTOR = [
201
+ "a[href]",
202
+ "area[href]",
203
+ "button",
204
+ "input",
205
+ "select",
206
+ "textarea",
207
+ "summary",
208
+ "iframe",
209
+ "audio[controls]",
210
+ "video[controls]",
211
+ "[tabindex]",
212
+ "[contenteditable]"
213
+ ].join(",");
214
+ function isRenderedForFocus(element) {
215
+ const check = element.checkVisibility;
216
+ return typeof check === "function" ? check.call(element, { visibilityProperty: true }) : true;
217
+ }
218
+ function authoredTabindex(element) {
219
+ const value = element.getAttribute("tabindex");
220
+ if (value === null || !/^[+-]?\d+$/.test(value.trim())) return null;
221
+ return Number(value);
222
+ }
223
+ function hasNativeTabStop(element) {
224
+ if (element instanceof HTMLAnchorElement || element instanceof HTMLAreaElement) {
225
+ return element.hasAttribute("href");
226
+ }
227
+ if (element instanceof HTMLButtonElement || element instanceof HTMLSelectElement || element instanceof HTMLTextAreaElement) {
228
+ return true;
229
+ }
230
+ if (element instanceof HTMLInputElement) return element.type !== "hidden";
231
+ if (element instanceof HTMLIFrameElement) return true;
232
+ if (element.tagName === "AUDIO" || element.tagName === "VIDEO") {
233
+ return element.hasAttribute("controls");
234
+ }
235
+ if (element instanceof HTMLElement && element.tagName === "SUMMARY") {
236
+ const details = element.parentElement;
237
+ return details instanceof HTMLDetailsElement && Array.from(details.children).find((child) => child.tagName === "SUMMARY") === element;
238
+ }
239
+ return false;
240
+ }
241
+ function hasEditableTabStop(element) {
242
+ const value = element.getAttribute("contenteditable")?.toLowerCase();
243
+ return value === "" || value === "true" || value === "plaintext-only";
244
+ }
245
+ function isTabStop(element) {
246
+ if (!canTakeFocus(element) || !isRenderedForFocus(element)) return false;
247
+ const tabindex = authoredTabindex(element);
248
+ if (tabindex !== null) return tabindex >= 0;
249
+ return hasNativeTabStop(element) || hasEditableTabStop(element);
250
+ }
251
+ function tabStopsWithin(root) {
252
+ return Array.from(root.querySelectorAll(TAB_STOP_CANDIDATE_SELECTOR)).filter(
253
+ isTabStop
254
+ );
255
+ }
256
+
183
257
  // src/utils/focus_trap.ts
184
- var FOCUSABLE = 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
185
258
  var FocusTrap = class {
186
259
  /** The element focused before activation, restored on deactivation. */
187
260
  #previouslyFocused = null;
@@ -343,9 +416,7 @@ var FocusTrap = class {
343
416
  }
344
417
  /** Collects the container's currently focusable descendants in DOM order. */
345
418
  #focusableElements() {
346
- return Array.from(this.#getContainer().querySelectorAll(FOCUSABLE)).filter(
347
- (el) => !el.hidden
348
- );
419
+ return tabStopsWithin(this.#getContainer());
349
420
  }
350
421
  };
351
422