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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 34dbcfca1f64a125bc86e2d946b7c2c62753f02e67c7444b4b25823f9ed3f3a3
4
+ data.tar.gz: fb0661197fe81c6559960f2b74d0dfffd5d126f3b285edda5123bf2fe37db6e3
5
+ SHA512:
6
+ metadata.gz: 6f1578a41271afa0633a19c627200e4abe957ce7b6fb7e2d9013db876d75fd84140af7e3b98a8c0e37ffa931c159f9c952b444105b099882cc61bbfde220085b
7
+ data.tar.gz: 2037a7c5dfb426bdc8f19bc6d14e689d488695eb545b59b58572bfbccd8db9d6dd6d9102d129a79d6baef3371ca1bd2301a0a6d353877584601689460c03e2e4
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Stimeo Labs (taiyaky)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,72 @@
1
+ # Stimeo UI
2
+
3
+ **Headless Stimulus UI framework for Ruby on Rails.** Stimeo UI ships *behavior*
4
+ — ARIA state, keyboard interaction, focus management, Turbo resilience — as
5
+ `data-*`-driven Stimulus controllers. It does **not** ship CSS: the consuming app
6
+ owns the look entirely.
7
+
8
+ - Lean by design: the **core** needs only `@hotwired/stimulus` at runtime (kept
9
+ external in the build). The opt-in `stimeo-ui/positioning` module is the one
10
+ exception — it uses `@floating-ui/dom` (an optional **peer dependency**; see the
11
+ Peer dependencies note below).
12
+ - Accessibility first: every controller follows the relevant WAI-ARIA APG pattern
13
+ and the related WCAG 2.2 AA criteria.
14
+ - Public controller identifiers use the `stimeo--` namespace (e.g.
15
+ `stimeo--dropdown`).
16
+
17
+ > Status: **alpha** (`0.x`). The `stimeo--*` attribute API may still change before
18
+ > 1.0 — pin your version.
19
+
20
+ ## Install
21
+
22
+ ### Rails with importmap (recommended)
23
+
24
+ ```bash
25
+ bundle add stimeo-ui
26
+ bin/rails generate stimeo:install
27
+ ```
28
+
29
+ The generator vendors the prebuilt JS into `vendor/javascript/stimeo/`, pins
30
+ `stimeo-ui` in `config/importmap.rb`, and registers all controllers with your
31
+ Stimulus application. Then drive components from HTML alone:
32
+
33
+ ```erb
34
+ <div data-controller="stimeo--dropdown">
35
+ <button data-stimeo--dropdown-target="trigger"
36
+ data-action="click->stimeo--dropdown#toggle">Menu</button>
37
+ <div data-stimeo--dropdown-target="menu" hidden>…</div>
38
+ </div>
39
+ ```
40
+
41
+ ### npm (jsbundling or any bundler)
42
+
43
+ ```bash
44
+ npm install stimeo-ui @hotwired/stimulus
45
+ ```
46
+
47
+ ```js
48
+ import { Application } from "@hotwired/stimulus";
49
+ import { registerStimeo } from "stimeo-ui";
50
+
51
+ const application = Application.start();
52
+ registerStimeo(application); // registers every stimeo--* controller
53
+ ```
54
+
55
+ Need only a few controllers? Import them individually from
56
+ `stimeo-ui/controllers/*` and register them under your own identifiers.
57
+
58
+ - **Peer dependencies:** `@hotwired/stimulus` (always), `@floating-ui/dom` (only
59
+ if you use the opt-in `stimeo-ui/positioning` module — tooltips, popovers, etc.
60
+ work without it via the default flow layout).
61
+ - **No CSS is shipped.** Style the components yourself; controllers only toggle
62
+ ARIA state and `data-*` hooks.
63
+
64
+ ## Contributing
65
+
66
+ Bug reports and feature requests are very welcome — please open a GitHub issue.
67
+ For code changes, open an issue first to discuss direction; see
68
+ [`CONTRIBUTING.md`](CONTRIBUTING.md).
69
+
70
+ ## License
71
+
72
+ Released under the [MIT License](LICENSE) © Stimeo Labs.
@@ -0,0 +1,76 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ // src/controllers/accordion_controller.ts
4
+ var AccordionController = class extends Controller {
5
+ static targets = ["trigger", "panel"];
6
+ static actions = ["collapseAll", "expandAll", "onKeydown", "toggle"];
7
+ /** Toggles the panel controlled by the activated header. */
8
+ toggle(event) {
9
+ const trigger = event.currentTarget;
10
+ const panel = this.#panelFor(trigger);
11
+ if (!panel) return;
12
+ this.#setExpanded(trigger, panel, trigger.getAttribute("aria-expanded") !== "true");
13
+ }
14
+ /** Opens every panel. Bound via `data-action` on an "expand all" control. */
15
+ expandAll() {
16
+ this.#setAll(true);
17
+ }
18
+ /** Closes every panel. Bound via `data-action` on a "collapse all" control. */
19
+ collapseAll() {
20
+ this.#setAll(false);
21
+ }
22
+ /** Drives every header/panel pair to the same expanded state. */
23
+ #setAll(open) {
24
+ for (const trigger of this.triggerTargets) {
25
+ const panel = this.#panelFor(trigger);
26
+ if (panel) this.#setExpanded(trigger, panel, open);
27
+ }
28
+ }
29
+ /** Reflects one header/panel pair's state through `aria-expanded` + `hidden`. */
30
+ #setExpanded(trigger, panel, open) {
31
+ trigger.setAttribute("aria-expanded", open ? "true" : "false");
32
+ panel.hidden = !open;
33
+ }
34
+ /**
35
+ * Moves focus between headers per the APG keyboard model, skipping any header
36
+ * that is hidden or nested in a hidden subtree. A consumer may hide whole
37
+ * sections (e.g. an `stimeo--filter` that collapses empty groups), and an
38
+ * unperceivable header must never become an arrow-key target — otherwise
39
+ * `.focus()` lands on nothing and navigation appears to stall.
40
+ */
41
+ onKeydown(event) {
42
+ const current = event.currentTarget;
43
+ if (this.triggerTargets.indexOf(current) === -1) return;
44
+ const navigable = this.triggerTargets.filter((trigger) => trigger.closest("[hidden]") === null);
45
+ const here = navigable.indexOf(current);
46
+ if (here === -1) return;
47
+ let next;
48
+ switch (event.key) {
49
+ case "ArrowDown":
50
+ next = navigable[(here + 1) % navigable.length];
51
+ break;
52
+ case "ArrowUp":
53
+ next = navigable[(here - 1 + navigable.length) % navigable.length];
54
+ break;
55
+ case "Home":
56
+ next = navigable[0];
57
+ break;
58
+ case "End":
59
+ next = navigable[navigable.length - 1];
60
+ break;
61
+ default:
62
+ return;
63
+ }
64
+ event.preventDefault();
65
+ next?.focus();
66
+ }
67
+ /** Resolves the panel a header controls via its `aria-controls` reference. */
68
+ #panelFor(trigger) {
69
+ const id = trigger.getAttribute("aria-controls");
70
+ return id ? this.panelTargets.find((panel) => panel.id === id) ?? null : null;
71
+ }
72
+ };
73
+
74
+ export { AccordionController };
75
+ //# sourceMappingURL=accordion_controller.js.map
76
+ //# sourceMappingURL=accordion_controller.js.map
@@ -0,0 +1,184 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ // src/controllers/announcer_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
+
58
+ // src/controllers/announcer_controller.ts
59
+ var AnnouncerController = class extends Controller {
60
+ static targets = ["polite", "assertive"];
61
+ static values = {
62
+ clearAfter: { type: Number, default: 1e3 },
63
+ dedupeReannounce: { type: Boolean, default: true }
64
+ };
65
+ static actions = ["announce"];
66
+ /** Clear/re-announce timers; one `clearAll()` in disconnect tears them all down. */
67
+ #timers = new SafeTimeout();
68
+ /** Live regions generated to stand in for absent targets, for teardown. */
69
+ #generated = /* @__PURE__ */ new Map();
70
+ /**
71
+ * Guards against handling the same CustomEvent twice. An event dispatched on
72
+ * the controller element with `bubbles: true` reaches both the element and the
73
+ * `window` listener; this WeakSet ensures it announces only once.
74
+ */
75
+ #handled = /* @__PURE__ */ new WeakSet();
76
+ /** Receives programmatic announcements at the element or bubbled to `window`. */
77
+ #onAnnounceEvent = (event) => {
78
+ if (this.#handled.has(event)) return;
79
+ this.#handled.add(event);
80
+ const detail = event.detail;
81
+ const message = this.#messageFromDetail(detail);
82
+ if (!message) return;
83
+ this.#announce(message, this.#assertiveFromDetail(detail));
84
+ };
85
+ connect() {
86
+ this.element.addEventListener("stimeo--announcer:announce", this.#onAnnounceEvent);
87
+ window.addEventListener("stimeo--announcer:announce", this.#onAnnounceEvent);
88
+ }
89
+ disconnect() {
90
+ this.element.removeEventListener("stimeo--announcer:announce", this.#onAnnounceEvent);
91
+ window.removeEventListener("stimeo--announcer:announce", this.#onAnnounceEvent);
92
+ this.#timers.clearAll();
93
+ for (const region of this.#generated.values()) {
94
+ region.remove();
95
+ }
96
+ this.#generated.clear();
97
+ }
98
+ /**
99
+ * Announces a message. Reads the text from a Stimulus action param
100
+ * (`message`, plus optional `assertive`) for attribute-only triggers, falling
101
+ * back to a CustomEvent `detail` when the same handler is wired to an event.
102
+ * An empty/non-string message is ignored so untrusted payloads cannot blank
103
+ * the region.
104
+ */
105
+ announce(event) {
106
+ const params = event.params;
107
+ const fromParam = params?.message;
108
+ const message = typeof fromParam === "string" && fromParam.length > 0 ? fromParam : this.#messageFromDetail(event.detail);
109
+ if (!message) return;
110
+ const assertive = params?.assertive === true || this.#assertiveFromDetail(event.detail);
111
+ this.#announce(message, assertive);
112
+ }
113
+ /**
114
+ * Writes `message` into the matching live region and schedules its clear.
115
+ *
116
+ * When the region already holds the same text, an aria-atomic region is not
117
+ * re-read by assistive tech (the node did not change). If `dedupeReannounce`
118
+ * is on, the text is cleared and re-set on a later task so the mutation is
119
+ * observed and announced again.
120
+ */
121
+ #announce(message, assertive) {
122
+ const region = this.#regionFor(assertive ? "assertive" : "polite");
123
+ if (this.dedupeReannounceValue && region.textContent === message) {
124
+ region.textContent = "";
125
+ this.#timers.set(() => {
126
+ region.textContent = message;
127
+ this.#scheduleClear(region, message);
128
+ }, 0);
129
+ return;
130
+ }
131
+ region.textContent = message;
132
+ this.#scheduleClear(region, message);
133
+ }
134
+ /** Clears the region after `clearAfter` ms, unless a newer message replaced it. */
135
+ #scheduleClear(region, message) {
136
+ if (this.clearAfterValue <= 0) return;
137
+ this.#timers.set(() => {
138
+ if (region.textContent === message) region.textContent = "";
139
+ }, this.clearAfterValue);
140
+ }
141
+ /** Resolves the live region for a politeness level, generating it if absent. */
142
+ #regionFor(level) {
143
+ if (level === "assertive" && this.hasAssertiveTarget) return this.assertiveTarget;
144
+ if (level === "polite" && this.hasPoliteTarget) return this.politeTarget;
145
+ const existing = this.#generated.get(level);
146
+ if (existing) return existing;
147
+ const region = document.createElement("div");
148
+ region.setAttribute("aria-live", level);
149
+ region.setAttribute("aria-atomic", "true");
150
+ visuallyHide(region);
151
+ this.element.appendChild(region);
152
+ this.#generated.set(level, region);
153
+ return region;
154
+ }
155
+ /** Extracts a non-empty string `message` from a CustomEvent detail, else null. */
156
+ #messageFromDetail(detail) {
157
+ if (detail && typeof detail === "object" && "message" in detail) {
158
+ const value = detail.message;
159
+ if (typeof value === "string" && value.length > 0) return value;
160
+ }
161
+ return null;
162
+ }
163
+ /** Reads an `assertive === true` flag from a CustomEvent detail (default polite). */
164
+ #assertiveFromDetail(detail) {
165
+ return !!detail && typeof detail === "object" && detail.assertive === true;
166
+ }
167
+ };
168
+ function visuallyHide(node) {
169
+ const { style } = node;
170
+ style.position = "absolute";
171
+ style.width = "1px";
172
+ style.height = "1px";
173
+ style.margin = "-1px";
174
+ style.padding = "0";
175
+ style.border = "0";
176
+ style.overflow = "hidden";
177
+ style.clip = "rect(0 0 0 0)";
178
+ style.clipPath = "inset(50%)";
179
+ style.whiteSpace = "nowrap";
180
+ }
181
+
182
+ export { AnnouncerController, visuallyHide };
183
+ //# sourceMappingURL=announcer_controller.js.map
184
+ //# sourceMappingURL=announcer_controller.js.map
@@ -0,0 +1,36 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ // src/controllers/aspect_ratio_controller.ts
4
+ var AspectRatioController = class extends Controller {
5
+ static targets = ["content"];
6
+ static values = {
7
+ ratio: { type: String, default: "1/1" }
8
+ };
9
+ /** Applies the ratio on connect and whenever the value changes. */
10
+ ratioValueChanged() {
11
+ this.element.style.setProperty("--stimeo-aspect-ratio", this.#normalizeRatio(this.ratioValue));
12
+ }
13
+ /**
14
+ * Normalizes a ratio string to a valid CSS `<ratio>`:
15
+ * - `"16/9"` / `"16 / 9"` → `"16 / 9"` (both parts must be positive numbers)
16
+ * - `"1.5"` → `"1.5"` (a bare positive number)
17
+ * - anything else → `"1 / 1"` (the default), so the custom property is always valid.
18
+ */
19
+ #normalizeRatio(raw) {
20
+ const value = raw.trim();
21
+ if (value.includes("/")) {
22
+ const [w, h] = value.split("/").map((part) => Number.parseFloat(part.trim()));
23
+ if (this.#isPositive(w) && this.#isPositive(h)) return `${w} / ${h}`;
24
+ return "1 / 1";
25
+ }
26
+ const single = Number.parseFloat(value);
27
+ return this.#isPositive(single) ? String(single) : "1 / 1";
28
+ }
29
+ #isPositive(value) {
30
+ return value !== void 0 && Number.isFinite(value) && value > 0;
31
+ }
32
+ };
33
+
34
+ export { AspectRatioController };
35
+ //# sourceMappingURL=aspect_ratio_controller.js.map
36
+ //# sourceMappingURL=aspect_ratio_controller.js.map
@@ -0,0 +1,147 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ // src/controllers/auto_submit_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
+
58
+ // src/controllers/auto_submit_controller.ts
59
+ var AutoSubmitController = class extends Controller {
60
+ static targets = ["form"];
61
+ static values = {
62
+ debounce: { type: Number, default: 300 },
63
+ on: { type: String, default: "input change" },
64
+ announce: { type: Boolean, default: false },
65
+ message: { type: String, default: "" }
66
+ };
67
+ static actions = ["submit"];
68
+ static events = ["submit", "done"];
69
+ /** Debounce timer registry; one `clearAll()` in disconnect tears it down. */
70
+ #timers = new SafeTimeout();
71
+ /** Id of the pending debounce timer, so a new keystroke can reset it. */
72
+ #pendingId = 0;
73
+ /** Clears `aria-busy` and emits completion once Turbo finishes the submit. */
74
+ #onSubmitEnd = () => {
75
+ this.#form.removeAttribute("aria-busy");
76
+ const message = this.messageValue;
77
+ this.dispatch("done", { detail: { message: message || void 0 } });
78
+ if (this.announceValue && message) {
79
+ window.dispatchEvent(new CustomEvent("stimeo--announcer:announce", { detail: { message } }));
80
+ }
81
+ };
82
+ /** True while an IME composition is in progress on one of the form's fields. */
83
+ #composing = false;
84
+ /** Marks composition active so `input` events mid-conversion don't submit. */
85
+ #onCompositionStart = () => {
86
+ this.#composing = true;
87
+ };
88
+ /**
89
+ * Composition finished (the IME conversion is confirmed): clear the flag and
90
+ * schedule a submit as if `input` fired, so the settled text triggers a submit
91
+ * even on browsers whose post-composition `input` still reads `isComposing`.
92
+ */
93
+ #onCompositionEnd = (event) => {
94
+ this.#composing = false;
95
+ if (this.#triggers("input")) this.#schedule(event.target ?? null);
96
+ };
97
+ connect() {
98
+ this.#form.addEventListener("turbo:submit-end", this.#onSubmitEnd);
99
+ this.#form.addEventListener("compositionstart", this.#onCompositionStart);
100
+ this.#form.addEventListener("compositionend", this.#onCompositionEnd);
101
+ }
102
+ disconnect() {
103
+ this.#timers.clearAll();
104
+ this.#pendingId = 0;
105
+ this.#composing = false;
106
+ this.#form.removeAttribute("data-auto-submit-pending");
107
+ this.#form.removeEventListener("turbo:submit-end", this.#onSubmitEnd);
108
+ this.#form.removeEventListener("compositionstart", this.#onCompositionStart);
109
+ this.#form.removeEventListener("compositionend", this.#onCompositionEnd);
110
+ }
111
+ /**
112
+ * Schedules a debounced submit. Wired to `input`/`change`; the `on` value is an
113
+ * allowlist so a configured subset (e.g. only `change`) is honored even when both
114
+ * are bound in markup. Coalesces rapid events into a single `requestSubmit`.
115
+ */
116
+ submit(event) {
117
+ if (!this.#triggers(event.type)) return;
118
+ if (event.type === "input" && (this.#composing || event.isComposing)) return;
119
+ this.#schedule(event.target ?? null);
120
+ }
121
+ /** Schedules (and coalesces) the debounced submit for the given trigger. */
122
+ #schedule(trigger) {
123
+ this.#form.setAttribute("data-auto-submit-pending", "true");
124
+ if (this.#pendingId) this.#timers.clear(this.#pendingId);
125
+ this.#pendingId = this.#timers.set(() => {
126
+ this.#pendingId = 0;
127
+ this.#form.removeAttribute("data-auto-submit-pending");
128
+ this.dispatch("submit", { detail: { trigger } });
129
+ if (this.#form.checkValidity()) {
130
+ this.#form.setAttribute("aria-busy", "true");
131
+ }
132
+ this.#form.requestSubmit();
133
+ }, this.debounceValue);
134
+ }
135
+ /** Resolves the form element (explicit `form` target, else the controller root). */
136
+ get #form() {
137
+ return this.hasFormTarget ? this.formTarget : this.element;
138
+ }
139
+ /** Whether `type` is one of the whitespace-separated event types in `on`. */
140
+ #triggers(type) {
141
+ return this.onValue.split(/\s+/).filter(Boolean).includes(type);
142
+ }
143
+ };
144
+
145
+ export { AutoSubmitController };
146
+ //# sourceMappingURL=auto_submit_controller.js.map
147
+ //# sourceMappingURL=auto_submit_controller.js.map
@@ -0,0 +1,66 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ // src/controllers/avatar_controller.ts
4
+ var AvatarController = class extends Controller {
5
+ static targets = ["image", "fallback"];
6
+ static values = {
7
+ src: { type: String, default: "" }
8
+ };
9
+ static actions = ["onError", "onLoad"];
10
+ static events = ["error"];
11
+ connect() {
12
+ if (!this.hasImageTarget) {
13
+ this.#showFallback();
14
+ return;
15
+ }
16
+ if (this.srcValue) {
17
+ this.imageTarget.src = this.srcValue;
18
+ }
19
+ const src = this.imageTarget.getAttribute("src");
20
+ if (!src) {
21
+ this.#showFallback();
22
+ return;
23
+ }
24
+ if (this.imageTarget.complete && this.imageTarget.naturalWidth > 0) {
25
+ this.#showImage();
26
+ return;
27
+ }
28
+ if (this.imageTarget.complete && this.imageTarget.naturalWidth === 0 && src) {
29
+ this.onError();
30
+ return;
31
+ }
32
+ this.#enterLoading();
33
+ }
34
+ /** Reveals the image once it has loaded successfully. */
35
+ onLoad() {
36
+ this.#showImage();
37
+ }
38
+ /** Swaps to the fallback when the image fails and emits `error`. */
39
+ onError() {
40
+ const src = this.hasImageTarget ? this.imageTarget.getAttribute("src") ?? "" : "";
41
+ this.#showFallback();
42
+ this.dispatch("error", { detail: { src } });
43
+ }
44
+ /** Loading phase: keep the image visible (per markup) while it fetches. */
45
+ #enterLoading() {
46
+ if (this.hasImageTarget) this.imageTarget.hidden = false;
47
+ if (this.hasFallbackTarget) this.fallbackTarget.hidden = true;
48
+ this.element.setAttribute("data-state", "loading");
49
+ }
50
+ /** Loaded phase: image visible, fallback hidden. */
51
+ #showImage() {
52
+ if (this.hasImageTarget) this.imageTarget.hidden = false;
53
+ if (this.hasFallbackTarget) this.fallbackTarget.hidden = true;
54
+ this.element.setAttribute("data-state", "loaded");
55
+ }
56
+ /** Error / no-src phase: fallback visible, image hidden. */
57
+ #showFallback() {
58
+ if (this.hasImageTarget) this.imageTarget.hidden = true;
59
+ if (this.hasFallbackTarget) this.fallbackTarget.hidden = false;
60
+ this.element.setAttribute("data-state", "error");
61
+ }
62
+ };
63
+
64
+ export { AvatarController };
65
+ //# sourceMappingURL=avatar_controller.js.map
66
+ //# sourceMappingURL=avatar_controller.js.map