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,221 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ // src/controllers/flash_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/flash_controller.ts
59
+ var ASSERTIVE_TYPES = /* @__PURE__ */ new Set(["alert", "error"]);
60
+ var MESSAGE_SELECTOR = '[data-stimeo--flash-target="message"]';
61
+ var FlashController = class extends Controller {
62
+ static targets = ["region", "message"];
63
+ static values = {
64
+ duration: { type: Number, default: 5e3 },
65
+ pauseOnHover: { type: Boolean, default: true },
66
+ max: { type: Number, default: 0 }
67
+ };
68
+ static actions = ["dismiss"];
69
+ static events = ["show", "dismiss"];
70
+ #timers = new SafeTimeout();
71
+ #observer = null;
72
+ /** Auto-dismiss timer state keyed by message element. */
73
+ #state = /* @__PURE__ */ new Map();
74
+ /** Messages already processed, in insertion order, to enforce `max` and avoid double work. */
75
+ #order = [];
76
+ #onEnter = (event) => this.#pause(event.currentTarget);
77
+ #onLeave = (event) => this.#resume(event.currentTarget);
78
+ connect() {
79
+ if (!this.hasRegionTarget) return;
80
+ for (const message of this.messageTargets) {
81
+ this.#process(message, true);
82
+ }
83
+ if (typeof MutationObserver !== "undefined") {
84
+ this.#observer = new MutationObserver((mutations) => this.#onMutations(mutations));
85
+ this.#observer.observe(this.regionTarget, { childList: true, subtree: true });
86
+ }
87
+ }
88
+ disconnect() {
89
+ this.#observer?.disconnect();
90
+ this.#observer = null;
91
+ this.#timers.clearAll();
92
+ for (const message of this.#order) this.#unbindPause(message);
93
+ this.#state.clear();
94
+ this.#order.length = 0;
95
+ }
96
+ /**
97
+ * Pause-on-hover/focus listeners, bound and unbound as a pair so the two sides
98
+ * stay in sync. Binding is gated by `pauseOnHover`; unbinding is unconditional
99
+ * and idempotent (a no-op when nothing was bound), which keeps teardown correct
100
+ * even if `pauseOnHover` were ever toggled during a message's life.
101
+ */
102
+ #bindPause(message) {
103
+ if (!this.pauseOnHoverValue) return;
104
+ message.addEventListener("mouseenter", this.#onEnter);
105
+ message.addEventListener("mouseleave", this.#onLeave);
106
+ message.addEventListener("focusin", this.#onEnter);
107
+ message.addEventListener("focusout", this.#onLeave);
108
+ }
109
+ #unbindPause(message) {
110
+ message.removeEventListener("mouseenter", this.#onEnter);
111
+ message.removeEventListener("mouseleave", this.#onLeave);
112
+ message.removeEventListener("focusin", this.#onEnter);
113
+ message.removeEventListener("focusout", this.#onLeave);
114
+ }
115
+ /** Dismisses the flash whose close control fired the event. */
116
+ dismiss(event) {
117
+ const target = event.currentTarget || event.target;
118
+ const message = target?.closest(MESSAGE_SELECTOR);
119
+ if (message) this.#beginDismiss(message, "user");
120
+ }
121
+ /** Processes messages added after connect (Turbo Stream); their own role announces them. */
122
+ #onMutations(mutations) {
123
+ for (const mutation of mutations) {
124
+ for (const node of mutation.addedNodes) {
125
+ if (!(node instanceof HTMLElement)) continue;
126
+ if (node.matches(MESSAGE_SELECTOR)) this.#process(node, false);
127
+ for (const message of node.querySelectorAll(MESSAGE_SELECTOR)) {
128
+ this.#process(message, false);
129
+ }
130
+ }
131
+ }
132
+ }
133
+ /**
134
+ * Applies role/state, wires pause listeners, schedules auto-dismiss, and either
135
+ * bridges to the Announcer (`bridge`, for initial flashes) or leaves the message's
136
+ * own role to do the announcing (dynamic inserts). Idempotent per message.
137
+ */
138
+ #process(message, bridge) {
139
+ if (this.#state.has(message) || this.#order.includes(message)) return;
140
+ const type = message.getAttribute("data-flash-type") ?? "";
141
+ const assertive = ASSERTIVE_TYPES.has(type);
142
+ if (!message.hasAttribute("role")) {
143
+ message.setAttribute("role", assertive ? "alert" : "status");
144
+ }
145
+ message.setAttribute("data-flash-state", "visible");
146
+ this.#order.push(message);
147
+ this.#bindPause(message);
148
+ const text = message.textContent?.trim() ?? "";
149
+ this.dispatch("show", { target: message, detail: { type, message: text } });
150
+ if (bridge && text) {
151
+ window.dispatchEvent(
152
+ new CustomEvent("stimeo--announcer:announce", { detail: { message: text, assertive } })
153
+ );
154
+ }
155
+ this.#startTimer(message);
156
+ this.#enforceMax();
157
+ }
158
+ /** Removes the oldest visible flashes once the count exceeds `max` (0 = unlimited). */
159
+ #enforceMax() {
160
+ if (this.maxValue <= 0) return;
161
+ while (this.#order.length > this.maxValue) {
162
+ const oldest = this.#order[0];
163
+ if (!oldest) break;
164
+ this.#beginDismiss(oldest, "limit");
165
+ }
166
+ }
167
+ #startTimer(message, duration = this.durationValue) {
168
+ if (duration <= 0) return;
169
+ const existing = this.#state.get(message);
170
+ if (existing?.id) this.#timers.clear(existing.id);
171
+ const id = this.#timers.set(() => this.#beginDismiss(message, "timeout"), duration);
172
+ this.#state.set(message, { id, startedAt: Date.now(), remaining: duration });
173
+ }
174
+ /** Pauses a message's auto-dismiss, banking the time left (hover/focus, WCAG 2.2.1). */
175
+ #pause(message) {
176
+ const timer = this.#state.get(message);
177
+ if (!timer || timer.id === 0) return;
178
+ this.#timers.clear(timer.id);
179
+ const remaining = Math.max(0, timer.remaining - (Date.now() - timer.startedAt));
180
+ this.#state.set(message, { id: 0, startedAt: 0, remaining });
181
+ }
182
+ /** Resumes a paused message's auto-dismiss with the banked time. */
183
+ #resume(message) {
184
+ const timer = this.#state.get(message);
185
+ if (!timer) return;
186
+ if (timer.id !== 0 || timer.remaining <= 0) return;
187
+ this.#startTimer(message, timer.remaining);
188
+ }
189
+ /** Marks a message leaving, then removes it after its CSS transition and emits dismiss. */
190
+ #beginDismiss(message, reason) {
191
+ const timer = this.#state.get(message);
192
+ if (timer?.id) this.#timers.clear(timer.id);
193
+ this.#state.delete(message);
194
+ const index = this.#order.indexOf(message);
195
+ if (index !== -1) this.#order.splice(index, 1);
196
+ message.setAttribute("data-flash-state", "leaving");
197
+ const finalize = () => {
198
+ this.#unbindPause(message);
199
+ message.remove();
200
+ this.dispatch("dismiss", { detail: { element: message, reason } });
201
+ };
202
+ const transition = this.#transitionMs(message);
203
+ if (transition > 0) {
204
+ this.#timers.set(finalize, transition);
205
+ } else {
206
+ finalize();
207
+ }
208
+ }
209
+ /** First `transition-duration` of `el` in ms (0 when none / unsupported). */
210
+ #transitionMs(el) {
211
+ if (typeof window.getComputedStyle !== "function") return 0;
212
+ const first = window.getComputedStyle(el).transitionDuration.split(",")[0]?.trim() ?? "";
213
+ const amount = Number.parseFloat(first);
214
+ if (Number.isNaN(amount)) return 0;
215
+ return first.endsWith("ms") ? amount : amount * 1e3;
216
+ }
217
+ };
218
+
219
+ export { FlashController };
220
+ //# sourceMappingURL=flash_controller.js.map
221
+ //# sourceMappingURL=flash_controller.js.map
@@ -0,0 +1,216 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ // src/controllers/focus_controller.ts
4
+
5
+ // src/utils/focus_trap.ts
6
+ var FOCUSABLE = 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
7
+ var FocusTrap = class {
8
+ /** The element focused before activation, restored on deactivation. */
9
+ #previouslyFocused = null;
10
+ /** The body's inline `overflow` before locking, restored on deactivation. */
11
+ #previousBodyOverflow = "";
12
+ /** Whether scroll was locked this activation (so it is only restored if applied). */
13
+ #scrollLocked = false;
14
+ /** Background siblings made `inert` while active, restored on deactivation. */
15
+ #inertedSiblings = [];
16
+ /** Whether the modal side effects are currently applied. */
17
+ #activeState = false;
18
+ /** Returns the trapped element; called on every operation for the live target. */
19
+ #getContainer;
20
+ /** Closing/focus hooks; see {@link FocusTrapOptions}. */
21
+ #options;
22
+ /**
23
+ * @param getContainer - Returns the trapped element. Called on every operation
24
+ * so the live target is always used.
25
+ * @param options - Closing/focus hooks; see {@link FocusTrapOptions}.
26
+ */
27
+ constructor(getContainer, options = {}) {
28
+ this.#getContainer = getContainer;
29
+ this.#options = options;
30
+ }
31
+ /** Whether the trap is currently active. */
32
+ get active() {
33
+ return this.#activeState;
34
+ }
35
+ /**
36
+ * Applies the trap: records the current focus, optionally locks background scroll
37
+ * and makes background siblings `inert`, listens for `Tab`/`Escape`, and (unless
38
+ * `autoFocus` is off) moves focus inside. No-ops if already active.
39
+ */
40
+ activate() {
41
+ if (this.#activeState) return;
42
+ this.#activeState = true;
43
+ const active = document.activeElement;
44
+ this.#previouslyFocused = active instanceof HTMLElement && active !== document.body ? active : null;
45
+ if (this.#flag(this.#options.lockScroll, true)) {
46
+ this.#previousBodyOverflow = document.body.style.overflow;
47
+ document.body.style.overflow = "hidden";
48
+ this.#scrollLocked = true;
49
+ }
50
+ if (this.#flag(this.#options.isolate, true)) this.#isolateBackground();
51
+ document.addEventListener("keydown", this.#onKeydown);
52
+ if (this.#flag(this.#options.autoFocus, true)) this.#focusInitial();
53
+ }
54
+ /**
55
+ * Reverts every side effect applied by {@link activate}. No-ops if inactive, so
56
+ * a controller can call it defensively from both `close()` and `disconnect()`.
57
+ *
58
+ * @param restoreFocus - Move focus back to the opener (default `true`). Pass
59
+ * `false` on teardown (`disconnect`), where yanking focus is undesirable.
60
+ */
61
+ deactivate({ restoreFocus = true } = {}) {
62
+ if (!this.#activeState) return;
63
+ this.#activeState = false;
64
+ document.removeEventListener("keydown", this.#onKeydown);
65
+ if (this.#scrollLocked) {
66
+ document.body.style.overflow = this.#previousBodyOverflow;
67
+ this.#scrollLocked = false;
68
+ }
69
+ this.#releaseBackground();
70
+ if (restoreFocus) {
71
+ const target = this.#previouslyFocused ?? this.#options.fallbackFocus?.() ?? null;
72
+ target?.focus();
73
+ }
74
+ }
75
+ /** Resolves a boolean-or-getter option, defaulting when it was not provided. */
76
+ #flag(option, fallback) {
77
+ if (option === void 0) return fallback;
78
+ return typeof option === "function" ? option() : option;
79
+ }
80
+ /** Handles `Escape` (delegated) and `Tab` (focus trap) while active. */
81
+ #onKeydown = (event) => {
82
+ if (event.key === "Escape") {
83
+ if (this.#options.onEscape) {
84
+ event.preventDefault();
85
+ this.#options.onEscape();
86
+ }
87
+ return;
88
+ }
89
+ if (event.key === "Tab") this.#trapTab(event);
90
+ };
91
+ /** Keeps `Tab` focus cycling within the container's focusable elements. */
92
+ #trapTab(event) {
93
+ const focusable = this.#focusableElements();
94
+ if (focusable.length === 0) {
95
+ event.preventDefault();
96
+ return;
97
+ }
98
+ const first = focusable[0];
99
+ const last = focusable[focusable.length - 1];
100
+ const active = document.activeElement;
101
+ if (!(active instanceof Node) || !this.#getContainer().contains(active)) {
102
+ event.preventDefault();
103
+ first?.focus();
104
+ return;
105
+ }
106
+ if (event.shiftKey && active === first) {
107
+ event.preventDefault();
108
+ last?.focus();
109
+ } else if (!event.shiftKey && active === last) {
110
+ event.preventDefault();
111
+ first?.focus();
112
+ }
113
+ }
114
+ /**
115
+ * Marks every element outside the container's subtree as `inert` so background
116
+ * content cannot be focused or reached by assistive technology, honoring the
117
+ * `aria-modal="true"` contract. An element that was *already* `inert` is left
118
+ * untracked so `#releaseBackground` does not wrongly clear it.
119
+ */
120
+ #isolateBackground() {
121
+ const container = this.#getContainer();
122
+ this.#inertedSiblings = [];
123
+ for (const sibling of Array.from(document.body.children)) {
124
+ if (!(sibling instanceof HTMLElement)) continue;
125
+ if (sibling.contains(container) || sibling.inert) continue;
126
+ sibling.inert = true;
127
+ this.#inertedSiblings.push(sibling);
128
+ }
129
+ }
130
+ /** Reverts the `inert` flags applied by `#isolateBackground`. */
131
+ #releaseBackground() {
132
+ for (const sibling of this.#inertedSiblings) {
133
+ sibling.inert = false;
134
+ }
135
+ this.#inertedSiblings = [];
136
+ }
137
+ /** Moves focus to the initial target, the first focusable, or the container. */
138
+ #focusInitial() {
139
+ const preferred = this.#options.initialFocus?.();
140
+ if (preferred) {
141
+ preferred.focus();
142
+ return;
143
+ }
144
+ const focusable = this.#focusableElements();
145
+ if (focusable[0]) {
146
+ focusable[0].focus();
147
+ return;
148
+ }
149
+ const container = this.#getContainer();
150
+ container.tabIndex = -1;
151
+ container.focus();
152
+ }
153
+ /** Collects the container's currently focusable descendants in DOM order. */
154
+ #focusableElements() {
155
+ return Array.from(this.#getContainer().querySelectorAll(FOCUSABLE)).filter(
156
+ (el) => !el.hidden
157
+ );
158
+ }
159
+ };
160
+
161
+ // src/controllers/focus_controller.ts
162
+ var FocusController = class extends Controller {
163
+ static targets = ["initial"];
164
+ static values = {
165
+ trap: { type: Boolean, default: false },
166
+ auto: { type: Boolean, default: true },
167
+ restore: { type: Boolean, default: true },
168
+ inert: { type: Boolean, default: false }
169
+ };
170
+ static actions = ["activate", "deactivate"];
171
+ static events = ["activate", "deactivate"];
172
+ #trap = new FocusTrap(() => this.element, {
173
+ // A focus scope is not a modal: never lock scroll, and only isolate the background
174
+ // when `inert` is requested. `auto` gates the initial focus move; Escape releases.
175
+ lockScroll: false,
176
+ isolate: () => this.inertValue,
177
+ autoFocus: () => this.autoValue,
178
+ initialFocus: () => this.hasInitialTarget ? this.initialTarget : null,
179
+ onEscape: () => this.deactivate()
180
+ });
181
+ /** Stimulus drives activation from the `trap` value (also fires on connect). */
182
+ trapValueChanged() {
183
+ if (this.trapValue) this.#activate();
184
+ else this.#deactivate();
185
+ }
186
+ disconnect() {
187
+ this.#trap.deactivate({ restoreFocus: false });
188
+ this.element.removeAttribute("data-focus-trapped");
189
+ }
190
+ /** Turns the trap on. Acts synchronously and keeps the `trap` value in sync. */
191
+ activate() {
192
+ this.trapValue = true;
193
+ this.#activate();
194
+ }
195
+ /** Turns the trap off (also wired to Escape). */
196
+ deactivate() {
197
+ this.trapValue = false;
198
+ this.#deactivate();
199
+ }
200
+ #activate() {
201
+ if (this.#trap.active) return;
202
+ this.#trap.activate();
203
+ this.element.setAttribute("data-focus-trapped", "true");
204
+ this.dispatch("activate", { detail: {} });
205
+ }
206
+ #deactivate() {
207
+ if (!this.#trap.active) return;
208
+ this.#trap.deactivate({ restoreFocus: this.restoreValue });
209
+ this.element.removeAttribute("data-focus-trapped");
210
+ this.dispatch("deactivate", { detail: {} });
211
+ }
212
+ };
213
+
214
+ export { FocusController };
215
+ //# sourceMappingURL=focus_controller.js.map
216
+ //# sourceMappingURL=focus_controller.js.map
@@ -0,0 +1,154 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ // src/controllers/form_field_controller.ts
4
+
5
+ // src/utils/aria_ids.ts
6
+ var counter = 0;
7
+ function uniqueId(prefix = "stimeo") {
8
+ let candidate;
9
+ do {
10
+ counter += 1;
11
+ candidate = `${prefix}-${counter}`;
12
+ } while (typeof document !== "undefined" && document.getElementById(candidate) !== null);
13
+ return candidate;
14
+ }
15
+ function ensureId(element, prefix = "stimeo") {
16
+ if (element.id) return element.id;
17
+ const id = uniqueId(prefix);
18
+ element.id = id;
19
+ return id;
20
+ }
21
+
22
+ // src/controllers/form_field_controller.ts
23
+ var FormFieldController = class _FormFieldController extends Controller {
24
+ static targets = ["control", "description", "error"];
25
+ static values = {
26
+ focusOnError: { type: Boolean, default: false }
27
+ };
28
+ static actions = ["clearError", "setError"];
29
+ static events = ["validate"];
30
+ /** Root attribute (CSS hook) reflecting the invalid state. */
31
+ static #INVALID_ATTR = "data-stimeo--form-field-invalid";
32
+ /**
33
+ * `aria-describedby` tokens the consumer set on the control that the
34
+ * controller does not own. Captured once so composition never clobbers them.
35
+ */
36
+ #baseDescribedBy = [];
37
+ /** Wires ids, captures consumer tokens, and reflects any initial error state. */
38
+ connect() {
39
+ for (const description of this.descriptionTargets) {
40
+ ensureId(description, "stimeo--form-field-desc");
41
+ }
42
+ for (const error of this.errorTargets) {
43
+ ensureId(error, "stimeo--form-field-error");
44
+ }
45
+ this.#baseDescribedBy = this.#externalDescribedByTokens();
46
+ this.#reflect();
47
+ }
48
+ /**
49
+ * Marks the field invalid and shows the error message. Bound via `data-action`
50
+ * (`#setError`) or callable directly.
51
+ *
52
+ * @param arg - Either the message string, or the action event whose
53
+ * `data-stimeo--form-field-message-param` supplies it. When no message is
54
+ * resolvable, any already-populated error targets are simply (re)shown.
55
+ */
56
+ setError(arg) {
57
+ const message = this.#resolveMessage(arg);
58
+ if (message !== null && this.hasErrorTarget) {
59
+ this.errorTargets[0]?.replaceChildren(document.createTextNode(message));
60
+ }
61
+ for (const error of this.errorTargets) {
62
+ error.hidden = (error.textContent ?? "").trim() === "";
63
+ }
64
+ this.#reflect(true);
65
+ this.dispatch("validate", { detail: { valid: false, message: this.#shownMessage() } });
66
+ if (this.focusOnErrorValue && this.hasControlTarget) {
67
+ this.controlTarget.focus();
68
+ }
69
+ }
70
+ /**
71
+ * Clears the error: empties and hides every error target and marks the field
72
+ * valid. Bound via `data-action` (`#clearError`) or callable directly.
73
+ */
74
+ clearError() {
75
+ for (const error of this.errorTargets) {
76
+ error.replaceChildren();
77
+ error.hidden = true;
78
+ }
79
+ this.#reflect();
80
+ this.dispatch("validate", { detail: { valid: true, message: "" } });
81
+ }
82
+ /**
83
+ * Synchronizes the control's ARIA wiring and the root CSS hook from the current
84
+ * error targets. Idempotent, so it is safe to call on connect and after any
85
+ * change (and survives Turbo morphing).
86
+ *
87
+ * @param force - When `true`, the field is marked invalid regardless of whether
88
+ * a (visible, non-empty) error region exists. {@link setError} passes this so
89
+ * the invalid state holds even with no error target; derivation from the DOM
90
+ * (connect / {@link clearError}) leaves it `false`.
91
+ */
92
+ #reflect(force = false) {
93
+ const shown = this.#shownErrors();
94
+ const invalid = force || shown.length > 0;
95
+ if (invalid) {
96
+ this.element.setAttribute(_FormFieldController.#INVALID_ATTR, "");
97
+ } else {
98
+ this.element.removeAttribute(_FormFieldController.#INVALID_ATTR);
99
+ }
100
+ if (!this.hasControlTarget) return;
101
+ const control = this.controlTarget;
102
+ control.setAttribute("aria-invalid", invalid ? "true" : "false");
103
+ const errorIds = shown.map((error) => error.id);
104
+ const primaryErrorId = errorIds[0];
105
+ if (primaryErrorId) {
106
+ control.setAttribute("aria-errormessage", primaryErrorId);
107
+ } else {
108
+ control.removeAttribute("aria-errormessage");
109
+ }
110
+ const describedBy = [
111
+ ...this.#baseDescribedBy,
112
+ ...this.descriptionTargets.map((description) => description.id),
113
+ ...errorIds
114
+ ];
115
+ if (describedBy.length > 0) {
116
+ control.setAttribute("aria-describedby", describedBy.join(" "));
117
+ } else {
118
+ control.removeAttribute("aria-describedby");
119
+ }
120
+ }
121
+ /** Error targets currently visible and non-empty. */
122
+ #shownErrors() {
123
+ return this.errorTargets.filter(
124
+ (error) => !error.hidden && (error.textContent ?? "").trim() !== ""
125
+ );
126
+ }
127
+ /** Text of the first shown error, for the `validate` event detail. */
128
+ #shownMessage() {
129
+ return (this.#shownErrors()[0]?.textContent ?? "").trim();
130
+ }
131
+ /** Resolves a message from a string argument or an action event's params. */
132
+ #resolveMessage(arg) {
133
+ if (typeof arg === "string") return arg;
134
+ const message = arg?.params?.message;
135
+ return typeof message === "string" ? message : null;
136
+ }
137
+ /**
138
+ * Tokens already in the control's `aria-describedby` that are not ids of this
139
+ * controller's own description/error targets.
140
+ */
141
+ #externalDescribedByTokens() {
142
+ if (!this.hasControlTarget) return [];
143
+ const owned = /* @__PURE__ */ new Set([
144
+ ...this.descriptionTargets.map((description) => description.id),
145
+ ...this.errorTargets.map((error) => error.id)
146
+ ]);
147
+ const existing = this.controlTarget.getAttribute("aria-describedby") ?? "";
148
+ return existing.split(/\s+/).filter((token) => token.length > 0 && !owned.has(token));
149
+ }
150
+ };
151
+
152
+ export { FormFieldController };
153
+ //# sourceMappingURL=form_field_controller.js.map
154
+ //# sourceMappingURL=form_field_controller.js.map