stimeo-ui 0.16.0 → 0.17.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 (115) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +31 -0
  3. data/README.md +8 -1
  4. data/dist/cable/index.js +363 -436
  5. data/dist/controllers/accordion_controller.js +166 -55
  6. data/dist/controllers/alert_dialog_controller.js +862 -221
  7. data/dist/controllers/announcer_controller.js +135 -198
  8. data/dist/controllers/aspect_ratio_controller.js +0 -10
  9. data/dist/controllers/auto_submit_controller.js +271 -127
  10. data/dist/controllers/avatar_controller.js +171 -105
  11. data/dist/controllers/breadcrumb_controller.js +0 -130
  12. data/dist/controllers/bulk_select_controller.js +262 -52
  13. data/dist/controllers/calendar_controller.js +175 -209
  14. data/dist/controllers/carousel_controller.js +461 -399
  15. data/dist/controllers/character_counter_controller.js +246 -156
  16. data/dist/controllers/checkbox_controller.js +32 -74
  17. data/dist/controllers/clipboard_controller.js +168 -147
  18. data/dist/controllers/collapsible_controller.js +301 -120
  19. data/dist/controllers/color_picker_controller.js +149 -139
  20. data/dist/controllers/combobox_controller.js +360 -133
  21. data/dist/controllers/command_palette_controller.js +970 -358
  22. data/dist/controllers/conditional_fields_controller.js +166 -124
  23. data/dist/controllers/confirm_controller.js +939 -248
  24. data/dist/controllers/context_menu_controller.js +256 -128
  25. data/dist/controllers/count_up_controller.js +73 -21
  26. data/dist/controllers/countdown_controller.js +183 -148
  27. data/dist/controllers/currency_input_controller.js +134 -158
  28. data/dist/controllers/data_grid_controller.js +114 -186
  29. data/dist/controllers/date_range_picker_controller.js +288 -206
  30. data/dist/controllers/dialog_controller.js +858 -223
  31. data/dist/controllers/direct_upload_controller.js +73 -153
  32. data/dist/controllers/dirty_form_controller.js +14 -48
  33. data/dist/controllers/dismissible_controller.js +175 -21
  34. data/dist/controllers/drawer_controller.js +933 -341
  35. data/dist/controllers/dropdown_controller.js +312 -82
  36. data/dist/controllers/editable_controller.js +158 -68
  37. data/dist/controllers/empty_state_controller.js +67 -53
  38. data/dist/controllers/file_dropzone_controller.js +311 -207
  39. data/dist/controllers/filter_controller.js +67 -39
  40. data/dist/controllers/flash_controller.js +172 -314
  41. data/dist/controllers/focus_controller.js +654 -240
  42. data/dist/controllers/form_field_controller.js +147 -132
  43. data/dist/controllers/form_validation_controller.js +13 -86
  44. data/dist/controllers/frame_loading_controller.js +264 -213
  45. data/dist/controllers/highlight_controller.js +107 -75
  46. data/dist/controllers/hover_card_controller.js +309 -118
  47. data/dist/controllers/idle_controller.js +267 -101
  48. data/dist/controllers/input_mask_controller.js +71 -72
  49. data/dist/controllers/intersection_controller.js +147 -106
  50. data/dist/controllers/lazy_frame_controller.js +9 -65
  51. data/dist/controllers/listbox_controller.js +306 -204
  52. data/dist/controllers/local_time_controller.js +54 -64
  53. data/dist/controllers/masonry_controller.js +70 -93
  54. data/dist/controllers/menu_controller.js +214 -156
  55. data/dist/controllers/menubar_controller.js +170 -329
  56. data/dist/controllers/meter_controller.js +151 -67
  57. data/dist/controllers/multi_select_controller.js +593 -358
  58. data/dist/controllers/navigation_menu_controller.js +124 -232
  59. data/dist/controllers/nested_form_controller.js +352 -182
  60. data/dist/controllers/network_status_controller.js +242 -54
  61. data/dist/controllers/number_input_controller.js +320 -263
  62. data/dist/controllers/optimistic_controller.js +16 -76
  63. data/dist/controllers/otp_controller.js +198 -193
  64. data/dist/controllers/overflow_indicator_controller.js +116 -113
  65. data/dist/controllers/overflow_menu_controller.js +343 -283
  66. data/dist/controllers/pagination_controller.js +295 -119
  67. data/dist/controllers/password_reveal_controller.js +389 -121
  68. data/dist/controllers/password_strength_controller.js +99 -153
  69. data/dist/controllers/persist_controller.js +237 -103
  70. data/dist/controllers/pointer_drag_controller.js +185 -165
  71. data/dist/controllers/popover_controller.js +236 -97
  72. data/dist/controllers/portal_controller.js +209 -76
  73. data/dist/controllers/preview_guard_controller.js +232 -116
  74. data/dist/controllers/progress_controller.js +125 -56
  75. data/dist/controllers/radio_group_controller.js +117 -137
  76. data/dist/controllers/range_slider_controller.js +192 -105
  77. data/dist/controllers/rating_controller.js +418 -207
  78. data/dist/controllers/read_more_controller.js +134 -63
  79. data/dist/controllers/reading_progress_controller.js +147 -135
  80. data/dist/controllers/relative_time_controller.js +145 -110
  81. data/dist/controllers/{reset_before_cache_controller.js → reset_on_restore_controller.js} +97 -38
  82. data/dist/controllers/resizable_controller.js +255 -139
  83. data/dist/controllers/roving_controller.js +39 -84
  84. data/dist/controllers/scroll_area_controller.js +428 -225
  85. data/dist/controllers/scroll_restore_controller.js +0 -53
  86. data/dist/controllers/scroll_visibility_controller.js +395 -145
  87. data/dist/controllers/scrollspy_controller.js +181 -242
  88. data/dist/controllers/separator_controller.js +267 -140
  89. data/dist/controllers/sidebar_controller.js +1023 -386
  90. data/dist/controllers/skeleton_controller.js +248 -97
  91. data/dist/controllers/slider_controller.js +159 -79
  92. data/dist/controllers/smart_sticky_header_controller.js +162 -63
  93. data/dist/controllers/sortable_controller.js +0 -112
  94. data/dist/controllers/spinner_controller.js +277 -169
  95. data/dist/controllers/step_indicator_controller.js +99 -46
  96. data/dist/controllers/stepper_controller.js +114 -73
  97. data/dist/controllers/stick_to_bottom_controller.js +262 -138
  98. data/dist/controllers/sticky_observer_controller.js +214 -49
  99. data/dist/controllers/submit_once_controller.js +294 -205
  100. data/dist/controllers/switch_controller.js +33 -60
  101. data/dist/controllers/tabs_controller.js +35 -29
  102. data/dist/controllers/tags_input_controller.js +136 -145
  103. data/dist/controllers/textarea_autosize_controller.js +71 -58
  104. data/dist/controllers/theme_controller.js +130 -112
  105. data/dist/controllers/time_picker_controller.js +146 -80
  106. data/dist/controllers/toast_controller.js +186 -297
  107. data/dist/controllers/toggle_group_controller.js +95 -136
  108. data/dist/controllers/toolbar_controller.js +68 -98
  109. data/dist/controllers/tooltip_controller.js +158 -124
  110. data/dist/controllers/transition_controller.js +123 -119
  111. data/dist/controllers/tree_view_controller.js +150 -345
  112. data/dist/index.js +6370 -9346
  113. data/dist/positioning/index.js +148 -47
  114. data/lib/stimeo/ui/version.rb +1 -1
  115. metadata +3 -3
