stimeo-ui 0.1.0-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 (238) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +72 -0
  3. package/dist/controllers/accordion_controller.d.ts +59 -0
  4. package/dist/controllers/accordion_controller.js +76 -0
  5. package/dist/controllers/accordion_controller.js.map +1 -0
  6. package/dist/controllers/announcer_controller.d.ts +78 -0
  7. package/dist/controllers/announcer_controller.js +184 -0
  8. package/dist/controllers/announcer_controller.js.map +1 -0
  9. package/dist/controllers/aspect_ratio_controller.d.ts +39 -0
  10. package/dist/controllers/aspect_ratio_controller.js +36 -0
  11. package/dist/controllers/aspect_ratio_controller.js.map +1 -0
  12. package/dist/controllers/auto_submit_controller.d.ts +74 -0
  13. package/dist/controllers/auto_submit_controller.js +147 -0
  14. package/dist/controllers/auto_submit_controller.js.map +1 -0
  15. package/dist/controllers/avatar_controller.d.ts +50 -0
  16. package/dist/controllers/avatar_controller.js +66 -0
  17. package/dist/controllers/avatar_controller.js.map +1 -0
  18. package/dist/controllers/breadcrumb_controller.d.ts +60 -0
  19. package/dist/controllers/breadcrumb_controller.js +123 -0
  20. package/dist/controllers/breadcrumb_controller.js.map +1 -0
  21. package/dist/controllers/bulk_select_controller.d.ts +67 -0
  22. package/dist/controllers/bulk_select_controller.js +104 -0
  23. package/dist/controllers/bulk_select_controller.js.map +1 -0
  24. package/dist/controllers/calendar_controller.d.ts +100 -0
  25. package/dist/controllers/calendar_controller.js +394 -0
  26. package/dist/controllers/calendar_controller.js.map +1 -0
  27. package/dist/controllers/character_counter_controller.d.ts +63 -0
  28. package/dist/controllers/character_counter_controller.js +179 -0
  29. package/dist/controllers/character_counter_controller.js.map +1 -0
  30. package/dist/controllers/checkbox_controller.d.ts +52 -0
  31. package/dist/controllers/checkbox_controller.js +73 -0
  32. package/dist/controllers/checkbox_controller.js.map +1 -0
  33. package/dist/controllers/combobox_controller.d.ts +76 -0
  34. package/dist/controllers/combobox_controller.js +186 -0
  35. package/dist/controllers/combobox_controller.js.map +1 -0
  36. package/dist/controllers/command_palette_controller.d.ts +103 -0
  37. package/dist/controllers/command_palette_controller.js +381 -0
  38. package/dist/controllers/command_palette_controller.js.map +1 -0
  39. package/dist/controllers/conditional_fields_controller.d.ts +56 -0
  40. package/dist/controllers/conditional_fields_controller.js +112 -0
  41. package/dist/controllers/conditional_fields_controller.js.map +1 -0
  42. package/dist/controllers/confirm_controller.d.ts +83 -0
  43. package/dist/controllers/confirm_controller.js +276 -0
  44. package/dist/controllers/confirm_controller.js.map +1 -0
  45. package/dist/controllers/context_menu_controller.d.ts +81 -0
  46. package/dist/controllers/context_menu_controller.js +112 -0
  47. package/dist/controllers/context_menu_controller.js.map +1 -0
  48. package/dist/controllers/countdown_controller.d.ts +93 -0
  49. package/dist/controllers/countdown_controller.js +202 -0
  50. package/dist/controllers/countdown_controller.js.map +1 -0
  51. package/dist/controllers/dialog_controller.d.ts +57 -0
  52. package/dist/controllers/dialog_controller.js +207 -0
  53. package/dist/controllers/dialog_controller.js.map +1 -0
  54. package/dist/controllers/direct_upload_controller.d.ts +77 -0
  55. package/dist/controllers/direct_upload_controller.js +212 -0
  56. package/dist/controllers/direct_upload_controller.js.map +1 -0
  57. package/dist/controllers/dirty_form_controller.d.ts +52 -0
  58. package/dist/controllers/dirty_form_controller.js +128 -0
  59. package/dist/controllers/dirty_form_controller.js.map +1 -0
  60. package/dist/controllers/dropdown_controller.d.ts +49 -0
  61. package/dist/controllers/dropdown_controller.js +66 -0
  62. package/dist/controllers/dropdown_controller.js.map +1 -0
  63. package/dist/controllers/empty_state_controller.d.ts +60 -0
  64. package/dist/controllers/empty_state_controller.js +67 -0
  65. package/dist/controllers/empty_state_controller.js.map +1 -0
  66. package/dist/controllers/flash_controller.d.ts +63 -0
  67. package/dist/controllers/flash_controller.js +221 -0
  68. package/dist/controllers/flash_controller.js.map +1 -0
  69. package/dist/controllers/focus_controller.d.ts +69 -0
  70. package/dist/controllers/focus_controller.js +216 -0
  71. package/dist/controllers/focus_controller.js.map +1 -0
  72. package/dist/controllers/form_field_controller.d.ts +79 -0
  73. package/dist/controllers/form_field_controller.js +154 -0
  74. package/dist/controllers/form_field_controller.js.map +1 -0
  75. package/dist/controllers/form_validation_controller.d.ts +114 -0
  76. package/dist/controllers/form_validation_controller.js +202 -0
  77. package/dist/controllers/form_validation_controller.js.map +1 -0
  78. package/dist/controllers/frame_loading_controller.d.ts +58 -0
  79. package/dist/controllers/frame_loading_controller.js +177 -0
  80. package/dist/controllers/frame_loading_controller.js.map +1 -0
  81. package/dist/controllers/highlight_controller.d.ts +50 -0
  82. package/dist/controllers/highlight_controller.js +107 -0
  83. package/dist/controllers/highlight_controller.js.map +1 -0
  84. package/dist/controllers/hover_card_controller.d.ts +79 -0
  85. package/dist/controllers/hover_card_controller.js +165 -0
  86. package/dist/controllers/hover_card_controller.js.map +1 -0
  87. package/dist/controllers/idle_controller.d.ts +51 -0
  88. package/dist/controllers/idle_controller.js +141 -0
  89. package/dist/controllers/idle_controller.js.map +1 -0
  90. package/dist/controllers/input_mask_controller.d.ts +74 -0
  91. package/dist/controllers/input_mask_controller.js +166 -0
  92. package/dist/controllers/input_mask_controller.js.map +1 -0
  93. package/dist/controllers/lazy_frame_controller.d.ts +53 -0
  94. package/dist/controllers/lazy_frame_controller.js +68 -0
  95. package/dist/controllers/lazy_frame_controller.js.map +1 -0
  96. package/dist/controllers/listbox_controller.d.ts +88 -0
  97. package/dist/controllers/listbox_controller.js +256 -0
  98. package/dist/controllers/listbox_controller.js.map +1 -0
  99. package/dist/controllers/local_time_controller.d.ts +60 -0
  100. package/dist/controllers/local_time_controller.js +81 -0
  101. package/dist/controllers/local_time_controller.js.map +1 -0
  102. package/dist/controllers/menu_controller.d.ts +81 -0
  103. package/dist/controllers/menu_controller.js +134 -0
  104. package/dist/controllers/menu_controller.js.map +1 -0
  105. package/dist/controllers/meter_controller.d.ts +93 -0
  106. package/dist/controllers/meter_controller.js +96 -0
  107. package/dist/controllers/meter_controller.js.map +1 -0
  108. package/dist/controllers/nested_form_controller.d.ts +85 -0
  109. package/dist/controllers/nested_form_controller.js +131 -0
  110. package/dist/controllers/nested_form_controller.js.map +1 -0
  111. package/dist/controllers/network_status_controller.d.ts +49 -0
  112. package/dist/controllers/network_status_controller.js +126 -0
  113. package/dist/controllers/network_status_controller.js.map +1 -0
  114. package/dist/controllers/number_input_controller.d.ts +93 -0
  115. package/dist/controllers/number_input_controller.js +306 -0
  116. package/dist/controllers/number_input_controller.js.map +1 -0
  117. package/dist/controllers/otp_controller.d.ts +68 -0
  118. package/dist/controllers/otp_controller.js +201 -0
  119. package/dist/controllers/otp_controller.js.map +1 -0
  120. package/dist/controllers/overflow_indicator_controller.d.ts +62 -0
  121. package/dist/controllers/overflow_indicator_controller.js +169 -0
  122. package/dist/controllers/overflow_indicator_controller.js.map +1 -0
  123. package/dist/controllers/overflow_menu_controller.d.ts +64 -0
  124. package/dist/controllers/overflow_menu_controller.js +274 -0
  125. package/dist/controllers/overflow_menu_controller.js.map +1 -0
  126. package/dist/controllers/pagination_controller.d.ts +69 -0
  127. package/dist/controllers/pagination_controller.js +89 -0
  128. package/dist/controllers/pagination_controller.js.map +1 -0
  129. package/dist/controllers/password_strength_controller.d.ts +67 -0
  130. package/dist/controllers/password_strength_controller.js +175 -0
  131. package/dist/controllers/password_strength_controller.js.map +1 -0
  132. package/dist/controllers/persist_controller.d.ts +63 -0
  133. package/dist/controllers/persist_controller.js +259 -0
  134. package/dist/controllers/persist_controller.js.map +1 -0
  135. package/dist/controllers/popover_controller.d.ts +63 -0
  136. package/dist/controllers/popover_controller.js +94 -0
  137. package/dist/controllers/popover_controller.js.map +1 -0
  138. package/dist/controllers/portal_controller.d.ts +58 -0
  139. package/dist/controllers/portal_controller.js +63 -0
  140. package/dist/controllers/portal_controller.js.map +1 -0
  141. package/dist/controllers/preview_guard_controller.d.ts +47 -0
  142. package/dist/controllers/preview_guard_controller.js +69 -0
  143. package/dist/controllers/preview_guard_controller.js.map +1 -0
  144. package/dist/controllers/progress_controller.d.ts +85 -0
  145. package/dist/controllers/progress_controller.js +93 -0
  146. package/dist/controllers/progress_controller.js.map +1 -0
  147. package/dist/controllers/radio_group_controller.d.ts +49 -0
  148. package/dist/controllers/radio_group_controller.js +128 -0
  149. package/dist/controllers/radio_group_controller.js.map +1 -0
  150. package/dist/controllers/rating_controller.d.ts +82 -0
  151. package/dist/controllers/rating_controller.js +179 -0
  152. package/dist/controllers/rating_controller.js.map +1 -0
  153. package/dist/controllers/relative_time_controller.d.ts +48 -0
  154. package/dist/controllers/relative_time_controller.js +129 -0
  155. package/dist/controllers/relative_time_controller.js.map +1 -0
  156. package/dist/controllers/reset_before_cache_controller.d.ts +54 -0
  157. package/dist/controllers/reset_before_cache_controller.js +62 -0
  158. package/dist/controllers/reset_before_cache_controller.js.map +1 -0
  159. package/dist/controllers/resizable_controller.d.ts +81 -0
  160. package/dist/controllers/resizable_controller.js +163 -0
  161. package/dist/controllers/resizable_controller.js.map +1 -0
  162. package/dist/controllers/roving_controller.d.ts +63 -0
  163. package/dist/controllers/roving_controller.js +116 -0
  164. package/dist/controllers/roving_controller.js.map +1 -0
  165. package/dist/controllers/scroll_area_controller.d.ts +45 -0
  166. package/dist/controllers/scroll_area_controller.js +183 -0
  167. package/dist/controllers/scroll_area_controller.js.map +1 -0
  168. package/dist/controllers/scroll_visibility_controller.d.ts +72 -0
  169. package/dist/controllers/scroll_visibility_controller.js +103 -0
  170. package/dist/controllers/scroll_visibility_controller.js.map +1 -0
  171. package/dist/controllers/scrollspy_controller.d.ts +65 -0
  172. package/dist/controllers/scrollspy_controller.js +171 -0
  173. package/dist/controllers/scrollspy_controller.js.map +1 -0
  174. package/dist/controllers/skeleton_controller.d.ts +50 -0
  175. package/dist/controllers/skeleton_controller.js +125 -0
  176. package/dist/controllers/skeleton_controller.js.map +1 -0
  177. package/dist/controllers/slider_controller.d.ts +76 -0
  178. package/dist/controllers/slider_controller.js +109 -0
  179. package/dist/controllers/slider_controller.js.map +1 -0
  180. package/dist/controllers/spinner_controller.d.ts +62 -0
  181. package/dist/controllers/spinner_controller.js +164 -0
  182. package/dist/controllers/spinner_controller.js.map +1 -0
  183. package/dist/controllers/step_indicator_controller.d.ts +55 -0
  184. package/dist/controllers/step_indicator_controller.js +55 -0
  185. package/dist/controllers/step_indicator_controller.js.map +1 -0
  186. package/dist/controllers/stepper_controller.d.ts +66 -0
  187. package/dist/controllers/stepper_controller.js +78 -0
  188. package/dist/controllers/stepper_controller.js.map +1 -0
  189. package/dist/controllers/stick_to_bottom_controller.d.ts +61 -0
  190. package/dist/controllers/stick_to_bottom_controller.js +100 -0
  191. package/dist/controllers/stick_to_bottom_controller.js.map +1 -0
  192. package/dist/controllers/sticky_observer_controller.d.ts +54 -0
  193. package/dist/controllers/sticky_observer_controller.js +53 -0
  194. package/dist/controllers/sticky_observer_controller.js.map +1 -0
  195. package/dist/controllers/submit_once_controller.d.ts +66 -0
  196. package/dist/controllers/submit_once_controller.js +206 -0
  197. package/dist/controllers/submit_once_controller.js.map +1 -0
  198. package/dist/controllers/switch_controller.d.ts +43 -0
  199. package/dist/controllers/switch_controller.js +50 -0
  200. package/dist/controllers/switch_controller.js.map +1 -0
  201. package/dist/controllers/tabs_controller.d.ts +48 -0
  202. package/dist/controllers/tabs_controller.js +63 -0
  203. package/dist/controllers/tabs_controller.js.map +1 -0
  204. package/dist/controllers/textarea_autosize_controller.d.ts +47 -0
  205. package/dist/controllers/textarea_autosize_controller.js +72 -0
  206. package/dist/controllers/textarea_autosize_controller.js.map +1 -0
  207. package/dist/controllers/theme_controller.d.ts +70 -0
  208. package/dist/controllers/theme_controller.js +154 -0
  209. package/dist/controllers/theme_controller.js.map +1 -0
  210. package/dist/controllers/toast_controller.d.ts +123 -0
  211. package/dist/controllers/toast_controller.js +310 -0
  212. package/dist/controllers/toast_controller.js.map +1 -0
  213. package/dist/controllers/toggle_group_controller.d.ts +56 -0
  214. package/dist/controllers/toggle_group_controller.js +130 -0
  215. package/dist/controllers/toggle_group_controller.js.map +1 -0
  216. package/dist/controllers/toolbar_controller.d.ts +59 -0
  217. package/dist/controllers/toolbar_controller.js +113 -0
  218. package/dist/controllers/toolbar_controller.js.map +1 -0
  219. package/dist/controllers/tooltip_controller.d.ts +74 -0
  220. package/dist/controllers/tooltip_controller.js +165 -0
  221. package/dist/controllers/tooltip_controller.js.map +1 -0
  222. package/dist/controllers/transition_controller.d.ts +86 -0
  223. package/dist/controllers/transition_controller.js +203 -0
  224. package/dist/controllers/transition_controller.js.map +1 -0
  225. package/dist/index.d.ts +1931 -0
  226. package/dist/index.js +12241 -0
  227. package/dist/index.js.map +1 -0
  228. package/dist/inspector/cli.d.ts +149 -0
  229. package/dist/inspector/cli.js +663 -0
  230. package/dist/inspector/cli.js.map +1 -0
  231. package/dist/inspector/cli_bin.d.ts +2 -0
  232. package/dist/inspector/cli_bin.js +665 -0
  233. package/dist/inspector/cli_bin.js.map +1 -0
  234. package/dist/inspector/manifest.json +2235 -0
  235. package/dist/positioning/index.d.ts +226 -0
  236. package/dist/positioning/index.js +145 -0
  237. package/dist/positioning/index.js.map +1 -0
  238. package/package.json +93 -0
