stimeo-ui 0.1.0.pre.alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +72 -0
  4. data/dist/controllers/accordion_controller.js +76 -0
  5. data/dist/controllers/announcer_controller.js +184 -0
  6. data/dist/controllers/aspect_ratio_controller.js +36 -0
  7. data/dist/controllers/auto_submit_controller.js +147 -0
  8. data/dist/controllers/avatar_controller.js +66 -0
  9. data/dist/controllers/breadcrumb_controller.js +123 -0
  10. data/dist/controllers/bulk_select_controller.js +104 -0
  11. data/dist/controllers/calendar_controller.js +394 -0
  12. data/dist/controllers/character_counter_controller.js +179 -0
  13. data/dist/controllers/checkbox_controller.js +73 -0
  14. data/dist/controllers/combobox_controller.js +186 -0
  15. data/dist/controllers/command_palette_controller.js +381 -0
  16. data/dist/controllers/conditional_fields_controller.js +112 -0
  17. data/dist/controllers/confirm_controller.js +276 -0
  18. data/dist/controllers/context_menu_controller.js +112 -0
  19. data/dist/controllers/countdown_controller.js +202 -0
  20. data/dist/controllers/dialog_controller.js +207 -0
  21. data/dist/controllers/direct_upload_controller.js +212 -0
  22. data/dist/controllers/dirty_form_controller.js +128 -0
  23. data/dist/controllers/dropdown_controller.js +66 -0
  24. data/dist/controllers/empty_state_controller.js +67 -0
  25. data/dist/controllers/flash_controller.js +221 -0
  26. data/dist/controllers/focus_controller.js +216 -0
  27. data/dist/controllers/form_field_controller.js +154 -0
  28. data/dist/controllers/form_validation_controller.js +202 -0
  29. data/dist/controllers/frame_loading_controller.js +177 -0
  30. data/dist/controllers/highlight_controller.js +107 -0
  31. data/dist/controllers/hover_card_controller.js +165 -0
  32. data/dist/controllers/idle_controller.js +141 -0
  33. data/dist/controllers/input_mask_controller.js +166 -0
  34. data/dist/controllers/lazy_frame_controller.js +68 -0
  35. data/dist/controllers/listbox_controller.js +256 -0
  36. data/dist/controllers/local_time_controller.js +81 -0
  37. data/dist/controllers/menu_controller.js +134 -0
  38. data/dist/controllers/meter_controller.js +96 -0
  39. data/dist/controllers/nested_form_controller.js +131 -0
  40. data/dist/controllers/network_status_controller.js +126 -0
  41. data/dist/controllers/number_input_controller.js +306 -0
  42. data/dist/controllers/otp_controller.js +201 -0
  43. data/dist/controllers/overflow_indicator_controller.js +169 -0
  44. data/dist/controllers/overflow_menu_controller.js +274 -0
  45. data/dist/controllers/pagination_controller.js +89 -0
  46. data/dist/controllers/password_strength_controller.js +175 -0
  47. data/dist/controllers/persist_controller.js +259 -0
  48. data/dist/controllers/popover_controller.js +94 -0
  49. data/dist/controllers/portal_controller.js +63 -0
  50. data/dist/controllers/preview_guard_controller.js +69 -0
  51. data/dist/controllers/progress_controller.js +93 -0
  52. data/dist/controllers/radio_group_controller.js +128 -0
  53. data/dist/controllers/rating_controller.js +179 -0
  54. data/dist/controllers/relative_time_controller.js +129 -0
  55. data/dist/controllers/reset_before_cache_controller.js +62 -0
  56. data/dist/controllers/resizable_controller.js +163 -0
  57. data/dist/controllers/roving_controller.js +116 -0
  58. data/dist/controllers/scroll_area_controller.js +183 -0
  59. data/dist/controllers/scroll_visibility_controller.js +103 -0
  60. data/dist/controllers/scrollspy_controller.js +171 -0
  61. data/dist/controllers/skeleton_controller.js +125 -0
  62. data/dist/controllers/slider_controller.js +109 -0
  63. data/dist/controllers/spinner_controller.js +164 -0
  64. data/dist/controllers/step_indicator_controller.js +55 -0
  65. data/dist/controllers/stepper_controller.js +78 -0
  66. data/dist/controllers/stick_to_bottom_controller.js +100 -0
  67. data/dist/controllers/sticky_observer_controller.js +53 -0
  68. data/dist/controllers/submit_once_controller.js +206 -0
  69. data/dist/controllers/switch_controller.js +50 -0
  70. data/dist/controllers/tabs_controller.js +63 -0
  71. data/dist/controllers/textarea_autosize_controller.js +72 -0
  72. data/dist/controllers/theme_controller.js +154 -0
  73. data/dist/controllers/toast_controller.js +310 -0
  74. data/dist/controllers/toggle_group_controller.js +130 -0
  75. data/dist/controllers/toolbar_controller.js +113 -0
  76. data/dist/controllers/tooltip_controller.js +165 -0
  77. data/dist/controllers/transition_controller.js +203 -0
  78. data/dist/index.js +12241 -0
  79. data/dist/positioning/index.js +145 -0
  80. data/lib/generators/stimeo/install/install_generator.rb +114 -0
  81. data/lib/generators/stimeo/install/templates/stimeo.js +12 -0
  82. data/lib/stimeo/ui/version.rb +10 -0
  83. data/lib/stimeo/ui.rb +19 -0
  84. data/lib/stimeo-ui.rb +4 -0
  85. metadata +152 -0