@@ -1,26 +1,17 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
 
3
- // src/controllers/checkbox_controller.ts
4
-
5
- // src/utils/form_reset_watcher.ts
6
3
  var FormResetWatcher = class {
7
4
  #owns;
8
5
  #onReset;
9
6
  #frames = /* @__PURE__ */ new Set();
10
7
  #generation = 0;
11
- /**
12
- * @param owns - whether `form` owns a control this controller derives from.
13
- * @param onReset - the reconciliation pass, run once per honoured reset.
14
- */
15
8
  constructor(owns, onReset) {
16
9
  this.#owns = owns;
17
10
  this.#onReset = onReset;
18
11
  }
19
- /** Subscribes to `reset` at the document; call from `connect()`. Idempotent. */
20
12
  observe() {
21
13
  document.addEventListener("reset", this.#handleReset, true);
22
14
  }
23
- /** Unsubscribes and drops every pending pass; call from `disconnect()`. */
24
15
  disconnect() {
25
16
  document.removeEventListener("reset", this.#handleReset, true);
26
17
  this.#generation += 1;
@@ -44,57 +35,38 @@ var FormResetWatcher = class {
44
35
  this.#frames.add(frame);
45
36
  });
46
37
  };
47
- /** Runs the pass unless a listener cancelled the reset before it landed. */
48
38
  #settle(event) {
49
39
  if (!event.defaultPrevented) this.#onReset();
50
40
  }
51
41
  };
52
42
 