@@ -0,0 +1,77 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ /**
4
+ * Headless progress UI for ActiveStorage Direct Uploads: subscribes to the
5
+ * `direct-upload:*` events and renders a per-file progress row (no dedicated APG
6
+ * pattern; the rows follow the `role="progressbar"` practice). The companion to
7
+ * {@link "file-dropzone"}, which leaves transport out of scope.
8
+ *
9
+ * Markup contract (identifier: `stimeo--direct-upload`):
10
+ * <div data-controller="stimeo--direct-upload">
11
+ * <div data-stimeo--direct-upload-target="list"></div>
12
+ * <template data-stimeo--direct-upload-target="row">
13
+ * <div role="progressbar" aria-valuemin="0" aria-valuemax="100">
14
+ * <span data-field="name"></span><span data-field="percent"></span>
15
+ * </div>
16
+ * </template>
17
+ * <span data-stimeo--direct-upload-target="status" aria-live="polite"></span>
18
+ * </div>
19
+ *
20
+ * For each upload it clones the `row` template into `list`, then updates
21
+ * `aria-valuenow`, the `[data-field="percent"]` text, and the
22
+ * `--stimeo-upload-progress` custom property as `direct-upload:progress` arrives,
23
+ * and flips `data-upload-state` to `done` / `error`. Completion and failure are
24
+ * announced into the optional `status` live region using the consumer-provided
25
+ * `doneLabel` / `errorLabel` (so copy stays localizable); per-tick progress is not
26
+ * announced (the progressbar's `aria-valuenow` conveys it) to avoid flooding.
27
+ *
28
+ * @remarks
29
+ * Behavior only — no bars are drawn. The `direct-upload:*` listeners live on
30
+ * `document` (the events bubble there) and are removed on `disconnect()` (Turbo
31
+ * navigation included), along with any pending removal timers, so a callback that
32
+ * arrives after teardown never touches a detached row. With multiple upload widgets
33
+ * on one page, set `scope` to a selector for the owning form/root so each widget
34
+ * only handles its own uploads.
35
+ */
36
+ declare class DirectUploadController extends Controller<HTMLElement> {
37
+ #private;
38
+ static targets: string[];
39
+ static values: {
40
+ announce: {
41
+ type: BooleanConstructor;
42
+ default: boolean;
43
+ };
44
+ removeOnDone: {
45
+ type: BooleanConstructor;
46
+ default: boolean;
47
+ };
48
+ doneLabel: {
49
+ type: StringConstructor;
50
+ default: string;
51
+ };
52
+ errorLabel: {
53
+ type: StringConstructor;
54
+ default: string;
55
+ };
56
+ scope: {
57
+ type: StringConstructor;
58
+ default: string;
59
+ };
60
+ };
61
+ static events: readonly ["progress", "done", "error"];
62
+ readonly listTarget: HTMLElement;
63
+ readonly rowTarget: HTMLTemplateElement;
64
+ readonly statusTarget: HTMLElement;
65
+ readonly hasListTarget: boolean;
66
+ readonly hasRowTarget: boolean;
67
+ readonly hasStatusTarget: boolean;
68
+ announceValue: boolean;
69
+ removeOnDoneValue: boolean;
70
+ doneLabelValue: string;
71
+ errorLabelValue: string;
72
+ scopeValue: string;
73
+ connect(): void;
74
+ disconnect(): void;
75
+ }
76
+
77
+ export { DirectUploadController };
@@ -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 @@
1
+ {"version":3,"sources":["../../src/utils/safe_timeout.ts","../../src/controllers/direct_upload_controller.ts"],"names":[],"mappings":";;;;;AAwBA,IAAe,gBAAf,MAA6B;AAAA;AAAA,EAER,GAAA,uBAAU,GAAA,EAAY;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAczC,MAAM,EAAA,EAAkB;AACtB,IAAA,IAAI,IAAA,CAAK,GAAA,CAAI,MAAA,CAAO,EAAE,CAAA,EAAG;AACvB,MAAA,IAAA,CAAK,OAAO,EAAE,CAAA;AAAA,IAChB;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,QAAA,GAAiB;AACf,IAAA,KAAA,MAAW,EAAA,IAAM,KAAK,GAAA,EAAK;AACzB,MAAA,IAAA,CAAK,OAAO,EAAE,CAAA;AAAA,IAChB;AACA,IAAA,IAAA,CAAK,IAAI,KAAA,EAAM;AAAA,EACjB;AAAA;AAAA,EAGA,IAAI,IAAA,GAAe;AACjB,IAAA,OAAO,KAAK,GAAA,CAAI,IAAA;AAAA,EAClB;AACF,CAAA;AAmBO,IAAM,WAAA,GAAN,cAA0B,aAAA,CAAc;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAO7C,GAAA,CAAI,UAAsB,KAAA,EAAuB;AAC/C,IAAA,MAAM,EAAA,GAAK,IAAA,CAAK,QAAA,CAAS,MAAM;AAC7B,MAAA,IAAA,CAAK,GAAA,CAAI,OAAO,EAAE,CAAA;AAClB,MAAA,QAAA,EAAS;AAAA,IACX,GAAG,KAAK,CAAA;AACR,IAAA,IAAA,CAAK,GAAA,CAAI,IAAI,EAAE,CAAA;AACf,IAAA,OAAO,EAAA;AAAA,EACT;AAAA,EAEU,QAAA,CAAS,UAAsB,KAAA,EAAuB;AAC9D,IAAA,OAAO,MAAA,CAAO,UAAA,CAAW,QAAA,EAAU,KAAK,CAAA;AAAA,EAC1C;AAAA,EAEU,OAAO,EAAA,EAAkB;AACjC,IAAA,MAAA,CAAO,aAAa,EAAE,CAAA;AAAA,EACxB;AACF,CAAA;;;AC3FA,IAAM,YAAA,GAAe,GAAA;AAmCd,IAAM,sBAAA,GAAN,cAAqC,UAAA,CAAwB;AAAA,EAClE,OAAgB,OAAA,GAAU,CAAC,MAAA,EAAQ,OAAO,QAAQ,CAAA;AAAA,EAClD,OAAgB,MAAA,GAAS;AAAA,IACvB,QAAA,EAAU,EAAE,IAAA,EAAM,OAAA,EAAS,SAAS,IAAA,EAAK;AAAA,IACzC,YAAA,EAAc,EAAE,IAAA,EAAM,OAAA,EAAS,SAAS,KAAA,EAAM;AAAA,IAC9C,SAAA,EAAW,EAAE,IAAA,EAAM,MAAA,EAAQ,SAAS,EAAA,EAAG;AAAA,IACvC,UAAA,EAAY,EAAE,IAAA,EAAM,MAAA,EAAQ,SAAS,EAAA,EAAG;AAAA,IACxC,KAAA,EAAO,EAAE,IAAA,EAAM,MAAA,EAAQ,SAAS,EAAA;AAAG,GACrC;AAAA,EACA,OAAO,MAAA,GAAS,CAAC,UAAA,EAAY,QAAQ,OAAO,CAAA;AAAA,EAenC,SAAA,GAAY,IAAI,WAAA,EAAY;AAAA,EAC5B,KAAA,uBAAY,GAAA,EAAyB;AAAA,EAErC,aAAA,GAAgB,CAAC,KAAA,KAAuB;AAC/C,IAAA,IAAI,CAAC,IAAA,CAAK,QAAA,CAAS,KAAK,CAAA,EAAG;AAC3B,IAAA,MAAM,MAAA,GAAS,IAAA,CAAK,OAAA,CAAQ,KAAK,CAAA;AACjC,IAAA,IAAA,CAAK,QAAQ,MAAA,CAAO,EAAA,EAAI,MAAA,CAAO,IAAA,EAAM,QAAQ,EAAE,CAAA;AAAA,EACjD,CAAA;AAAA,EAES,WAAA,GAAc,CAAC,KAAA,KAAuB;AAC7C,IAAA,IAAI,CAAC,IAAA,CAAK,QAAA,CAAS,KAAK,CAAA,EAAG;AAC3B,IAAA,MAAM,MAAA,GAAS,IAAA,CAAK,OAAA,CAAQ,KAAK,CAAA;AACjC,IAAA,IAAA,CAAK,eAAA,CAAgB,IAAA,CAAK,IAAA,CAAK,MAAA,CAAO,EAAE,CAAA,EAAG,IAAA,CAAK,KAAA,CAAM,MAAA,CAAO,QAAA,IAAY,CAAC,CAAC,CAAA;AAAA,EAC7E,CAAA;AAAA,EAES,QAAA,GAAW,CAAC,KAAA,KAAuB;AAC1C,IAAA,IAAI,CAAC,IAAA,CAAK,QAAA,CAAS,KAAK,CAAA,EAAG;AAC3B,IAAA,MAAM,MAAA,GAAS,IAAA,CAAK,OAAA,CAAQ,KAAK,CAAA;AACjC,IAAA,IAAA,CAAK,KAAA,CAAM,KAAK,IAAA,CAAK,MAAA,CAAO,EAAE,CAAA,EAAG,MAAA,CAAO,SAAS,EAAE,CAAA;AAAA,EACrD,CAAA;AAAA,EAES,MAAA,GAAS,CAAC,KAAA,KAAuB;AACxC,IAAA,IAAI,CAAC,IAAA,CAAK,QAAA,CAAS,KAAK,CAAA,EAAG;AAC3B,IAAA,MAAM,MAAA,GAAS,IAAA,CAAK,OAAA,CAAQ,KAAK,CAAA;AACjC,IAAA,IAAA,CAAK,SAAA,CAAU,KAAK,IAAA,CAAK,MAAA,CAAO,EAAE,CAAA,EAAG,MAAA,CAAO,IAAA,EAAM,IAAA,IAAQ,EAAE,CAAA;AAAA,EAC9D,CAAA;AAAA,EAES,OAAA,GAAgB;AACvB,IAAA,QAAA,CAAS,gBAAA,CAAiB,0BAAA,EAA4B,IAAA,CAAK,aAAa,CAAA;AACxE,IAAA,QAAA,CAAS,gBAAA,CAAiB,wBAAA,EAA0B,IAAA,CAAK,WAAW,CAAA;AACpE,IAAA,QAAA,CAAS,gBAAA,CAAiB,qBAAA,EAAuB,IAAA,CAAK,QAAQ,CAAA;AAC9D,IAAA,QAAA,CAAS,gBAAA,CAAiB,mBAAA,EAAqB,IAAA,CAAK,MAAM,CAAA;AAAA,EAC5D;AAAA,EAES,UAAA,GAAmB;AAC1B,IAAA,QAAA,CAAS,mBAAA,CAAoB,0BAAA,EAA4B,IAAA,CAAK,aAAa,CAAA;AAC3E,IAAA,QAAA,CAAS,mBAAA,CAAoB,wBAAA,EAA0B,IAAA,CAAK,WAAW,CAAA;AACvE,IAAA,QAAA,CAAS,mBAAA,CAAoB,qBAAA,EAAuB,IAAA,CAAK,QAAQ,CAAA;AACjE,IAAA,QAAA,CAAS,mBAAA,CAAoB,mBAAA,EAAqB,IAAA,CAAK,MAAM,CAAA;AAC7D,IAAA,IAAA,CAAK,UAAU,QAAA,EAAS;AACxB,IAAA,IAAA,CAAK,MAAM,KAAA,EAAM;AAAA,EACnB;AAAA;AAAA,EAGA,eAAA,CAAgB,IAAY,OAAA,EAAuB;AACjD,IAAA,MAAM,GAAA,GAAM,IAAA,CAAK,OAAA,CAAQ,EAAE,CAAA;AAC3B,IAAA,IAAI,QAAQ,IAAA,EAAM;AAClB,IAAA,MAAM,OAAA,GAAU,KAAK,GAAA,CAAI,CAAA,EAAG,KAAK,GAAA,CAAI,GAAA,EAAK,OAAO,CAAC,CAAA;AAClD,IAAA,GAAA,CAAI,YAAA,CAAa,eAAA,EAAiB,MAAA,CAAO,OAAO,CAAC,CAAA;AACjD,IAAA,GAAA,CAAI,YAAA,CAAa,gBAAA,EAAkB,CAAA,EAAG,OAAO,CAAA,CAAA,CAAG,CAAA;AAChD,IAAA,GAAA,CAAI,KAAA,CAAM,WAAA,CAAY,0BAAA,EAA4B,CAAA,EAAG,OAAO,CAAA,CAAA,CAAG,CAAA;AAC/D,IAAA,IAAA,CAAK,SAAA,CAAU,GAAA,EAAK,SAAA,EAAW,CAAA,EAAG,OAAO,CAAA,CAAA,CAAG,CAAA;AAC5C,IAAA,IAAA,CAAK,cAAA,EAAe;AACpB,IAAA,IAAA,CAAK,QAAA,CAAS,YAAY,EAAE,MAAA,EAAQ,EAAE,EAAA,EAAI,OAAA,EAAS,OAAA,EAAQ,EAAG,CAAA;AAAA,EAChE;AAAA;AAAA,EAGA,SAAA,CAAU,EAAA,EAAY,IAAA,GAAO,EAAA,EAAU;AAKrC,IAAA,MAAM,GAAA,GAAM,IAAA,CAAK,OAAA,CAAQ,EAAA,EAAI,IAAI,CAAA;AACjC,IAAA,IAAI,QAAQ,IAAA,EAAM;AAClB,IAAA,GAAA,CAAI,YAAA,CAAa,qBAAqB,MAAM,CAAA;AAC5C,IAAA,IAAA,CAAK,SAAA,CAAU,IAAA,CAAK,cAAA,EAAgB,GAAG,CAAA;AACvC,IAAA,IAAA,CAAK,SAAS,MAAA,EAAQ,EAAE,QAAQ,EAAE,EAAA,IAAM,CAAA;AACxC,IAAA,IAAI,KAAK,iBAAA,EAAmB;AAC1B,MAAA,IAAA,CAAK,UAAU,GAAA,CAAI,MAAM,KAAK,UAAA,CAAW,EAAE,GAAG,YAAY,CAAA;AAAA,IAC5D;AAAA,EACF;AAAA;AAAA,EAGA,KAAA,CAAM,IAAY,KAAA,EAAqB;AACrC,IAAA,MAAM,GAAA,GAAM,IAAA,CAAK,OAAA,CAAQ,EAAE,CAAA;AAC3B,IAAA,IAAI,QAAQ,IAAA,EAAM;AAClB,IAAA,GAAA,CAAI,YAAA,CAAa,qBAAqB,OAAO,CAAA;AAC7C,IAAA,IAAA,CAAK,SAAA,CAAU,IAAA,CAAK,eAAA,EAAiB,GAAG,CAAA;AACxC,IAAA,IAAA,CAAK,QAAA,CAAS,SAAS,EAAE,MAAA,EAAQ,EAAE,EAAA,EAAI,KAAA,IAAS,CAAA;AAAA,EAClD;AAAA;AAAA,EAGA,OAAA,CAAQ,IAAiC,IAAA,EAAmC;AAC1E,IAAA,MAAM,GAAA,GAAM,IAAA,CAAK,IAAA,CAAK,EAAE,CAAA;AACxB,IAAA,MAAM,QAAA,GAAW,IAAA,CAAK,KAAA,CAAM,GAAA,CAAI,GAAG,CAAA;AACnC,IAAA,IAAI,QAAA,KAAa,QAAW,OAAO,QAAA;AACnC,IAAA,IAAI,CAAC,IAAA,CAAK,YAAA,IAAgB,CAAC,IAAA,CAAK,eAAe,OAAO,IAAA;AACtD,IAAA,MAAM,QAAQ,IAAA,CAAK,SAAA,CAAU,OAAA,CAAQ,iBAAA,EAAmB,UAAU,IAAI,CAAA;AACtE,IAAA,IAAI,EAAE,KAAA,YAAiB,WAAA,CAAA,EAAc,OAAO,IAAA;AAC5C,IAAA,IAAI,IAAA,KAAS,MAAA,IAAa,IAAA,CAAK,MAAA,GAAS,CAAA,EAAG;AACzC,MAAA,IAAA,CAAK,SAAA,CAAU,KAAA,EAAO,MAAA,EAAQ,IAAI,CAAA;AAElC,MAAA,KAAA,CAAM,YAAA,CAAa,cAAc,IAAI,CAAA;AAAA,IACvC;AACA,IAAA,KAAA,CAAM,YAAA,CAAa,iBAAiB,GAAG,CAAA;AACvC,IAAA,KAAA,CAAM,YAAA,CAAa,qBAAqB,WAAW,CAAA;AACnD,IAAA,KAAA,CAAM,KAAA,CAAM,WAAA,CAAY,0BAAA,EAA4B,IAAI,CAAA;AACxD,IAAA,IAAA,CAAK,UAAA,CAAW,YAAY,KAAK,CAAA;AACjC,IAAA,IAAA,CAAK,KAAA,CAAM,GAAA,CAAI,GAAA,EAAK,KAAK,CAAA;AACzB,IAAA,OAAO,KAAA;AAAA,EACT;AAAA,EAEA,WAAW,EAAA,EAAkB;AAC3B,IAAA,MAAM,GAAA,GAAM,IAAA,CAAK,KAAA,CAAM,GAAA,CAAI,EAAE,CAAA;AAC7B,IAAA,IAAI,QAAQ,MAAA,EAAW;AACvB,IAAA,GAAA,CAAI,MAAA,EAAO;AACX,IAAA,IAAA,CAAK,KAAA,CAAM,OAAO,EAAE,CAAA;AACpB,IAAA,IAAA,CAAK,cAAA,EAAe;AAAA,EACtB;AAAA;AAAA,EAGA,cAAA,GAAuB;AACrB,IAAA,IAAI,IAAA,CAAK,KAAA,CAAM,IAAA,KAAS,CAAA,EAAG;AACzB,MAAA,IAAA,CAAK,OAAA,CAAQ,gBAAgB,sBAAsB,CAAA;AACnD,MAAA;AAAA,IACF;AACA,IAAA,IAAI,KAAA,GAAQ,CAAA;AACZ,IAAA,KAAA,MAAW,GAAA,IAAO,IAAA,CAAK,KAAA,CAAM,MAAA,EAAO,EAAG;AACrC,MAAA,KAAA,IAAS,MAAA,CAAO,GAAA,CAAI,YAAA,CAAa,eAAe,KAAK,GAAG,CAAA;AAAA,IAC1D;AACA,IAAA,MAAM,UAAU,IAAA,CAAK,KAAA,CAAM,KAAA,GAAQ,IAAA,CAAK,MAAM,IAAI,CAAA;AAClD,IAAA,IAAA,CAAK,OAAA,CAAQ,YAAA,CAAa,sBAAA,EAAwB,MAAA,CAAO,OAAO,CAAC,CAAA;AACjE,IAAA,IAAA,CAAK,QAAQ,KAAA,CAAM,WAAA,CAAY,0BAAA,EAA4B,CAAA,EAAG,OAAO,CAAA,CAAA,CAAG,CAAA;AAAA,EAC1E;AAAA;AAAA,EAGA,SAAA,CAAU,OAAe,GAAA,EAAwB;AAC/C,IAAA,IAAI,CAAC,KAAK,aAAA,IAAiB,CAAC,KAAK,eAAA,IAAmB,KAAA,CAAM,WAAW,CAAA,EAAG;AACxE,IAAA,MAAM,OAAO,IAAA,CAAK,MAAA,CAAO,GAAA,EAAK,MAAM,GAAG,WAAA,IAAe,EAAA;AACtD,IAAA,IAAA,CAAK,YAAA,CAAa,WAAA,GAAc,KAAA,CAAM,OAAA,CAAQ,WAAW,IAAI,CAAA;AAAA,EAC/D;AAAA,EAEA,MAAA,CAAO,KAAkB,IAAA,EAAkC;AACzD,IAAA,OAAO,GAAA,CAAI,aAAA,CAA2B,CAAA,aAAA,EAAgB,IAAI,CAAA,EAAA,CAAI,CAAA;AAAA,EAChE;AAAA,EAEA,SAAA,CAAU,GAAA,EAAkB,IAAA,EAAc,IAAA,EAAoB;AAC5D,IAAA,MAAM,KAAA,GAAQ,IAAA,CAAK,MAAA,CAAO,GAAA,EAAK,IAAI,CAAA;AACnC,IAAA,IAAI,KAAA,KAAU,IAAA,EAAM,KAAA,CAAM,WAAA,GAAc,IAAA;AAAA,EAC1C;AAAA,EAEA,QAAQ,KAAA,EAA4B;AAClC,IAAA,OAAQ,KAAA,CAAoC,UAAU,EAAC;AAAA,EACzD;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EASA,SAAS,KAAA,EAAuB;AAC9B,IAAA,IAAI,IAAA,CAAK,UAAA,CAAW,MAAA,KAAW,CAAA,EAAG,OAAO,IAAA;AACzC,IAAA,MAAM,SAAS,KAAA,CAAM,MAAA;AACrB,IAAA,OAAO,kBAAkB,OAAA,IAAW,MAAA,CAAO,OAAA,CAAQ,IAAA,CAAK,UAAU,CAAA,KAAM,IAAA;AAAA,EAC1E;AAAA,EAEA,KAAK,EAAA,EAAyC;AAC5C,IAAA,OAAO,MAAA,CAAO,MAAM,EAAE,CAAA;AAAA,EACxB;AACF","file":"direct_upload_controller.js","sourcesContent":["/**\n * Self-cleaning timer registries shared by Stimeo controllers.\n *\n * Stimulus controllers frequently schedule `setTimeout` / `setInterval` work\n * (auto-dismiss, debouncing, polling). When the element leaves the DOM — a\n * Turbo Drive navigation, a Turbo Stream replacement, or any `disconnect()` —\n * orphaned timers keep firing against a detached controller, leaking memory and\n * mutating stale state. {@link SafeTimeout} and {@link SafeInterval} track every\n * timer they create so a single {@link TimerRegistry.clearAll | clearAll()} call\n * in `disconnect()` tears them all down.\n *\n * These are intentionally low-level primitives: they own *registration and\n * cleanup only*. Higher-level policy (pause/resume, remaining-time accounting)\n * stays in the individual controllers so per-widget semantics are not flattened\n * into a lowest-common-denominator helper.\n */\n\n/**\n * Shared registry bookkeeping for the timeout/interval variants.\n *\n * Subclasses provide the scheduling primitive ({@link schedule}) and its matching\n * canceller ({@link cancel}); this base owns the set of live ids plus the\n * per-id and bulk teardown shared by both.\n */\nabstract class TimerRegistry {\n /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */\n protected readonly ids = new Set<number>();\n\n /** Schedules the underlying platform timer and returns its id. */\n protected abstract schedule(callback: () => void, delay: number): number;\n\n /** Cancels the underlying platform timer for `id`. */\n protected abstract cancel(id: number): void;\n\n /**\n * Cancels a single tracked timer.\n *\n * No-ops if the id is unknown (already cleared, fired, or never owned by this\n * registry), so callers can clear defensively without guarding.\n */\n clear(id: number): void {\n if (this.ids.delete(id)) {\n this.cancel(id);\n }\n }\n\n /**\n * Cancels every tracked timer. Call this from a controller's `disconnect()`\n * to guarantee no timer outlives the element.\n */\n clearAll(): void {\n for (const id of this.ids) {\n this.cancel(id);\n }\n this.ids.clear();\n }\n\n /** Number of timers currently tracked (pending). */\n get size(): number {\n return this.ids.size;\n }\n}\n\n/**\n * `setTimeout` wrapper that auto-forgets each timer once it fires and supports\n * bulk teardown on disconnect.\n *\n * @example\n * ```ts\n * #timers = new SafeTimeout();\n *\n * connect() {\n * this.#timers.set(() => this.dismiss(), 5000);\n * }\n *\n * disconnect() {\n * this.#timers.clearAll();\n * }\n * ```\n */\nexport class SafeTimeout extends TimerRegistry {\n /**\n * Schedules `callback` after `delay` ms and returns the timer id.\n *\n * The id is removed from the registry automatically when the timeout fires,\n * so {@link TimerRegistry.size | size} reflects only still-pending timers.\n */\n set(callback: () => void, delay: number): number {\n const id = this.schedule(() => {\n this.ids.delete(id);\n callback();\n }, delay);\n this.ids.add(id);\n return id;\n }\n\n protected schedule(callback: () => void, delay: number): number {\n return window.setTimeout(callback, delay);\n }\n\n protected cancel(id: number): void {\n window.clearTimeout(id);\n }\n}\n\n/**\n * `setInterval` wrapper that tracks every interval for bulk teardown on\n * disconnect. Unlike {@link SafeTimeout}, intervals are retained until they are\n * explicitly cleared because they fire repeatedly.\n *\n * @example\n * ```ts\n * #intervals = new SafeInterval();\n *\n * connect() {\n * this.#intervals.set(() => this.tick(), 1000);\n * }\n *\n * disconnect() {\n * this.#intervals.clearAll();\n * }\n * ```\n */\nexport class SafeInterval extends TimerRegistry {\n /** Schedules a repeating `callback` every `delay` ms and returns the timer id. */\n set(callback: () => void, delay: number): number {\n const id = this.schedule(callback, delay);\n this.ids.add(id);\n return id;\n }\n\n protected schedule(callback: () => void, delay: number): number {\n return window.setInterval(callback, delay);\n }\n\n protected cancel(id: number): void {\n window.clearInterval(id);\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { SafeTimeout } from \"../utils/safe_timeout\";\n\n/** Detail shapes for the ActiveStorage `direct-upload:*` events. */\ninterface UploadDetail {\n id?: string | number;\n file?: { name?: string };\n progress?: number;\n error?: string;\n}\n\n/** Delay (ms) before a completed row is removed when `removeOnDone` is set. */\nconst REMOVE_DELAY = 4000;\n\n/**\n * Headless progress UI for ActiveStorage Direct Uploads: subscribes to the\n * `direct-upload:*` events and renders a per-file progress row (no dedicated APG\n * pattern; the rows follow the `role=\"progressbar\"` practice). The companion to\n * {@link \"file-dropzone\"}, which leaves transport out of scope.\n *\n * Markup contract (identifier: `stimeo--direct-upload`):\n * <div data-controller=\"stimeo--direct-upload\">\n * <div data-stimeo--direct-upload-target=\"list\"></div>\n * <template data-stimeo--direct-upload-target=\"row\">\n * <div role=\"progressbar\" aria-valuemin=\"0\" aria-valuemax=\"100\">\n * <span data-field=\"name\"></span><span data-field=\"percent\"></span>\n * </div>\n * </template>\n * <span data-stimeo--direct-upload-target=\"status\" aria-live=\"polite\"></span>\n * </div>\n *\n * For each upload it clones the `row` template into `list`, then updates\n * `aria-valuenow`, the `[data-field=\"percent\"]` text, and the\n * `--stimeo-upload-progress` custom property as `direct-upload:progress` arrives,\n * and flips `data-upload-state` to `done` / `error`. Completion and failure are\n * announced into the optional `status` live region using the consumer-provided\n * `doneLabel` / `errorLabel` (so copy stays localizable); per-tick progress is not\n * announced (the progressbar's `aria-valuenow` conveys it) to avoid flooding.\n *\n * @remarks\n * Behavior only — no bars are drawn. The `direct-upload:*` listeners live on\n * `document` (the events bubble there) and are removed on `disconnect()` (Turbo\n * navigation included), along with any pending removal timers, so a callback that\n * arrives after teardown never touches a detached row. With multiple upload widgets\n * on one page, set `scope` to a selector for the owning form/root so each widget\n * only handles its own uploads.\n */\nexport class DirectUploadController extends Controller<HTMLElement> {\n static override targets = [\"list\", \"row\", \"status\"];\n static override values = {\n announce: { type: Boolean, default: true },\n removeOnDone: { type: Boolean, default: false },\n doneLabel: { type: String, default: \"\" },\n errorLabel: { type: String, default: \"\" },\n scope: { type: String, default: \"\" },\n };\n static events = [\"progress\", \"done\", \"error\"] as const;\n\n declare readonly listTarget: HTMLElement;\n declare readonly rowTarget: HTMLTemplateElement;\n declare readonly statusTarget: HTMLElement;\n declare readonly hasListTarget: boolean;\n declare readonly hasRowTarget: boolean;\n declare readonly hasStatusTarget: boolean;\n\n declare announceValue: boolean;\n declare removeOnDoneValue: boolean;\n declare doneLabelValue: string;\n declare errorLabelValue: string;\n declare scopeValue: string;\n\n readonly #timeouts = new SafeTimeout();\n readonly #rows = new Map<string, HTMLElement>();\n\n readonly #onInitialize = (event: Event): void => {\n if (!this.#inScope(event)) return;\n const detail = this.#detail(event);\n this.#rowFor(detail.id, detail.file?.name ?? \"\");\n };\n\n readonly #onProgress = (event: Event): void => {\n if (!this.#inScope(event)) return;\n const detail = this.#detail(event);\n this.#updateProgress(this.#key(detail.id), Math.round(detail.progress ?? 0));\n };\n\n readonly #onError = (event: Event): void => {\n if (!this.#inScope(event)) return;\n const detail = this.#detail(event);\n this.#fail(this.#key(detail.id), detail.error ?? \"\");\n };\n\n readonly #onEnd = (event: Event): void => {\n if (!this.#inScope(event)) return;\n const detail = this.#detail(event);\n this.#complete(this.#key(detail.id), detail.file?.name ?? \"\");\n };\n\n override connect(): void {\n document.addEventListener(\"direct-upload:initialize\", this.#onInitialize);\n document.addEventListener(\"direct-upload:progress\", this.#onProgress);\n document.addEventListener(\"direct-upload:error\", this.#onError);\n document.addEventListener(\"direct-upload:end\", this.#onEnd);\n }\n\n override disconnect(): void {\n document.removeEventListener(\"direct-upload:initialize\", this.#onInitialize);\n document.removeEventListener(\"direct-upload:progress\", this.#onProgress);\n document.removeEventListener(\"direct-upload:error\", this.#onError);\n document.removeEventListener(\"direct-upload:end\", this.#onEnd);\n this.#timeouts.clearAll();\n this.#rows.clear();\n }\n\n /** Updates a row's progress and the aggregate, emitting `progress`. */\n #updateProgress(id: string, percent: number): void {\n const row = this.#rowFor(id);\n if (row === null) return;\n const clamped = Math.max(0, Math.min(100, percent));\n row.setAttribute(\"aria-valuenow\", String(clamped));\n row.setAttribute(\"aria-valuetext\", `${clamped}%`);\n row.style.setProperty(\"--stimeo-upload-progress\", `${clamped}%`);\n this.#setField(row, \"percent\", `${clamped}%`);\n this.#syncAggregate();\n this.dispatch(\"progress\", { detail: { id, percent: clamped } });\n }\n\n /** Marks a row done, announces it, and emits `done`. */\n #complete(id: string, name = \"\"): void {\n // Lazily resolve like `#fail`/`#updateProgress` so an `end` that arrives\n // without a prior `initialize`/`progress` (no row yet) still records the\n // completion instead of silently dropping it; the file name (when the event\n // carries one) gives the freshly-created row an accessible label.\n const row = this.#rowFor(id, name);\n if (row === null) return;\n row.setAttribute(\"data-upload-state\", \"done\");\n this.#announce(this.doneLabelValue, row);\n this.dispatch(\"done\", { detail: { id } });\n if (this.removeOnDoneValue) {\n this.#timeouts.set(() => this.#removeRow(id), REMOVE_DELAY);\n }\n }\n\n /** Marks a row failed, announces it, and emits `error`. */\n #fail(id: string, error: string): void {\n const row = this.#rowFor(id);\n if (row === null) return;\n row.setAttribute(\"data-upload-state\", \"error\");\n this.#announce(this.errorLabelValue, row);\n this.dispatch(\"error\", { detail: { id, error } });\n }\n\n /** Returns an existing row or clones one from the template. */\n #rowFor(id: string | number | undefined, name?: string): HTMLElement | null {\n const key = this.#key(id);\n const existing = this.#rows.get(key);\n if (existing !== undefined) return existing;\n if (!this.hasRowTarget || !this.hasListTarget) return null;\n const clone = this.rowTarget.content.firstElementChild?.cloneNode(true);\n if (!(clone instanceof HTMLElement)) return null;\n if (name !== undefined && name.length > 0) {\n this.#setField(clone, \"name\", name);\n // Give the progressbar an accessible name (per-file).\n clone.setAttribute(\"aria-label\", name);\n }\n clone.setAttribute(\"aria-valuenow\", \"0\");\n clone.setAttribute(\"data-upload-state\", \"uploading\");\n clone.style.setProperty(\"--stimeo-upload-progress\", \"0%\");\n this.listTarget.appendChild(clone);\n this.#rows.set(key, clone);\n return clone;\n }\n\n #removeRow(id: string): void {\n const row = this.#rows.get(id);\n if (row === undefined) return;\n row.remove();\n this.#rows.delete(id);\n this.#syncAggregate();\n }\n\n /** Reflects the average progress across rows on the controller element. */\n #syncAggregate(): void {\n if (this.#rows.size === 0) {\n this.element.removeAttribute(\"data-upload-progress\");\n return;\n }\n let total = 0;\n for (const row of this.#rows.values()) {\n total += Number(row.getAttribute(\"aria-valuenow\") ?? \"0\");\n }\n const overall = Math.round(total / this.#rows.size);\n this.element.setAttribute(\"data-upload-progress\", String(overall));\n this.element.style.setProperty(\"--stimeo-upload-progress\", `${overall}%`);\n }\n\n /** Writes a consumer label (with `%{name}` substituted) to the status region. */\n #announce(label: string, row: HTMLElement): void {\n if (!this.announceValue || !this.hasStatusTarget || label.length === 0) return;\n const name = this.#field(row, \"name\")?.textContent ?? \"\";\n this.statusTarget.textContent = label.replace(\"%{name}\", name);\n }\n\n #field(row: HTMLElement, name: string): HTMLElement | null {\n return row.querySelector<HTMLElement>(`[data-field=\"${name}\"]`);\n }\n\n #setField(row: HTMLElement, name: string, text: string): void {\n const field = this.#field(row, name);\n if (field !== null) field.textContent = text;\n }\n\n #detail(event: Event): UploadDetail {\n return (event as CustomEvent<UploadDetail>).detail ?? {};\n }\n\n /**\n * Whether an event belongs to this controller. With `scope` set, only events\n * whose target (the file input) sits inside an element matching `scope` are\n * handled, so several upload widgets on one page do not cross-populate. Resolved\n * with `closest()` from the target itself, so the chatty `progress` stream never\n * pays a document-wide query. Empty `scope` handles all.\n */\n #inScope(event: Event): boolean {\n if (this.scopeValue.length === 0) return true;\n const target = event.target;\n return target instanceof Element && target.closest(this.scopeValue) !== null;\n }\n\n #key(id: string | number | undefined): string {\n return String(id ?? \"\");\n }\n}\n"]}
@@ -0,0 +1,52 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ /**
4
+ * Headless "unsaved changes" guard for a form (no dedicated APG pattern; supports
5
+ * WCAG 2.2 error-prevention 3.3.4 / 3.3.6 by preventing accidental data loss).
6
+ *
7
+ * Markup contract (identifier: `stimeo--dirty-form`):
8
+ * <form data-controller="stimeo--dirty-form">
9
+ * …fields…
10
+ * </form>
11
+ *
12
+ * Snapshots the form's field values on connect, marks the form `data-dirty` once a
13
+ * value changes, and — while dirty — guards both a full unload (`beforeunload`) and
14
+ * a Turbo visit (`turbo:before-visit`). On a Turbo visit it dispatches a cancelable
15
+ * `guard` event; if a consumer cancels it (or `confirmBridge` is set) the visit is
16
+ * blocked, otherwise it falls back to a native `confirm(message)`. While a submit is
17
+ * in flight the guard is suppressed (so a legitimate submit never prompts); the dirty
18
+ * state is cleared only on a successful `turbo:submit-end`, and a failed submit
19
+ * re-arms the guard. `markClean` clears it manually (e.g. after a custom save).
20
+ *
21
+ * @remarks
22
+ * Behavior only — it renders no confirmation UI (pair with a Confirm Bridge) and
23
+ * does not persist input (pair with Persist). The dirty baseline is read from the
24
+ * DOM on `connect()` (no module-scope state) — which also clears a stale
25
+ * `data-dirty` left in a Turbo cache snapshot, since the restored values are the
26
+ * new baseline — `beforeunload` is wired only while dirty, and every listener is
27
+ * removed on `disconnect()` (Turbo navigation included) so a stale guard never
28
+ * outlives the form.
29
+ */
30
+ declare class DirtyFormController extends Controller<HTMLFormElement> {
31
+ #private;
32
+ static values: {
33
+ message: {
34
+ type: StringConstructor;
35
+ default: string;
36
+ };
37
+ confirmBridge: {
38
+ type: BooleanConstructor;
39
+ default: boolean;
40
+ };
41
+ };
42
+ static actions: readonly ["markClean"];
43
+ static events: readonly ["dirty", "guard"];
44
+ messageValue: string;
45
+ confirmBridgeValue: boolean;
46
+ connect(): void;
47
+ disconnect(): void;
48
+ /** Re-baselines to the current values and clears the dirty state (e.g. after a save). */
49
+ markClean(): void;
50
+ }
51
+
52
+ export { DirtyFormController };
@@ -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 @@
1
+ {"version":3,"sources":["../../src/controllers/dirty_form_controller.ts"],"names":[],"mappings":"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","file":"dirty_form_controller.js","sourcesContent":["import { Controller } from \"@hotwired/stimulus\";\n\n/**\n * Headless \"unsaved changes\" guard for a form (no dedicated APG pattern; supports\n * WCAG 2.2 error-prevention 3.3.4 / 3.3.6 by preventing accidental data loss).\n *\n * Markup contract (identifier: `stimeo--dirty-form`):\n * <form data-controller=\"stimeo--dirty-form\">\n * …fields…\n * </form>\n *\n * Snapshots the form's field values on connect, marks the form `data-dirty` once a\n * value changes, and — while dirty — guards both a full unload (`beforeunload`) and\n * a Turbo visit (`turbo:before-visit`). On a Turbo visit it dispatches a cancelable\n * `guard` event; if a consumer cancels it (or `confirmBridge` is set) the visit is\n * blocked, otherwise it falls back to a native `confirm(message)`. While a submit is\n * in flight the guard is suppressed (so a legitimate submit never prompts); the dirty\n * state is cleared only on a successful `turbo:submit-end`, and a failed submit\n * re-arms the guard. `markClean` clears it manually (e.g. after a custom save).\n *\n * @remarks\n * Behavior only — it renders no confirmation UI (pair with a Confirm Bridge) and\n * does not persist input (pair with Persist). The dirty baseline is read from the\n * DOM on `connect()` (no module-scope state) — which also clears a stale\n * `data-dirty` left in a Turbo cache snapshot, since the restored values are the\n * new baseline — `beforeunload` is wired only while dirty, and every listener is\n * removed on `disconnect()` (Turbo navigation included) so a stale guard never\n * outlives the form.\n */\nexport class DirtyFormController extends Controller<HTMLFormElement> {\n static override values = {\n message: { type: String, default: \"You have unsaved changes that will be lost.\" },\n confirmBridge: { type: Boolean, default: false },\n };\n static actions = [\"markClean\"] as const;\n static events = [\"dirty\", \"guard\"] as const;\n\n declare messageValue: string;\n declare confirmBridgeValue: boolean;\n\n #baseline = \"\";\n #dirty = false;\n #beforeunloadBound = false;\n /** True between a form `submit` and its `turbo:submit-end`, suppressing the guard. */\n #submitting = false;\n\n readonly #onFieldChange = (): void => {\n // Any edit means the user is actively working again, so a prior submit that\n // never resolved (e.g. one cancelled client-side, with no turbo:submit-end)\n // must not keep the guard suppressed.\n this.#submitting = false;\n this.#evaluate();\n };\n\n readonly #onSubmit = (): void => {\n // A real submit is leaving on purpose: suppress the guard for the in-flight\n // request. We do NOT clear the baseline yet — a failed submit must stay dirty.\n this.#submitting = true;\n };\n\n readonly #onBeforeVisit = (event: Event): void => {\n this.#guardVisit(event);\n };\n\n readonly #onSubmitEnd = (event: Event): void => {\n this.#submitting = false;\n const success = (event as CustomEvent<{ success?: boolean }>).detail?.success;\n // Only a successful submit clears the dirty state; a failure re-arms the guard.\n if (success !== false) this.markClean();\n };\n\n readonly #onBeforeUnload = (event: BeforeUnloadEvent): void => {\n if (!this.#dirty || this.#submitting) return;\n event.preventDefault();\n // Legacy requirement: a non-empty returnValue triggers the native prompt.\n event.returnValue = this.messageValue;\n };\n\n override connect(): void {\n this.#baseline = this.#serialize();\n // Re-baselining means the restored values ARE the clean state, so a stale\n // data-dirty captured in a Turbo cache snapshot mid-edit must not linger\n // (the guard would not fire, but consumer CSS would keep claiming \"unsaved\").\n this.element.removeAttribute(\"data-dirty\");\n this.element.addEventListener(\"input\", this.#onFieldChange);\n this.element.addEventListener(\"change\", this.#onFieldChange);\n this.element.addEventListener(\"submit\", this.#onSubmit);\n this.element.addEventListener(\"turbo:submit-end\", this.#onSubmitEnd);\n document.addEventListener(\"turbo:before-visit\", this.#onBeforeVisit);\n }\n\n override disconnect(): void {\n this.element.removeEventListener(\"input\", this.#onFieldChange);\n this.element.removeEventListener(\"change\", this.#onFieldChange);\n this.element.removeEventListener(\"submit\", this.#onSubmit);\n this.element.removeEventListener(\"turbo:submit-end\", this.#onSubmitEnd);\n document.removeEventListener(\"turbo:before-visit\", this.#onBeforeVisit);\n this.#unbindBeforeUnload();\n }\n\n /** Re-baselines to the current values and clears the dirty state (e.g. after a save). */\n markClean(): void {\n this.#baseline = this.#serialize();\n this.#setDirty(false);\n }\n\n /** Recomputes dirty against the baseline and flips state when it changes. */\n #evaluate(): void {\n this.#setDirty(this.#serialize() !== this.#baseline);\n }\n\n #setDirty(dirty: boolean): void {\n if (dirty === this.#dirty) return;\n this.#dirty = dirty;\n if (dirty) {\n this.element.setAttribute(\"data-dirty\", \"true\");\n this.#bindBeforeUnload();\n } else {\n this.element.removeAttribute(\"data-dirty\");\n this.#unbindBeforeUnload();\n }\n this.dispatch(\"dirty\", { detail: { dirty } });\n }\n\n /** Guards a Turbo visit while dirty: consumer cancel → confirmBridge → confirm. */\n #guardVisit(event: Event): void {\n if (!this.#dirty || this.#submitting) return;\n const guard = this.dispatch(\"guard\", { detail: { event }, cancelable: true });\n if (guard.defaultPrevented) {\n event.preventDefault();\n return;\n }\n if (this.confirmBridgeValue) {\n // Defer the actual prompt to a consumer-wired Confirm Bridge (via the guard\n // event); block the visit here so nothing navigates before it decides.\n event.preventDefault();\n return;\n }\n if (!window.confirm(this.messageValue)) {\n event.preventDefault();\n }\n }\n\n #bindBeforeUnload(): void {\n if (this.#beforeunloadBound) return;\n window.addEventListener(\"beforeunload\", this.#onBeforeUnload);\n this.#beforeunloadBound = true;\n }\n\n #unbindBeforeUnload(): void {\n if (!this.#beforeunloadBound) return;\n window.removeEventListener(\"beforeunload\", this.#onBeforeUnload);\n this.#beforeunloadBound = false;\n }\n\n /** Stable serialization of the form's controls for change detection. */\n #serialize(): string {\n const parts: string[] = [];\n for (const el of Array.from(this.element.elements)) {\n const name = this.#nameOf(el);\n if (name === null) continue;\n if (el instanceof HTMLInputElement && (el.type === \"checkbox\" || el.type === \"radio\")) {\n parts.push(`${name}:${el.checked ? 1 : 0}`);\n } else if (el instanceof HTMLSelectElement) {\n // Single select: read `value` (robust); multi-select: join the selected values.\n const value = el.multiple\n ? Array.from(el.selectedOptions)\n .map((o) => o.value)\n .join(\",\")\n : el.value;\n parts.push(`${name}:${value}`);\n } else if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {\n parts.push(`${name}:${el.value}`);\n }\n }\n return parts.join(\"|\");\n }\n\n /** A stable key for a control, or null for elements without value semantics. */\n #nameOf(el: Element): string | null {\n if (\n el instanceof HTMLInputElement ||\n el instanceof HTMLTextAreaElement ||\n el instanceof HTMLSelectElement\n ) {\n return el.name || el.id || \"\";\n }\n return null;\n }\n}\n"]}
@@ -0,0 +1,49 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ /**
4
+ * Headless, accessible dropdown menu behavior.
5
+ *
6
+ * Markup contract (identifier: `stimeo--dropdown`):
7
+ * <div data-controller="stimeo--dropdown">
8
+ * <button data-stimeo--dropdown-target="trigger"
9
+ * data-action="stimeo--dropdown#toggle">Menu</button>
10
+ * <div data-stimeo--dropdown-target="menu">...</div>
11
+ * </div>
12
+ *
13
+ * This is a **disclosure** pattern (WAI-ARIA APG): a button toggles the
14
+ * visibility of an adjacent region. It is intentionally *not* a full APG
15
+ * "menu" widget — there is no roving-tabindex arrow-key navigation.
16
+ *
17
+ * @remarks
18
+ * The library owns behavior only (ARIA state, keyboard, focus, outside-click).
19
+ * Visual styling is left entirely to the consumer's CSS.
20
+ *
21
+ * Behavior provided:
22
+ * - Click the trigger to toggle the menu (`aria-expanded` + `hidden` reflect state).
23
+ * - `Escape` closes the menu and returns focus to the trigger.
24
+ * - A click outside the controller element closes the menu.
25
+ */
26
+ declare class DropdownController extends Controller<HTMLElement> {
27
+ #private;
28
+ static targets: string[];
29
+ static actions: readonly ["close", "open", "toggle"];
30
+ readonly triggerTarget: HTMLButtonElement;
31
+ readonly menuTarget: HTMLElement;
32
+ readonly hasMenuTarget: boolean;
33
+ readonly hasTriggerTarget: boolean;
34
+ /**
35
+ * Starts in the closed state and registers the document-level listeners that
36
+ * power outside-click and `Escape` handling.
37
+ */
38
+ connect(): void;
39
+ /** Removes the document-level listeners registered in {@link connect}. */
40
+ disconnect(): void;
41
+ /** Toggles the menu between open and closed. Bound via `data-action`. */
42
+ toggle(): void;
43
+ /** Reveals the menu and reflects the open state on the trigger. */
44
+ open(): void;
45
+ /** Hides the menu and reflects the closed state on the trigger. */
46
+ close(): void;
47
+ }
48
+
49
+ export { DropdownController };
@@ -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