@@ -0,0 +1,306 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ // src/controllers/number_input_controller.ts
4
+
5
+ // src/utils/safe_timeout.ts
6
+ var TimerRegistry = class {
7
+ /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
8
+ ids = /* @__PURE__ */ new Set();
9
+ /**
10
+ * Cancels a single tracked timer.
11
+ *
12
+ * No-ops if the id is unknown (already cleared, fired, or never owned by this
13
+ * registry), so callers can clear defensively without guarding.
14
+ */
15
+ clear(id) {
16
+ if (this.ids.delete(id)) {
17
+ this.cancel(id);
18
+ }
19
+ }
20
+ /**
21
+ * Cancels every tracked timer. Call this from a controller's `disconnect()`
22
+ * to guarantee no timer outlives the element.
23
+ */
24
+ clearAll() {
25
+ for (const id of this.ids) {
26
+ this.cancel(id);
27
+ }
28
+ this.ids.clear();
29
+ }
30
+ /** Number of timers currently tracked (pending). */
31
+ get size() {
32
+ return this.ids.size;
33
+ }
34
+ };
35
+ var SafeTimeout = class extends TimerRegistry {
36
+ /**
37
+ * Schedules `callback` after `delay` ms and returns the timer id.
38
+ *
39
+ * The id is removed from the registry automatically when the timeout fires,
40
+ * so {@link TimerRegistry.size | size} reflects only still-pending timers.
41
+ */
42
+ set(callback, delay) {
43
+ const id = this.schedule(() => {
44
+ this.ids.delete(id);
45
+ callback();
46
+ }, delay);
47
+ this.ids.add(id);
48
+ return id;
49
+ }
50
+ schedule(callback, delay) {
51
+ return window.setTimeout(callback, delay);
52
+ }
53
+ cancel(id) {
54
+ window.clearTimeout(id);
55
+ }
56
+ };
57
+ var SafeInterval = class extends TimerRegistry {
58
+ /** Schedules a repeating `callback` every `delay` ms and returns the timer id. */
59
+ set(callback, delay) {
60
+ const id = this.schedule(callback, delay);
61
+ this.ids.add(id);
62
+ return id;
63
+ }
64
+ schedule(callback, delay) {
65
+ return window.setInterval(callback, delay);
66
+ }
67
+ cancel(id) {
68
+ window.clearInterval(id);
69
+ }
70
+ };
71
+
72
+ // src/controllers/number_input_controller.ts
73
+ var NumberInputController = class _NumberInputController extends Controller {
74
+ static targets = ["input", "increment", "decrement"];
75
+ static values = {
76
+ min: { type: Number, default: Number.NEGATIVE_INFINITY },
77
+ max: { type: Number, default: Number.POSITIVE_INFINITY },
78
+ step: { type: Number, default: 1 },
79
+ pageStep: { type: Number, default: 0 }
80
+ };
81
+ static actions = ["decrement", "increment", "onInput", "onKeydown"];
82
+ static events = ["change"];
83
+ /** Delay (ms) a button must be held before auto-repeat starts. */
84
+ static #HOLD_DELAY_MS = 400;
85
+ /** Interval (ms) between auto-repeat steps once a hold has started. */
86
+ static #HOLD_REPEAT_MS = 80;
87
+ /**
88
+ * Window (ms) after a hold ends during which the trailing synthetic `click` is
89
+ * swallowed. A safety net for the (rare) case where that click never arrives
90
+ * (e.g. the pointer was released off the button): the flag self-clears instead
91
+ * of poisoning the next legitimate click.
92
+ */
93
+ static #SUPPRESS_RESET_MS = 250;
94
+ /** Aborts the pointer listeners on disconnect so none outlive the element. */
95
+ #guards = null;
96
+ /** Timers for the hold delay and the suppress-reset safety net. */
97
+ #holdTimeouts = new SafeTimeout();
98
+ /** The running auto-repeat interval (one at a time). */
99
+ #holdIntervals = new SafeInterval();
100
+ /** True while a hold is armed/running, making `#stopHold` idempotent. */
101
+ #holdActive = false;
102
+ /** True once a hold actually produced a repeated step (vs. a quick click). */
103
+ #repeatedDuringHold = false;
104
+ /** True when the next `click` is the trailing one after a hold and must be ignored. */
105
+ #suppressNextClick = false;
106
+ /** Normalizes any initial value and wires the focus/hold pointer guards. */
107
+ connect() {
108
+ if (!this.hasInputTarget) return;
109
+ if (this.inputTarget.value.trim() !== "") {
110
+ this.#write(this.#normalize(this.#currentValue()));
111
+ } else {
112
+ this.#updateButtons(this.#currentValue());
113
+ }
114
+ this.#guards = new AbortController();
115
+ const { signal } = this.#guards;
116
+ if (this.hasIncrementTarget) this.#wireButton(this.incrementTarget, this.stepValue, signal);
117
+ if (this.hasDecrementTarget) this.#wireButton(this.decrementTarget, -this.stepValue, signal);
118
+ window.addEventListener("pointerup", this.#stopHold, { signal });
119
+ window.addEventListener("pointercancel", this.#stopHold, { signal });
120
+ window.addEventListener("blur", this.#stopHold, { signal });
121
+ }
122
+ /** Releases the pointer guards and tears down every pending timer and hold state. */
123
+ disconnect() {
124
+ this.#guards?.abort();
125
+ this.#guards = null;
126
+ this.#holdActive = false;
127
+ this.#repeatedDuringHold = false;
128
+ this.#suppressNextClick = false;
129
+ this.#holdTimeouts.clearAll();
130
+ this.#holdIntervals.clearAll();
131
+ }
132
+ /** Increases by one step. Bound via `data-action` (click). */
133
+ increment() {
134
+ if (this.#consumeSuppressedClick()) return;
135
+ this.#commit(this.#currentValue() + this.stepValue);
136
+ this.inputTarget.focus();
137
+ }
138
+ /** Decreases by one step. Bound via `data-action` (click). */
139
+ decrement() {
140
+ if (this.#consumeSuppressedClick()) return;
141
+ this.#commit(this.#currentValue() - this.stepValue);
142
+ this.inputTarget.focus();
143
+ }
144
+ /** Clamps and snaps a typed value. Bound via `data-action` (change). */
145
+ onInput() {
146
+ if (this.inputTarget.value.trim() === "") return;
147
+ this.#commit(this.#currentValue());
148
+ }
149
+ /** Keyboard stepping per the APG spinbutton model. */
150
+ onKeydown(event) {
151
+ const page = this.pageStepValue > 0 ? this.pageStepValue : this.stepValue * 10;
152
+ let next = null;
153
+ switch (event.key) {
154
+ case "ArrowUp":
155
+ next = this.#currentValue() + this.stepValue;
156
+ break;
157
+ case "ArrowDown":
158
+ next = this.#currentValue() - this.stepValue;
159
+ break;
160
+ case "PageUp":
161
+ next = this.#currentValue() + page;
162
+ break;
163
+ case "PageDown":
164
+ next = this.#currentValue() - page;
165
+ break;
166
+ case "Home":
167
+ if (!Number.isFinite(this.minValue)) return;
168
+ next = this.minValue;
169
+ break;
170
+ case "End":
171
+ if (!Number.isFinite(this.maxValue)) return;
172
+ next = this.maxValue;
173
+ break;
174
+ default:
175
+ return;
176
+ }
177
+ event.preventDefault();
178
+ this.#commit(next);
179
+ }
180
+ /**
181
+ * Wires a step button: `pointerdown` keeps focus on the input and arms the
182
+ * hold; leaving the button while held stops it (the global listeners cover
183
+ * release/cancel/blur).
184
+ */
185
+ #wireButton(button, delta, signal) {
186
+ button.addEventListener("pointerdown", (event) => this.#armHold(event, button, delta), {
187
+ signal
188
+ });
189
+ button.addEventListener("pointerleave", this.#stopHold, { signal });
190
+ }
191
+ /** Starts a hold: focus the input, then schedule the first repeat after a delay. */
192
+ #armHold(event, button, delta) {
193
+ const pointerButton = event.button;
194
+ if (typeof pointerButton === "number" && pointerButton !== 0) return;
195
+ if (button.disabled) return;
196
+ event.preventDefault();
197
+ this.inputTarget.focus();
198
+ this.#stopHold();
199
+ this.#holdActive = true;
200
+ this.#repeatedDuringHold = false;
201
+ this.#suppressNextClick = false;
202
+ this.#holdTimeouts.set(() => {
203
+ if (!this.#commit(this.#currentValue() + delta)) {
204
+ this.#stopHold();
205
+ return;
206
+ }
207
+ this.#repeatedDuringHold = true;
208
+ this.#holdIntervals.set(() => {
209
+ if (!this.#commit(this.#currentValue() + delta)) this.#stopHold();
210
+ }, _NumberInputController.#HOLD_REPEAT_MS);
211
+ }, _NumberInputController.#HOLD_DELAY_MS);
212
+ }
213
+ /**
214
+ * Stops the current hold (idempotent). When the hold actually repeated, the
215
+ * trailing synthetic `click` must be ignored, so it is suppressed until the
216
+ * click consumes it or a short safety-net timeout clears it.
217
+ */
218
+ #stopHold = () => {
219
+ if (!this.#holdActive) return;
220
+ this.#holdActive = false;
221
+ this.#holdTimeouts.clearAll();
222
+ this.#holdIntervals.clearAll();
223
+ if (this.#repeatedDuringHold) {
224
+ this.#suppressNextClick = true;
225
+ this.#holdTimeouts.set(() => {
226
+ this.#suppressNextClick = false;
227
+ }, _NumberInputController.#SUPPRESS_RESET_MS);
228
+ }
229
+ };
230
+ /** Consumes a pending trailing-click suppression; returns true if the click was swallowed. */
231
+ #consumeSuppressedClick() {
232
+ if (!this.#suppressNextClick) return false;
233
+ this.#suppressNextClick = false;
234
+ this.#holdTimeouts.clearAll();
235
+ return true;
236
+ }
237
+ /**
238
+ * Normalizes `raw`, reflects it, and dispatches `change` only when the
239
+ * displayed value actually changes.
240
+ *
241
+ * @returns Whether the value changed (drives the auto-repeat's bound stop).
242
+ */
243
+ #commit(raw) {
244
+ const value = this.#normalize(raw);
245
+ const changed = this.inputTarget.value !== String(value);
246
+ this.#write(value);
247
+ if (changed) this.dispatch("change", { detail: { value } });
248
+ return changed;
249
+ }
250
+ /** Reflects `value` on the input (and ARIA for non-native hosts) and the buttons. */
251
+ #write(value) {
252
+ this.inputTarget.value = String(value);
253
+ if (this.inputTarget.getAttribute("role") === "spinbutton") {
254
+ this.inputTarget.setAttribute("aria-valuenow", String(value));
255
+ if (Number.isFinite(this.minValue)) {
256
+ this.inputTarget.setAttribute("aria-valuemin", String(this.minValue));
257
+ }
258
+ if (Number.isFinite(this.maxValue)) {
259
+ this.inputTarget.setAttribute("aria-valuemax", String(this.maxValue));
260
+ }
261
+ }
262
+ this.#updateButtons(value);
263
+ }
264
+ /** Disables a step button at its bound, returning focus to the input first. */
265
+ #updateButtons(value) {
266
+ if (this.hasIncrementTarget) this.#toggleButton(this.incrementTarget, value < this.maxValue);
267
+ if (this.hasDecrementTarget) this.#toggleButton(this.decrementTarget, value > this.minValue);
268
+ }
269
+ /**
270
+ * Enables or disables a step button at its bound, never disabling one while it
271
+ * holds focus. Owns only the `disabled` it sets itself via a marker
272
+ * (`data-number-input-disabled`, like `conditional-fields`/`submit-once`), so an
273
+ * author-disabled button (e.g. the whole control disabled) is never re-enabled.
274
+ */
275
+ #toggleButton(button, enabled) {
276
+ if (enabled) {
277
+ if (button.hasAttribute("data-number-input-disabled")) {
278
+ button.disabled = false;
279
+ button.removeAttribute("data-number-input-disabled");
280
+ }
281
+ return;
282
+ }
283
+ if (button.disabled) return;
284
+ if (document.activeElement === button) this.inputTarget.focus();
285
+ button.disabled = true;
286
+ button.setAttribute("data-number-input-disabled", "");
287
+ }
288
+ /** The current numeric value, falling back to a finite min (else 0) when blank. */
289
+ #currentValue() {
290
+ const parsed = Number(this.inputTarget.value);
291
+ if (Number.isFinite(parsed) && this.inputTarget.value.trim() !== "") return parsed;
292
+ return Number.isFinite(this.minValue) ? this.minValue : 0;
293
+ }
294
+ /** Clamps to `[min, max]` and snaps to the step grid anchored at a finite min (else 0). */
295
+ #normalize(raw) {
296
+ const clamped = Math.min(this.maxValue, Math.max(this.minValue, raw));
297
+ if (this.stepValue <= 0) return clamped;
298
+ const base = Number.isFinite(this.minValue) ? this.minValue : 0;
299
+ const stepped = Math.round((clamped - base) / this.stepValue) * this.stepValue + base;
300
+ return Math.min(this.maxValue, Math.max(this.minValue, stepped));
301
+ }
302
+ };
303
+
304
+ export { NumberInputController };
305
+ //# sourceMappingURL=number_input_controller.js.map
306
+ //# sourceMappingURL=number_input_controller.js.map
@@ -0,0 +1,201 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ // src/controllers/otp_controller.ts
4
+ var OtpController = class extends Controller {
5
+ static targets = ["field", "value", "error"];
6
+ static values = {
7
+ length: { type: Number, default: 6 },
8
+ pattern: { type: String, default: "[0-9]" }
9
+ };
10
+ static actions = ["onInput", "onKeydown", "onPaste"];
11
+ static events = ["change", "complete", "invalid"];
12
+ #isComposing = /* @__PURE__ */ new Map();
13
+ connect() {
14
+ for (const field of this.fieldTargets) {
15
+ field.addEventListener("focus", this.#onFieldFocus);
16
+ field.addEventListener("compositionstart", this.#onCompositionStart);
17
+ field.addEventListener("compositionend", this.#onCompositionEnd);
18
+ }
19
+ }
20
+ disconnect() {
21
+ for (const field of this.fieldTargets) {
22
+ field.removeEventListener("focus", this.#onFieldFocus);
23
+ field.removeEventListener("compositionstart", this.#onCompositionStart);
24
+ field.removeEventListener("compositionend", this.#onCompositionEnd);
25
+ }
26
+ this.#isComposing.clear();
27
+ }
28
+ /**
29
+ * Stimulus lifecycle callback when a new field target enters the DOM.
30
+ * Ensures new additions are also wired with overwriting support.
31
+ */
32
+ fieldTargetConnected(element) {
33
+ element.addEventListener("focus", this.#onFieldFocus);
34
+ element.addEventListener("compositionstart", this.#onCompositionStart);
35
+ element.addEventListener("compositionend", this.#onCompositionEnd);
36
+ }
37
+ /** Removes focus listeners when fields are dropped. */
38
+ fieldTargetDisconnected(element) {
39
+ element.removeEventListener("focus", this.#onFieldFocus);
40
+ element.removeEventListener("compositionstart", this.#onCompositionStart);
41
+ element.removeEventListener("compositionend", this.#onCompositionEnd);
42
+ this.#isComposing.delete(element);
43
+ }
44
+ /** Handles keystroke inputs and advances focus to the next field. */
45
+ onInput(event) {
46
+ const input = event.currentTarget;
47
+ if (!input) return;
48
+ if (this.#isComposing.get(input)) return;
49
+ this.#handleInputValidation(input);
50
+ }
51
+ /** Handles Backspace retreating, arrows, and home/end navigation. */
52
+ onKeydown(event) {
53
+ const input = event.currentTarget;
54
+ if (!input) return;
55
+ const index = this.fieldTargets.indexOf(input);
56
+ if (index === -1) return;
57
+ if (this.#isComposing.get(input)) return;
58
+ switch (event.key) {
59
+ case "Backspace":
60
+ if (!input.value) {
61
+ if (index > 0) {
62
+ event.preventDefault();
63
+ const prevField = this.fieldTargets[index - 1];
64
+ if (prevField) {
65
+ prevField.value = "";
66
+ prevField.removeAttribute("data-filled");
67
+ prevField.focus();
68
+ this.#clearError();
69
+ this.#syncAndDispatch();
70
+ }
71
+ }
72
+ } else {
73
+ input.value = "";
74
+ input.removeAttribute("data-filled");
75
+ this.#clearError();
76
+ this.#syncAndDispatch();
77
+ }
78
+ break;
79
+ case "ArrowLeft":
80
+ if (index > 0) {
81
+ event.preventDefault();
82
+ this.fieldTargets[index - 1]?.focus();
83
+ }
84
+ break;
85
+ case "ArrowRight":
86
+ if (index < this.lengthValue - 1) {
87
+ event.preventDefault();
88
+ this.fieldTargets[index + 1]?.focus();
89
+ }
90
+ break;
91
+ case "Home":
92
+ event.preventDefault();
93
+ this.fieldTargets[0]?.focus();
94
+ break;
95
+ case "End":
96
+ event.preventDefault();
97
+ this.fieldTargets[this.lengthValue - 1]?.focus();
98
+ break;
99
+ }
100
+ }
101
+ /** Divides pasted string characters across available input fields. */
102
+ onPaste(event) {
103
+ const input = event.currentTarget;
104
+ if (!input) return;
105
+ const startIndex = this.fieldTargets.indexOf(input);
106
+ if (startIndex === -1) return;
107
+ event.preventDefault();
108
+ const rawText = event.clipboardData?.getData("text") || "";
109
+ const text = this.#normalizeValue(rawText);
110
+ const regex = new RegExp(`^${this.patternValue}$`);
111
+ const validChars = Array.from(text).filter((char) => regex.test(char));
112
+ if (validChars.length === 0) {
113
+ this.#showError();
114
+ return;
115
+ }
116
+ const limit = Math.min(validChars.length, this.lengthValue - startIndex);
117
+ let lastFocusedIndex = startIndex;
118
+ for (let i = 0; i < limit; i++) {
119
+ const fieldIndex = startIndex + i;
120
+ const field = this.fieldTargets[fieldIndex];
121
+ const char = validChars[i];
122
+ if (field && char) {
123
+ field.value = char;
124
+ field.setAttribute("data-filled", "true");
125
+ lastFocusedIndex = fieldIndex;
126
+ }
127
+ }
128
+ this.#clearError();
129
+ const focusTargetIndex = lastFocusedIndex < this.lengthValue - 1 ? lastFocusedIndex + 1 : lastFocusedIndex;
130
+ this.fieldTargets[focusTargetIndex]?.focus();
131
+ this.#syncAndDispatch();
132
+ }
133
+ #onFieldFocus = (event) => {
134
+ const input = event.currentTarget;
135
+ if (input) {
136
+ input.select();
137
+ }
138
+ };
139
+ #onCompositionStart = (event) => {
140
+ const input = event.currentTarget;
141
+ this.#isComposing.set(input, true);
142
+ };
143
+ #onCompositionEnd = (event) => {
144
+ const input = event.currentTarget;
145
+ this.#isComposing.set(input, false);
146
+ this.#handleInputValidation(input);
147
+ };
148
+ #handleInputValidation(input) {
149
+ const index = this.fieldTargets.indexOf(input);
150
+ if (index === -1) return;
151
+ const rawValue = input.value;
152
+ const normalized = this.#normalizeValue(rawValue);
153
+ const regex = new RegExp(`^${this.patternValue}$`);
154
+ if (normalized && regex.test(normalized)) {
155
+ input.value = normalized;
156
+ input.setAttribute("data-filled", "true");
157
+ this.#clearError();
158
+ if (index < this.lengthValue - 1) {
159
+ const nextField = this.fieldTargets[index + 1];
160
+ nextField?.focus();
161
+ }
162
+ } else if (normalized) {
163
+ input.value = "";
164
+ input.removeAttribute("data-filled");
165
+ this.#showError();
166
+ } else {
167
+ input.removeAttribute("data-filled");
168
+ }
169
+ this.#syncAndDispatch();
170
+ }
171
+ #normalizeValue(val) {
172
+ return val.replace(/[0-9]/g, (s) => String.fromCharCode(s.charCodeAt(0) - 65248));
173
+ }
174
+ #showError() {
175
+ if (this.hasErrorTarget) {
176
+ this.errorTarget.removeAttribute("hidden");
177
+ }
178
+ this.dispatch("invalid", { detail: { pattern: this.patternValue } });
179
+ }
180
+ #clearError() {
181
+ if (this.hasErrorTarget) {
182
+ this.errorTarget.setAttribute("hidden", "true");
183
+ }
184
+ }
185
+ #syncAndDispatch() {
186
+ const fields = this.fieldTargets;
187
+ const combinedValue = fields.map((f) => f.value).join("");
188
+ if (this.hasValueTarget) {
189
+ this.valueTarget.value = combinedValue;
190
+ }
191
+ this.dispatch("change", { detail: { value: combinedValue } });
192
+ const isCompleted = fields.every((f) => f.value.length > 0) && combinedValue.length === this.lengthValue;
193
+ if (isCompleted) {
194
+ this.dispatch("complete", { detail: { value: combinedValue } });
195
+ }
196
+ }
197
+ };
198
+
199
+ export { OtpController };
200
+ //# sourceMappingURL=otp_controller.js.map
201
+ //# sourceMappingURL=otp_controller.js.map
@@ -0,0 +1,169 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ // src/controllers/overflow_indicator_controller.ts
4
+
5
+ // src/utils/layout_observer.ts
6
+ var LayoutObserver = class {
7
+ #callback;
8
+ #resizeObserverFactory;
9
+ #resizeObserver = null;
10
+ #observingViewport = false;
11
+ /** Stable bound handler so add/removeEventListener target the same reference. */
12
+ #handleViewportResize = () => {
13
+ this.#callback();
14
+ };
15
+ constructor(callback, options = {}) {
16
+ this.#callback = callback;
17
+ this.#resizeObserverFactory = options.resizeObserverFactory ?? (typeof ResizeObserver === "undefined" ? null : (cb) => new ResizeObserver(cb));
18
+ }
19
+ /**
20
+ * Starts observing an element's size. Repeated calls observe additional
21
+ * elements through the same shared observer. No-ops when no
22
+ * `ResizeObserver` implementation is available.
23
+ */
24
+ observe(element) {
25
+ if (!this.#resizeObserverFactory) return;
26
+ if (!this.#resizeObserver) {
27
+ this.#resizeObserver = this.#resizeObserverFactory(() => {
28
+ this.#callback();
29
+ });
30
+ }
31
+ this.#resizeObserver.observe(element);
32
+ }
33
+ /** Stops observing a single element while leaving any others in place. */
34
+ unobserve(element) {
35
+ this.#resizeObserver?.unobserve(element);
36
+ }
37
+ /** Starts observing viewport resizes. Idempotent: the listener is added once. */
38
+ observeViewport() {
39
+ if (this.#observingViewport) return;
40
+ this.#observingViewport = true;
41
+ window.addEventListener("resize", this.#handleViewportResize);
42
+ }
43
+ /** Stops observing viewport resizes without affecting element observation. */
44
+ unobserveViewport() {
45
+ if (!this.#observingViewport) return;
46
+ this.#observingViewport = false;
47
+ window.removeEventListener("resize", this.#handleViewportResize);
48
+ }
49
+ /**
50
+ * Releases every observation: disconnects the {@link ResizeObserver} and
51
+ * removes the viewport listener. Safe to call multiple times. Call this from a
52
+ * controller's `disconnect()`.
53
+ */
54
+ disconnect() {
55
+ this.#resizeObserver?.disconnect();
56
+ this.#resizeObserver = null;
57
+ this.unobserveViewport();
58
+ }
59
+ };
60
+
61
+ // src/controllers/overflow_indicator_controller.ts
62
+ var OverflowIndicatorController = class extends Controller {
63
+ static targets = ["viewport"];
64
+ static values = {
65
+ orientation: { type: String, default: "horizontal" },
66
+ threshold: { type: Number, default: 1 }
67
+ };
68
+ static actions = ["scrollByPage", "update"];
69
+ static events = ["change"];
70
+ #layout = new LayoutObserver(() => this.update());
71
+ #mutationObserver = null;
72
+ /** Last reported room, so `change` fires only on transitions. */
73
+ #state = null;
74
+ connect() {
75
+ if (!this.hasViewportTarget) return;
76
+ this.#layout.observe(this.viewportTarget);
77
+ this.#layout.observeViewport();
78
+ if (typeof MutationObserver !== "undefined") {
79
+ this.#mutationObserver = new MutationObserver(() => this.update());
80
+ this.#mutationObserver.observe(this.viewportTarget, {
81
+ childList: true,
82
+ subtree: true,
83
+ characterData: true
84
+ });
85
+ }
86
+ this.update();
87
+ }
88
+ disconnect() {
89
+ this.#layout.disconnect();
90
+ this.#mutationObserver?.disconnect();
91
+ this.#mutationObserver = null;
92
+ this.#state = null;
93
+ }
94
+ /** Re-measures remaining scroll room and reflects the state hooks. Public so it can be wired to the viewport's `scroll`. */
95
+ update() {
96
+ if (!this.hasViewportTarget) return;
97
+ const vp = this.viewportTarget;
98
+ const horizontal = this.orientationValue !== "vertical";
99
+ const t = this.thresholdValue;
100
+ const scrollPos = horizontal ? vp.scrollLeft : vp.scrollTop;
101
+ const maxScroll = horizontal ? vp.scrollWidth - vp.clientWidth : vp.scrollHeight - vp.clientHeight;
102
+ const start = scrollPos > t;
103
+ const end = scrollPos < maxScroll - t;
104
+ vp.setAttribute("data-overflow-start", start ? "true" : "false");
105
+ vp.setAttribute("data-overflow-end", end ? "true" : "false");
106
+ this.#syncButtons(start, end);
107
+ if (!this.#state || this.#state.start !== start || this.#state.end !== end) {
108
+ this.#state = { start, end };
109
+ this.dispatch("change", { detail: { start, end } });
110
+ }
111
+ }
112
+ /** Scrolls one viewport page toward the `direction` param (`start`/`end`). */
113
+ scrollByPage(event) {
114
+ if (!this.hasViewportTarget) return;
115
+ const direction = this.#directionFromEvent(event);
116
+ if (!direction) return;
117
+ const vp = this.viewportTarget;
118
+ const horizontal = this.orientationValue !== "vertical";
119
+ const page = horizontal ? vp.clientWidth : vp.clientHeight;
120
+ const delta = direction === "start" ? -page : page;
121
+ const behavior = this.#prefersReducedMotion() ? "auto" : "smooth";
122
+ if (horizontal) {
123
+ vp.scrollBy({ left: delta, behavior });
124
+ } else {
125
+ vp.scrollBy({ top: delta, behavior });
126
+ }
127
+ }
128
+ /** Mirrors remaining room onto any direction buttons by toggling `disabled`. */
129
+ #syncButtons(start, end) {
130
+ const buttons = this.element.querySelectorAll(
131
+ "[data-stimeo--overflow-indicator-direction-param]"
132
+ );
133
+ for (const button of buttons) {
134
+ const direction = button.getAttribute("data-stimeo--overflow-indicator-direction-param");
135
+ if (direction === "start") this.#toggleButton(button, start);
136
+ else if (direction === "end") this.#toggleButton(button, end);
137
+ }
138
+ }
139
+ /**
140
+ * Reflects the remaining room onto a page button's `disabled`, owning only the
141
+ * `disabled` it sets itself via a marker (`data-overflow-indicator-disabled`,
142
+ * like `number-input`/`conditional-fields`). An author-disabled button (e.g. the
143
+ * whole control disabled) is therefore never blindly re-enabled.
144
+ */
145
+ #toggleButton(button, hasRoom) {
146
+ if (hasRoom) {
147
+ if (button.hasAttribute("data-overflow-indicator-disabled")) {
148
+ button.disabled = false;
149
+ button.removeAttribute("data-overflow-indicator-disabled");
150
+ }
151
+ return;
152
+ }
153
+ if (button.disabled) return;
154
+ button.disabled = true;
155
+ button.setAttribute("data-overflow-indicator-disabled", "");
156
+ }
157
+ #directionFromEvent(event) {
158
+ const params = event.params;
159
+ const direction = params?.direction;
160
+ return direction === "start" || direction === "end" ? direction : null;
161
+ }
162
+ #prefersReducedMotion() {
163
+ return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
164
+ }
165
+ };
166
+
167
+ export { OverflowIndicatorController };
168
+ //# sourceMappingURL=overflow_indicator_controller.js.map
169
+ //# sourceMappingURL=overflow_indicator_controller.js.map