53
- // src/utils/listener_set.ts
54
43
  var ListenerSet = class {
55
- /** The generation every `add` joins until the next `dispose()`. */
56
44
  #abort = new AbortController();
57
- /**
58
- * Attaches `handler` to the open generation, exactly as the caller spelled it.
59
- *
60
- * The set supplies the signal, so `options` carries everything else the DOM
61
- * accepts — `capture` included, which has to match at release time and no
62
- * longer has a second place to drift from.
63
- */
64
45
  add(target, type, handler, options) {
65
46
  target.addEventListener(type, handler, { ...options, signal: this.#abort.signal });
66
47
  }
67
- /**
68
- * Releases every listener of the open generation, synchronously, and opens the
69
- * next one. Idempotent, and safe before anything has been added.
70
- */
71
48
  dispose() {
72
49
  this.#abort.abort();
73
50
  this.#abort = new AbortController();
74
51
  }
75
52
  };
76
53
 
77
- // src/utils/microtask_coalescer.ts
78
54
  var MicrotaskCoalescer = class {
79
55
  #run;
80
56
  #queued = false;
81
57
  #active = false;
82
58
  #generation = 0;
83
- /** @param run - the single reconciliation pass, invoked at most once per batch. */
84
59
  constructor(run) {
85
60
  this.#run = run;
86
61
  }
87
- /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
88
62
  activate() {
89
63
  this.#active = true;
90
64
  }
91
- /** Closes the window and drops any pending pass; call from `disconnect()`. */
92
65
  cancel() {
93
66
  this.#active = false;
94
67
  this.#queued = false;
95
68
  this.#generation += 1;
96
69
  }
97
- /** Requests one pass after the batch settles. Idempotent; inert outside the window. */
98
70
  schedule() {
99
71
  if (!this.#active || this.#queued) return;
100
72
  this.#queued = true;
@@ -107,82 +79,92 @@ var MicrotaskCoalescer = class {
107
79
  }
108
80
  };
109
81
 
110
- // src/controllers/checkbox_controller.ts
82
+ var MorphRenderWatcher = class {
83
+ #listeners = new ListenerSet();
84
+ #pass;
85
+ #element = null;
86
+ #onMorph = () => this.#pass.schedule();
87
+ constructor(run) {
88
+ this.#pass = new MicrotaskCoalescer(run);
89
+ }
90
+ observe(element) {
91
+ if (this.#element === element) return;
92
+ this.disconnect();
93
+ this.#element = element;
94
+ this.#pass.activate();
95
+ this.#listeners.add(element, "turbo:morph-element", this.#onMorph);
96
+ }
97
+ schedule() {
98
+ this.#pass.schedule();
99
+ }
100
+ disconnect() {
101
+ this.#listeners.dispose();
102
+ this.#pass.cancel();
103
+ this.#element = null;
104
+ }
105
+ };
106
+
111
107
  var CheckboxController = class extends Controller {
112
108
  static targets = ["parent", "child"];
113
109
  static actions = ["onChildChange", "onParentChange"];
114
110
  static events = ["change", "reconcile"];
115
- /** Collapses every lifecycle signal from one DOM update into one derived pass. */
116
- #reconcile = new MicrotaskCoalescer(() => this.#reconcileFromChildren());
117
- #listeners = new ListenerSet();
111
+ #reconcile = new MorphRenderWatcher(() => this.#reconcileFromChildren());
118
112
  #formReset = new FormResetWatcher(
119
113
  (form) => this.#hasCheckboxOwnedBy(form),
120
114
  () => this.#reconcile.schedule()
121
115
  );
122
- /** Aggregate this root last settled on, so a derived repair is reported once. */
123
116
  #committedState = null;
124
- /** Watches authored checked-attribute changes on retained target elements. */
125
117
  #checkedObserver = new MutationObserver((records) => {
126
118
  if (records.some((record) => this.#isManagedCheckbox(record.target))) {
127
119
  this.#reconcile.schedule();
128
120
  }
129
121
  });
130
- /** Reflects the initial aggregate and starts retained-DOM reconciliation. */
131
122
  connect() {
132
- this.#reconcile.activate();
133
123
  this.#syncFromChildren();
124
+ this.#reconcile.observe(this.element);
134
125
  this.#checkedObserver.observe(this.element, {
135
126
  attributes: true,
136
127
  attributeFilter: ["checked"],
137
128
  subtree: true
138
129
  });
139
- this.#listeners.add(this.element, "turbo:morph-element", this.#onMorph);
140
130
  this.#formReset.observe();
141
131
  }
142
- /** Releases the observer, global reset listener, and every pending reconciliation. */
143
132
  disconnect() {
144
- this.#reconcile.cancel();
133
+ this.#reconcile.disconnect();
145
134
  this.#checkedObserver.disconnect();
146
- this.#listeners.dispose();
147
135
  this.#formReset.disconnect();
148
136
  }
149
- /** Reconciles the aggregate for a parent added or replaced at runtime. */
150
137
  parentTargetConnected() {
151
138
  this.#reconcile.schedule();
152
139
  }
153
- /** Reconciles the aggregate after a parent target leaves the group. */
154
140
  parentTargetDisconnected() {
155
141
  this.#reconcile.schedule();
156
142
  }
157
- /** Reconciles the aggregate for a child added at runtime. */
158
143
  childTargetConnected() {
159
144
  this.#reconcile.schedule();
160
145
  }
161
- /** Reconciles the aggregate after a child leaves the group. */
162
146
  childTargetDisconnected() {
163
147
  this.#reconcile.schedule();
164
148
  }
165
- /** Cascades the parent's state to every child. Bound via `data-action` (change). */
166
149
  onParentChange() {
167
150
  if (!this.hasParentTarget) return;
151
+ const previous = this.#committedState;
168
152
  const checked = this.parentTarget.checked;
169
153
  for (const child of this.childTargets) {
170
154
  child.checked = checked;
171
155
  }
172
156
  this.#reflect(checked ? "all" : "none", true);
157
+ if (this.#committedState === previous) return;
173
158
  const detail = this.#settledDetail();
174
159
  if (detail) this.dispatch("change", { detail });
175
160
  }
176
- /** Recomputes the parent from its children. Bound via `data-action` (change). */
177
161
  onChildChange() {
162
+ const previous = this.#committedState;
178
163
  this.#syncFromChildren();
164
+ if (this.#committedState === previous) return;
179
165
  const detail = this.#settledDetail();
180
166
  if (detail) this.dispatch("change", { detail });
181
167
  }
182
- /**
183
- * Announces an aggregate this pass derived rather than the user. `change` stays
184
- * reserved for the two public actions, so automation never reads a repair as an edit.
185
- */
186
168
  #reconcileFromChildren() {
187
169
  const previous = this.#committedState;
188
170
  this.#syncFromChildren();
@@ -190,20 +172,9 @@ var CheckboxController = class extends Controller {
190
172
  const detail = this.#settledDetail();
191
173
  if (detail) this.dispatch("reconcile", { detail });
192
174
  }
193
- /**
194
- * Derives the parent's `checked`/`indeterminate` and the root `data-state` from
195
- * the children. Writing state and reporting it are separate so the caller — not
196
- * a flag threaded through the write — decides which event describes the cause.
197
- */
198
175
  #syncFromChildren() {
199
176
  this.#reflect(this.#aggregate(), this.childTargets.length > 0);
200
177
  }
201
- /**
202
- * Reflects one aggregate state.
203
- *
204
- * @param writeParent - whether the state is authoritative over the `parent`
205
- * target's own `checked` / `indeterminate`.
206
- */
207
178
  #reflect(state, writeParent) {
208
179
  if (writeParent && this.hasParentTarget) {
209
180
  this.parentTarget.checked = state === "all";
@@ -212,29 +183,17 @@ var CheckboxController = class extends Controller {
212
183
  this.element.setAttribute("data-state", state);
213
184
  this.#committedState = state;
214
185
  }
215
- /** The settled aggregate as event detail, or `null` before anything has settled. */
216
186
  #settledDetail() {
217
187
  const state = this.#committedState;
218
188
  if (state === null) return null;
219
189
  return { checked: state === "all", indeterminate: state === "partial", state };
220
190
  }
221
- /** Reconciles retained targets after Turbo has finished morphing their live state. */
222
- #onMorph = () => {
223
- this.#reconcile.schedule();
224
- };
225
- /** Whether a form owns at least one current parent or child target. */
226
191
  #hasCheckboxOwnedBy(form) {
227
192
  return [...this.parentTargets, ...this.childTargets].some((checkbox) => checkbox.form === form);
228
193
  }
229
- /** Whether an observed attribute mutation belongs to this controller's target set. */
230
194
  #isManagedCheckbox(node) {
231
195
  return this.parentTargets.some((checkbox) => checkbox === node) || this.childTargets.some((checkbox) => checkbox === node);
232
196
  }
