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,207 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ // src/controllers/dialog_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/dialog_controller.ts
162
+ var DialogController = class extends Controller {
163
+ static targets = ["trigger", "dialog"];
164
+ static actions = ["close", "closeOnBackdrop", "open"];
165
+ /** Owns the modal side effects; Escape closes, focus falls back to the trigger. */
166
+ #trap = new FocusTrap(() => this.dialogTarget, {
167
+ onEscape: () => this.close(),
168
+ fallbackFocus: () => this.hasTriggerTarget ? this.triggerTarget : null
169
+ });
170
+ /** Starts closed (idempotently reflects the closed state on the markup). */
171
+ connect() {
172
+ if (this.hasDialogTarget) this.dialogTarget.hidden = true;
173
+ }
174
+ /**
175
+ * Reverts the modal side effects (scroll lock, background `inert`, keydown
176
+ * listener) if the controller is torn down while open (e.g. a Turbo navigation
177
+ * replaces the page while the dialog is showing). Focus is not restored on
178
+ * teardown.
179
+ */
180
+ disconnect() {
181
+ this.#trap.deactivate({ restoreFocus: false });
182
+ }
183
+ /** Opens the dialog, traps focus, and locks background scroll. */
184
+ open() {
185
+ if (!this.hasDialogTarget || this.#isOpen) return;
186
+ this.dialogTarget.hidden = false;
187
+ this.#trap.activate();
188
+ }
189
+ /** Closes the dialog, restores scroll, and returns focus to the opener. */
190
+ close() {
191
+ if (!this.hasDialogTarget || !this.#isOpen) return;
192
+ this.dialogTarget.hidden = true;
193
+ this.#trap.deactivate();
194
+ }
195
+ /** Closes when the backdrop (the dialog target itself) is clicked. */
196
+ closeOnBackdrop(event) {
197
+ if (event.target === this.dialogTarget) this.close();
198
+ }
199
+ /** Whether the dialog is currently visible. */
200
+ get #isOpen() {
201
+ return this.hasDialogTarget && !this.dialogTarget.hidden;
202
+ }
203
+ };
204
+
205
+ export { DialogController };
206
+ //# sourceMappingURL=dialog_controller.js.map
207
+ //# sourceMappingURL=dialog_controller.js.map
@@ -0,0 +1,212 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ // src/controllers/direct_upload_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/direct_upload_controller.ts
59
+ var REMOVE_DELAY = 4e3;
60
+ var DirectUploadController = class extends Controller {
61
+ static targets = ["list", "row", "status"];
62
+ static values = {
63
+ announce: { type: Boolean, default: true },
64
+ removeOnDone: { type: Boolean, default: false },
65
+ doneLabel: { type: String, default: "" },
66
+ errorLabel: { type: String, default: "" },
67
+ scope: { type: String, default: "" }
68
+ };
69
+ static events = ["progress", "done", "error"];
70
+ #timeouts = new SafeTimeout();
71
+ #rows = /* @__PURE__ */ new Map();
72
+ #onInitialize = (event) => {
73
+ if (!this.#inScope(event)) return;
74
+ const detail = this.#detail(event);
75
+ this.#rowFor(detail.id, detail.file?.name ?? "");
76
+ };
77
+ #onProgress = (event) => {
78
+ if (!this.#inScope(event)) return;
79
+ const detail = this.#detail(event);
80
+ this.#updateProgress(this.#key(detail.id), Math.round(detail.progress ?? 0));
81
+ };
82
+ #onError = (event) => {
83
+ if (!this.#inScope(event)) return;
84
+ const detail = this.#detail(event);
85
+ this.#fail(this.#key(detail.id), detail.error ?? "");
86
+ };
87
+ #onEnd = (event) => {
88
+ if (!this.#inScope(event)) return;
89
+ const detail = this.#detail(event);
90
+ this.#complete(this.#key(detail.id), detail.file?.name ?? "");
91
+ };
92
+ connect() {
93
+ document.addEventListener("direct-upload:initialize", this.#onInitialize);
94
+ document.addEventListener("direct-upload:progress", this.#onProgress);
95
+ document.addEventListener("direct-upload:error", this.#onError);
96
+ document.addEventListener("direct-upload:end", this.#onEnd);
97
+ }
98
+ disconnect() {
99
+ document.removeEventListener("direct-upload:initialize", this.#onInitialize);
100
+ document.removeEventListener("direct-upload:progress", this.#onProgress);
101
+ document.removeEventListener("direct-upload:error", this.#onError);
102
+ document.removeEventListener("direct-upload:end", this.#onEnd);
103
+ this.#timeouts.clearAll();
104
+ this.#rows.clear();
105
+ }
106
+ /** Updates a row's progress and the aggregate, emitting `progress`. */
107
+ #updateProgress(id, percent) {
108
+ const row = this.#rowFor(id);
109
+ if (row === null) return;
110
+ const clamped = Math.max(0, Math.min(100, percent));
111
+ row.setAttribute("aria-valuenow", String(clamped));
112
+ row.setAttribute("aria-valuetext", `${clamped}%`);
113
+ row.style.setProperty("--stimeo-upload-progress", `${clamped}%`);
114
+ this.#setField(row, "percent", `${clamped}%`);
115
+ this.#syncAggregate();
116
+ this.dispatch("progress", { detail: { id, percent: clamped } });
117
+ }
118
+ /** Marks a row done, announces it, and emits `done`. */
119
+ #complete(id, name = "") {
120
+ const row = this.#rowFor(id, name);
121
+ if (row === null) return;
122
+ row.setAttribute("data-upload-state", "done");
123
+ this.#announce(this.doneLabelValue, row);
124
+ this.dispatch("done", { detail: { id } });
125
+ if (this.removeOnDoneValue) {
126
+ this.#timeouts.set(() => this.#removeRow(id), REMOVE_DELAY);
127
+ }
128
+ }
129
+ /** Marks a row failed, announces it, and emits `error`. */
130
+ #fail(id, error) {
131
+ const row = this.#rowFor(id);
132
+ if (row === null) return;
133
+ row.setAttribute("data-upload-state", "error");
134
+ this.#announce(this.errorLabelValue, row);
135
+ this.dispatch("error", { detail: { id, error } });
136
+ }
137
+ /** Returns an existing row or clones one from the template. */
138
+ #rowFor(id, name) {
139
+ const key = this.#key(id);
140
+ const existing = this.#rows.get(key);
141
+ if (existing !== void 0) return existing;
142
+ if (!this.hasRowTarget || !this.hasListTarget) return null;
143
+ const clone = this.rowTarget.content.firstElementChild?.cloneNode(true);
144
+ if (!(clone instanceof HTMLElement)) return null;
145
+ if (name !== void 0 && name.length > 0) {
146
+ this.#setField(clone, "name", name);
147
+ clone.setAttribute("aria-label", name);
148
+ }
149
+ clone.setAttribute("aria-valuenow", "0");
150
+ clone.setAttribute("data-upload-state", "uploading");
151
+ clone.style.setProperty("--stimeo-upload-progress", "0%");
152
+ this.listTarget.appendChild(clone);
153
+ this.#rows.set(key, clone);
154
+ return clone;
155
+ }
156
+ #removeRow(id) {
157
+ const row = this.#rows.get(id);
158
+ if (row === void 0) return;
159
+ row.remove();
160
+ this.#rows.delete(id);
161
+ this.#syncAggregate();
162
+ }
163
+ /** Reflects the average progress across rows on the controller element. */
164
+ #syncAggregate() {
165
+ if (this.#rows.size === 0) {
166
+ this.element.removeAttribute("data-upload-progress");
167
+ return;
168
+ }
169
+ let total = 0;
170
+ for (const row of this.#rows.values()) {
171
+ total += Number(row.getAttribute("aria-valuenow") ?? "0");
172
+ }
173
+ const overall = Math.round(total / this.#rows.size);
174
+ this.element.setAttribute("data-upload-progress", String(overall));
175
+ this.element.style.setProperty("--stimeo-upload-progress", `${overall}%`);
176
+ }
177
+ /** Writes a consumer label (with `%{name}` substituted) to the status region. */
178
+ #announce(label, row) {
179
+ if (!this.announceValue || !this.hasStatusTarget || label.length === 0) return;
180
+ const name = this.#field(row, "name")?.textContent ?? "";
181
+ this.statusTarget.textContent = label.replace("%{name}", name);
182
+ }
183
+ #field(row, name) {
184
+ return row.querySelector(`[data-field="${name}"]`);
185
+ }
186
+ #setField(row, name, text) {
187
+ const field = this.#field(row, name);
188
+ if (field !== null) field.textContent = text;
189
+ }
190
+ #detail(event) {
191
+ return event.detail ?? {};
192
+ }
193
+ /**
194
+ * Whether an event belongs to this controller. With `scope` set, only events
195
+ * whose target (the file input) sits inside an element matching `scope` are
196
+ * handled, so several upload widgets on one page do not cross-populate. Resolved
197
+ * with `closest()` from the target itself, so the chatty `progress` stream never
198
+ * pays a document-wide query. Empty `scope` handles all.
199
+ */
200
+ #inScope(event) {
201
+ if (this.scopeValue.length === 0) return true;
202
+ const target = event.target;
203
+ return target instanceof Element && target.closest(this.scopeValue) !== null;
204
+ }
205
+ #key(id) {
206
+ return String(id ?? "");
207
+ }
208
+ };
209
+
210
+ export { DirectUploadController };
211
+ //# sourceMappingURL=direct_upload_controller.js.map
212
+ //# sourceMappingURL=direct_upload_controller.js.map
@@ -0,0 +1,128 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ // src/controllers/dirty_form_controller.ts
4
+ var DirtyFormController = class extends Controller {
5
+ static values = {
6
+ message: { type: String, default: "You have unsaved changes that will be lost." },
7
+ confirmBridge: { type: Boolean, default: false }
8
+ };
9
+ static actions = ["markClean"];
10
+ static events = ["dirty", "guard"];
11
+ #baseline = "";
12
+ #dirty = false;
13
+ #beforeunloadBound = false;
14
+ /** True between a form `submit` and its `turbo:submit-end`, suppressing the guard. */
15
+ #submitting = false;
16
+ #onFieldChange = () => {
17
+ this.#submitting = false;
18
+ this.#evaluate();
19
+ };
20
+ #onSubmit = () => {
21
+ this.#submitting = true;
22
+ };
23
+ #onBeforeVisit = (event) => {
24
+ this.#guardVisit(event);
25
+ };
26
+ #onSubmitEnd = (event) => {
27
+ this.#submitting = false;
28
+ const success = event.detail?.success;
29
+ if (success !== false) this.markClean();
30
+ };
31
+ #onBeforeUnload = (event) => {
32
+ if (!this.#dirty || this.#submitting) return;
33
+ event.preventDefault();
34
+ event.returnValue = this.messageValue;
35
+ };
36
+ connect() {
37
+ this.#baseline = this.#serialize();
38
+ this.element.removeAttribute("data-dirty");
39
+ this.element.addEventListener("input", this.#onFieldChange);
40
+ this.element.addEventListener("change", this.#onFieldChange);
41
+ this.element.addEventListener("submit", this.#onSubmit);
42
+ this.element.addEventListener("turbo:submit-end", this.#onSubmitEnd);
43
+ document.addEventListener("turbo:before-visit", this.#onBeforeVisit);
44
+ }
45
+ disconnect() {
46
+ this.element.removeEventListener("input", this.#onFieldChange);
47
+ this.element.removeEventListener("change", this.#onFieldChange);
48
+ this.element.removeEventListener("submit", this.#onSubmit);
49
+ this.element.removeEventListener("turbo:submit-end", this.#onSubmitEnd);
50
+ document.removeEventListener("turbo:before-visit", this.#onBeforeVisit);
51
+ this.#unbindBeforeUnload();
52
+ }
53
+ /** Re-baselines to the current values and clears the dirty state (e.g. after a save). */
54
+ markClean() {
55
+ this.#baseline = this.#serialize();
56
+ this.#setDirty(false);
57
+ }
58
+ /** Recomputes dirty against the baseline and flips state when it changes. */
59
+ #evaluate() {
60
+ this.#setDirty(this.#serialize() !== this.#baseline);
61
+ }
62
+ #setDirty(dirty) {
63
+ if (dirty === this.#dirty) return;
64
+ this.#dirty = dirty;
65
+ if (dirty) {
66
+ this.element.setAttribute("data-dirty", "true");
67
+ this.#bindBeforeUnload();
68
+ } else {
69
+ this.element.removeAttribute("data-dirty");
70
+ this.#unbindBeforeUnload();
71
+ }
72
+ this.dispatch("dirty", { detail: { dirty } });
73
+ }
74
+ /** Guards a Turbo visit while dirty: consumer cancel → confirmBridge → confirm. */
75
+ #guardVisit(event) {
76
+ if (!this.#dirty || this.#submitting) return;
77
+ const guard = this.dispatch("guard", { detail: { event }, cancelable: true });
78
+ if (guard.defaultPrevented) {
79
+ event.preventDefault();
80
+ return;
81
+ }
82
+ if (this.confirmBridgeValue) {
83
+ event.preventDefault();
84
+ return;
85
+ }
86
+ if (!window.confirm(this.messageValue)) {
87
+ event.preventDefault();
88
+ }
89
+ }
90
+ #bindBeforeUnload() {
91
+ if (this.#beforeunloadBound) return;
92
+ window.addEventListener("beforeunload", this.#onBeforeUnload);
93
+ this.#beforeunloadBound = true;
94
+ }
95
+ #unbindBeforeUnload() {
96
+ if (!this.#beforeunloadBound) return;
97
+ window.removeEventListener("beforeunload", this.#onBeforeUnload);
98
+ this.#beforeunloadBound = false;
99
+ }
100
+ /** Stable serialization of the form's controls for change detection. */
101
+ #serialize() {
102
+ const parts = [];
103
+ for (const el of Array.from(this.element.elements)) {
104
+ const name = this.#nameOf(el);
105
+ if (name === null) continue;
106
+ if (el instanceof HTMLInputElement && (el.type === "checkbox" || el.type === "radio")) {
107
+ parts.push(`${name}:${el.checked ? 1 : 0}`);
108
+ } else if (el instanceof HTMLSelectElement) {
109
+ const value = el.multiple ? Array.from(el.selectedOptions).map((o) => o.value).join(",") : el.value;
110
+ parts.push(`${name}:${value}`);
111
+ } else if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
112
+ parts.push(`${name}:${el.value}`);
113
+ }
114
+ }
115
+ return parts.join("|");
116
+ }
117
+ /** A stable key for a control, or null for elements without value semantics. */
118
+ #nameOf(el) {
119
+ if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement || el instanceof HTMLSelectElement) {
120
+ return el.name || el.id || "";
121
+ }
122
+ return null;
123
+ }
124
+ };
125
+
126
+ export { DirtyFormController };
127
+ //# sourceMappingURL=dirty_form_controller.js.map
128
+ //# sourceMappingURL=dirty_form_controller.js.map
@@ -0,0 +1,66 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ // src/controllers/dropdown_controller.ts
4
+ var DropdownController = class extends Controller {
5
+ static targets = ["trigger", "menu"];
6
+ static actions = ["close", "open", "toggle"];
7
+ /** Closes the menu when a click lands outside the controller's element. */
8
+ #onOutsideClick = (event) => {
9
+ if (!this.element.contains(event.target)) {
10
+ this.close();
11
+ }
12
+ };
13
+ /** Closes the menu on `Escape` and restores focus to the trigger. */
14
+ #onKeydown = (event) => {
15
+ if (event.key === "Escape" && this.#isOpen) {
16
+ this.close();
17
+ if (this.hasTriggerTarget) this.triggerTarget.focus();
18
+ }
19
+ };
20
+ /**
21
+ * Starts in the closed state and registers the document-level listeners that
22
+ * power outside-click and `Escape` handling.
23
+ */
24
+ connect() {
25
+ this.close();
26
+ document.addEventListener("click", this.#onOutsideClick);
27
+ document.addEventListener("keydown", this.#onKeydown);
28
+ }
29
+ /** Removes the document-level listeners registered in {@link connect}. */
30
+ disconnect() {
31
+ document.removeEventListener("click", this.#onOutsideClick);
32
+ document.removeEventListener("keydown", this.#onKeydown);
33
+ }
34
+ /** Toggles the menu between open and closed. Bound via `data-action`. */
35
+ toggle() {
36
+ if (this.#isOpen) {
37
+ this.close();
38
+ } else {
39
+ this.open();
40
+ }
41
+ }
42
+ /** Reveals the menu and reflects the open state on the trigger. */
43
+ open() {
44
+ if (!this.hasMenuTarget) return;
45
+ this.menuTarget.hidden = false;
46
+ if (this.hasTriggerTarget) {
47
+ this.triggerTarget.setAttribute("aria-expanded", "true");
48
+ }
49
+ }
50
+ /** Hides the menu and reflects the closed state on the trigger. */
51
+ close() {
52
+ if (!this.hasMenuTarget) return;
53
+ this.menuTarget.hidden = true;
54
+ if (this.hasTriggerTarget) {
55
+ this.triggerTarget.setAttribute("aria-expanded", "false");
56
+ }
57
+ }
58
+ /** Whether the menu is currently visible. */
59
+ get #isOpen() {
60
+ return this.hasMenuTarget && !this.menuTarget.hidden;
61
+ }
62
+ };
63
+
64
+ export { DropdownController };
65
+ //# sourceMappingURL=dropdown_controller.js.map
66
+ //# sourceMappingURL=dropdown_controller.js.map
@@ -0,0 +1,67 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ // src/controllers/empty_state_controller.ts
4
+ var EmptyStateController = class extends Controller {
5
+ static targets = ["list", "empty"];
6
+ static values = {
7
+ itemSelector: { type: String, default: "" },
8
+ announce: { type: Boolean, default: false }
9
+ };
10
+ static events = ["change"];
11
+ #observer = null;
12
+ /** Last applied empty state; `null` until the first sync so connect emits nothing. */
13
+ #empty = null;
14
+ connect() {
15
+ if (!this.hasListTarget) return;
16
+ if (this.announceValue && this.hasEmptyTarget && !this.#isLiveRegion(this.emptyTarget)) {
17
+ this.emptyTarget.setAttribute("role", "status");
18
+ this.emptyTarget.setAttribute("aria-live", "polite");
19
+ }
20
+ if (typeof MutationObserver !== "undefined") {
21
+ this.#observer = new MutationObserver(() => this.#apply());
22
+ this.#observer.observe(this.listTarget, { childList: true });
23
+ }
24
+ this.#apply();
25
+ }
26
+ disconnect() {
27
+ this.#observer?.disconnect();
28
+ this.#observer = null;
29
+ }
30
+ /** Recomputes the count and syncs visibility, hooks, and the change event. */
31
+ #apply() {
32
+ if (!this.hasListTarget) return;
33
+ const count = this.#count();
34
+ const empty = count === 0;
35
+ this.element.setAttribute("data-count", String(count));
36
+ if (empty) {
37
+ this.element.setAttribute("data-empty", "true");
38
+ } else {
39
+ this.element.removeAttribute("data-empty");
40
+ }
41
+ this.listTarget.hidden = empty;
42
+ if (this.hasEmptyTarget) this.emptyTarget.hidden = !empty;
43
+ if (this.#empty !== null && empty !== this.#empty) {
44
+ this.dispatch("change", { detail: { count, empty } });
45
+ }
46
+ this.#empty = empty;
47
+ }
48
+ /** Item count: element children matching `itemSelector`, or all element children. */
49
+ #count() {
50
+ const selector = this.itemSelectorValue;
51
+ if (selector.length === 0) return this.listTarget.childElementCount;
52
+ try {
53
+ return Array.from(this.listTarget.children).filter((child) => child.matches(selector)).length;
54
+ } catch {
55
+ return this.listTarget.childElementCount;
56
+ }
57
+ }
58
+ #isLiveRegion(el) {
59
+ if (el.hasAttribute("aria-live")) return true;
60
+ const role = el.getAttribute("role");
61
+ return role === "status" || role === "alert";
62
+ }
63
+ };
64
+
65
+ export { EmptyStateController };
66
+ //# sourceMappingURL=empty_state_controller.js.map
67
+ //# sourceMappingURL=empty_state_controller.js.map