233
- /**
234
- * Computes the aggregate state. With children it counts them; with none it
235
- * reads the parent so a lone tri-state checkbox still reports a state without
236
- * its externally-set `indeterminate` being clobbered.
237
- */
238
197
  #aggregate() {
239
198
  const children = this.childTargets;
240
199
  if (children.length === 0) {
@@ -249,5 +208,4 @@ var CheckboxController = class extends Controller {
249
208
  };
250
209
 
251
210
  export { CheckboxController };
252
- //# sourceMappingURL=checkbox_controller.js.map
253
211
  //# sourceMappingURL=checkbox_controller.js.map
@@ -1,8 +1,5 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
 
3
- // src/controllers/clipboard_controller.ts
4
-
5
- // src/utils/announce.ts
6
3
  function announce(message, options = {}) {
7
4
  const text = message.trim();
8
5
  if (text.length === 0) return;
@@ -13,79 +10,140 @@ function announce(message, options = {}) {
13
10
  );
14
11
  }
15
12
 
16
- // src/utils/before_cache_reset.ts
17
- var BeforeCacheReset = class _BeforeCacheReset {
18
- /** Every subscribed instance, iterated by the one shared document listener. */
19
- static #subscribers = /* @__PURE__ */ new Set();
20
- /** The shared listener; installed while at least one instance is subscribed. */
21
- static #onBeforeCache = () => {
22
- for (const subscriber of _BeforeCacheReset.#subscribers) subscriber.#rewind();
23
- };
24
- #rewind;
25
- /** @param rewind - the pass that returns this controller's state to its initial form. */
26
- constructor(rewind) {
27
- this.#rewind = rewind;
13
+ function setDefaultAttribute(element, name, value) {
14
+ if (element.hasAttribute(name)) return false;
15
+ element.setAttribute(name, value);
16
+ return true;
17
+ }
18
+
19
+ var ListenerSet = class {
20
+ #abort = new AbortController();
21
+ add(target, type, handler, options) {
22
+ target.addEventListener(type, handler, { ...options, signal: this.#abort.signal });
23
+ }
24
+ dispose() {
25
+ this.#abort.abort();
26
+ this.#abort = new AbortController();
27
+ }
28
+ };
29
+
30
+ var MicrotaskCoalescer = class {
31
+ #run;
32
+ #queued = false;
33
+ #active = false;
34
+ #generation = 0;
35
+ constructor(run) {
36
+ this.#run = run;
28
37
  }
29
- /** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */
30
38
  activate() {
31
- const first = _BeforeCacheReset.#subscribers.size === 0;
32
- _BeforeCacheReset.#subscribers.add(this);
33
- if (first) {
34
- document.addEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
35
- }
39
+ this.#active = true;
40
+ }
41
+ cancel() {
42
+ this.#active = false;
43
+ this.#queued = false;
44
+ this.#generation += 1;
36
45
  }
37
- /** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */
38
- deactivate() {
39
- _BeforeCacheReset.#subscribers.delete(this);
40
- if (_BeforeCacheReset.#subscribers.size > 0) return;
41
- document.removeEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
46
+ schedule() {
47
+ if (!this.#active || this.#queued) return;
48
+ this.#queued = true;
49
+ const generation = this.#generation;
50
+ queueMicrotask(() => {
51
+ if (generation !== this.#generation || !this.#queued || !this.#active) return;
52
+ this.#queued = false;
53
+ this.#run();
54
+ });
42
55
  }
43
56
  };
44
57
 
45
- // src/utils/default_attribute.ts
46
- function setDefaultAttribute(element, name, value) {
47
- if (element.hasAttribute(name)) return false;
48
- element.setAttribute(name, value);
58
+ var MorphRenderWatcher = class {
59
+ #listeners = new ListenerSet();
60
+ #pass;
61
+ #element = null;
62
+ #onMorph = () => this.#pass.schedule();
63
+ constructor(run) {
64
+ this.#pass = new MicrotaskCoalescer(run);
65
+ }
66
+ observe(element) {
67
+ if (this.#element === element) return;
68
+ this.disconnect();
69
+ this.#element = element;
70
+ this.#pass.activate();
71
+ this.#listeners.add(element, "turbo:morph-element", this.#onMorph);
72
+ }
73
+ schedule() {
74
+ this.#pass.schedule();
75
+ }
76
+ disconnect() {
77
+ this.#listeners.dispose();
78
+ this.#pass.cancel();
79
+ this.#element = null;
80
+ }
81
+ };
82
+
83
+ var MAX_TIMER_DELAY_MS = 2147483647;
84
+
85
+ var NUMBER_BOUNDS = {
86
+ timer: { finite: true, min: 0, max: MAX_TIMER_DELAY_MS }};
87
+ function matchesNumberBounds(value, bounds) {
88
+ if (!Number.isFinite(value)) {
89
+ const direction = value === Infinity ? "positive" : value === -Infinity ? "negative" : null;
90
+ if (direction === null) return false;
91
+ if (bounds.allowInfinity !== "both" && bounds.allowInfinity !== direction) return false;
92
+ }
93
+ if (bounds.min !== void 0 && value < bounds.min) return false;
94
+ if (bounds.max !== void 0 && value > bounds.max) return false;
95
+ if (bounds.exclusiveMin !== void 0 && value <= bounds.exclusiveMin) return false;
96
+ if (bounds.integer && !Number.isInteger(value)) return false;
97
+ if (bounds.allowedValues !== void 0 && !bounds.allowedValues.includes(value)) return false;
49
98
  return true;
50
99
  }
51
100
 
52
- // src/utils/safe_timeout.ts
101
+ function readNumber(raw, fallback, bounds) {
102
+ return matchesNumberBounds(raw, bounds) ? raw : fallback;
103
+ }
104
+
105
+ var NumberValueReader = class {
106
+ #lastRejected = /* @__PURE__ */ new Map();
107
+ read(owner, name, raw, fallback, bounds) {
108
+ const resolved = readNumber(raw, fallback, bounds);
109
+ if (matchesNumberBounds(raw, bounds)) {
110
+ this.#lastRejected.delete(name);
111
+ return resolved;
112
+ }
113
+ const attribute = `data-${owner.identifier}-${name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}-value`;
114
+ const literal = owner.element.getAttribute(attribute);
115
+ if (literal === null) {
116
+ this.#lastRejected.delete(name);
117
+ return resolved;
118
+ }
119
+ if (this.#lastRejected.get(name) !== literal) {
120
+ this.#lastRejected.set(name, literal);
121
+ console.warn(
122
+ `Stimeo UI: "${owner.identifier}" has an invalid number Value "${name}" declaration ${JSON.stringify(literal)}; using ${fallback}.`
123
+ );
124
+ }
125
+ return resolved;
126
+ }
127
+ };
128
+
53
129
  var TimerRegistry = class {
54
- /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
55
130
  ids = /* @__PURE__ */ new Set();
56
- /**
57
- * Cancels a single tracked timer.
58
- *
59
- * No-ops if the id is unknown (already cleared, fired, or never owned by this
60
- * registry), so callers can clear defensively without guarding.
61
- */
62
131
  clear(id) {
63
132
  if (this.ids.delete(id)) {
64
133
  this.cancel(id);
65
134
  }
66
135
  }
67
- /**
68
- * Cancels every tracked timer. Call this from a controller's `disconnect()`
69
- * to guarantee no timer outlives the element.
70
- */
71
136
  clearAll() {
72
137
  for (const id of this.ids) {
73
138
  this.cancel(id);
74
139
  }
75
140
  this.ids.clear();
76
141
  }
77
- /** Number of timers currently tracked (pending). */
78
142
  get size() {
79
143
  return this.ids.size;
80
144
  }
81
145
  };
82
146
  var SafeTimeout = class extends TimerRegistry {
83
- /**
84
- * Schedules `callback` after `delay` ms and returns the timer id.
85
- *
86
- * The id is removed from the registry automatically when the timeout fires,
87
- * so {@link TimerRegistry.size | size} reflects only still-pending timers.
88
- */
89
147
  set(callback, delay) {
90
148
  const id = this.schedule(() => {
91
149
  this.ids.delete(id);
@@ -102,9 +160,9 @@ var SafeTimeout = class extends TimerRegistry {
102
160
  }
103
161
  };
104
162
 
105
- // src/controllers/clipboard_controller.ts
106
163
  var TRANSIENT_STATES = /* @__PURE__ */ new Set(["copied", "error"]);
107
- var ClipboardController = class extends Controller {
164
+ var ClipboardController = class _ClipboardController extends Controller {
165
+ #numbers = new NumberValueReader();
108
166
  static targets = ["source", "button", "feedback"];
109
167
  static values = {
110
168
  text: { type: String, default: "" },
@@ -114,63 +172,39 @@ var ClipboardController = class extends Controller {
114
172
  announceCopiedText: { type: String, default: "" },
115
173
  announceErrorText: { type: String, default: "" }
116
174
  };
175
+ static valueConstraints = {
176
+ feedbackDuration: NUMBER_BOUNDS.timer
177
+ };
117
178
  static actions = ["copy"];
118
179
  static events = ["copy"];
119
- /**
120
- * The pending return to idle — the only timer this controller schedules, so
121
- * `clearAll()` is exactly "drop the auto-clear" and needs no id of its own.
122
- */
123
180
  #timers = new SafeTimeout();
124
- /** Returns the completion state to idle for the snapshot Turbo takes. */
125
- #beforeCache = new BeforeCacheReset(() => this.#rewind());
126
- /**
127
- * Whether this connection is still live. `copy()` suspends on the Clipboard API,
128
- * and a teardown that lands while it is suspended must win: the continuation
129
- * would otherwise write to an element nobody owns and arm a timer past the
130
- * `clearAll()` that was supposed to be the last word. Stimulus also runs the label
131
- * callbacks ahead of `connect()`, while this is still false: a result restored with
132
- * the markup is `connect()`'s to settle, not theirs.
133
- */
134
181
  #connected = false;
182
+ #result = null;
183
+ #feedback = null;
184
+ #written = /* @__PURE__ */ new Map();
185
+ #morphRender = new MorphRenderWatcher(() => this.#renderResult());
135
186
  connect() {
136
187
  this.#connected = true;
137
188
  this.#adopt();
138
- this.#beforeCache.activate();
189
+ this.#result = null;
190
+ this.#feedback = this.hasFeedbackTarget ? {
191
+ element: this.feedbackTarget,
192
+ original: this.feedbackTarget.textContent ?? "",
193
+ text: this.feedbackTarget.textContent ?? ""
194
+ } : null;
195
+ this.#morphRender.observe(this.element);
139
196
  }
140
197
  disconnect() {
141
198
  this.#connected = false;
142
- this.#beforeCache.deactivate();
199
+ this.#morphRender.disconnect();
143
200
  this.#timers.clearAll();
144
201
  }
145
- /**
146
- * Follows a `copiedLabel` swapped in place while a successful copy is shown.
147
- *
148
- * Render only: nothing is announced or dispatched, and the return to idle keeps the
149
- * deadline the copy set. The slot changes only while `data-state` is `copied` and it
150
- * still reads the previous label — the one the copy wrote there — so text someone
151
- * else put in the slot stays. Stimulus also runs this ahead of `connect()`, which
152
- * settles a result restored with the markup itself, so nothing is written then.
153
- */
154
202
  copiedLabelValueChanged(label, previous) {
155
203
  this.#followLabel("copied", label, previous);
156
204
  }
157
- /**
158
- * Follows an `errorLabel` swapped in place while a failed copy is shown, on the same
159
- * terms as {@link ClipboardController.copiedLabelValueChanged}: only while
160
- * `data-state` is `error` and the slot still reads the previous label.
161
- */
162
205
  errorLabelValueChanged(label, previous) {
163
206
  this.#followLabel("error", label, previous);
164
207
  }
165
- /**
166
- * Copies the resolved text and reports the outcome. Bound via `data-action`
167
- * (click). Dispatches `stimeo--clipboard:copy` with `{ success, text, message }`
168
- * once per completed attempt — including on failure — so consumers can react
169
- * either way. `message` is the authored label for that outcome, so a part that
170
- * shows wording — `stimeo--clipboard:copy->stimeo--toast#show` — needs nothing
171
- * else; wiring both that and the announcer reads the same result twice.
172
- * An attempt whose connection ended while it was in flight reports nothing.
173
- */
174
208
  async copy() {
175
209
  const text = this.#resolveText();
176
210
  let success = false;
@@ -185,10 +219,6 @@ var ClipboardController = class extends Controller {
185
219
  const message = success ? this.copiedLabelValue : this.errorLabelValue;
186
220
  this.dispatch("copy", { detail: { success, text, message } });
187
221
  }
188
- /**
189
- * The text to copy: the explicit `text` value when set, otherwise the source
190
- * target's current value (inputs/textareas) or text content.
191
- */
192
222
  #resolveText() {
193
223
  if (this.textValue.length > 0) return this.textValue;
194
224
  if (!this.hasSourceTarget) return "";
@@ -198,92 +228,83 @@ var ClipboardController = class extends Controller {
198
228
  }
199
229
  return source.textContent ?? "";
200
230
  }
201
- /**
202
- * Reads the current state back from the DOM.
203
- *
204
- * A `copied` or `error` found at connect time is this controller's own output
205
- * from a connection that is gone, and so is the timer that would have cleared it
206
- * — nothing else would ever return the element to `idle`. Any other authored
207
- * value belongs to the consumer and only a missing attribute takes the default.
208
- */
209
231
  #adopt() {
210
232
  if (this.#inTransientState()) {
233
+ const restored = this.#written.size === 0;
211
234
  this.#reset();
235
+ if (restored && this.hasFeedbackTarget) this.feedbackTarget.textContent = "";
212
236
  return;
213
237
  }
214
238
  setDefaultAttribute(this.element, "data-state", "idle");
215
239
  }
216
- /** Whether `data-state` currently holds one of the values this controller writes. */
217
240
  #inTransientState() {
218
241
  const state = this.element.getAttribute("data-state");
219
242
  return state !== null && TRANSIENT_STATES.has(state);
220
243
  }
221
- /**
222
- * Returns the completion state to idle for the snapshot Turbo is about to take,
223
- * so a page reached with the Back button does not report a copy that happened
224
- * before the navigation. Only a state this controller wrote is rewound — an
225
- * authored one is the consumer's and has to survive into the snapshot, exactly as
226
- * `connect()` leaves it alone. State only — no `copy` is dispatched, which would
227
- * claim a fresh copy ran.
228
- */
229
- #rewind() {
230
- if (!this.#inTransientState()) return;
231
- this.#timers.clearAll();
232
- this.#reset();
233
- }
234
- /**
235
- * Reflects the result, announces it, and schedules the return to idle.
236
- *
237
- * @stimeoRenderRoot
238
- */
239
244
  #reportResult(success) {
240
- this.element.setAttribute("data-state", success ? "copied" : "error");
241
- if (this.hasFeedbackTarget) {
242
- this.feedbackTarget.textContent = success ? this.copiedLabelValue : this.errorLabelValue;
243
- }
245
+ this.#result = success;
246
+ this.#renderResult(true);
244
247
  this.#announceResult(success);
245
248
  this.#scheduleReset();
246
249
  }
247
- /**
248
- * Reads the result out through the shared announcer.
249
- *
250
- * @stimeoRuntimeOnly `announceCopiedText` / `announceErrorText` word the one announcement a copy
251
- * makes.
252
- */
250
+ #renderResult(force = false) {
251
+ if (this.#result === null) {
252
+ setDefaultAttribute(this.element, "data-state", "idle");
253
+ return;
254
+ }
255
+ this.element.setAttribute("data-state", this.#result ? "copied" : "error");
256
+ if (!this.hasFeedbackTarget) return;
257
+ const element = this.feedbackTarget;
258
+ const held = this.#feedback;
259
+ if (!force && (!held || element !== held.element || element.firstElementChild !== null || element.textContent !== held.text && element.textContent !== held.original))
260
+ return;
261
+ const text = this.#result ? this.copiedLabelValue : this.errorLabelValue;
262
+ this.#feedback = {
263
+ element,
264
+ original: held?.element === element ? held.original : element.textContent ?? "",
265
+ text
266
+ };
267
+ element.textContent = text;
268
+ this.#written.set(element, text);
269
+ }
253
270
  #announceResult(success) {
254
271
  announce(success ? this.announceCopiedTextValue : this.announceErrorTextValue);
255
272
  }
256
- /**
257
- * Arms the return to idle for the result just shown.
258
- *
259
- * @stimeoRuntimeOnly `feedbackDuration` is the delay of the one timer this call arms, and the
260
- * reset that timer runs reads no Value.
261
- */
262
273
  #scheduleReset() {
263
274
  this.#timers.clearAll();
264
- if (this.feedbackDurationValue > 0) {
265
- this.#timers.set(() => this.#reset(), this.feedbackDurationValue);
275
+ if (this.#safeFeedbackDuration > 0) {
276
+ this.#timers.set(() => this.#reset(), this.#safeFeedbackDuration);
266
277
  }
267
278
  }
268
- /**
269
- * Rewrites the slot to `label` while `state` is shown and the slot still reads
270
- * `previous`, the label this controller wrote for that result.
271
- */
272
279
  #followLabel(state, label, previous) {
273
280
  if (!this.#connected) return;
274
281
  if (this.element.getAttribute("data-state") !== state || !this.hasFeedbackTarget) return;
275
282
  if (this.feedbackTarget.textContent !== previous) return;
276
283
  this.feedbackTarget.textContent = label;
284
+ this.#written.set(this.feedbackTarget, label);
285
+ if (this.#feedback?.element === this.feedbackTarget) this.#feedback.text = label;
277
286
  }
278
- /** Returns to the idle state and empties the completion slot. */
279
287
  #reset() {
288
+ this.#result = null;
280
289
  this.element.setAttribute("data-state", "idle");
281
- if (this.hasFeedbackTarget) {
282
- this.feedbackTarget.textContent = "";
290
+ for (const [element, text] of this.#written) {
291
+ if (!this.feedbackTargets.includes(element)) continue;
292
+ if (element.firstElementChild === null && element.textContent === text) {
293
+ element.textContent = "";
294
+ }
283
295
  }
296
+ this.#written.clear();
297
+ }
298
+ get #safeFeedbackDuration() {
299
+ return this.#numbers.read(
300
+ this,
301
+ "feedbackDuration",
302
+ this.feedbackDurationValue,
303
+ _ClipboardController.values.feedbackDuration.default,
304
+ _ClipboardController.valueConstraints.feedbackDuration
305
+ );
284
306
  }
285
307
  };
286
308
 
287
309
  export { ClipboardController };
288
- //# sourceMappingURL=clipboard_controller.js.map
289
310
  //# sourceMappingURL=clipboard_controller.js.map