stimeo-ui 0.6.0 → 0.7.0

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 (199) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/dist/controllers/alert_dialog_controller.d.ts +2 -0
  3. package/dist/controllers/alert_dialog_controller.js +75 -4
  4. package/dist/controllers/alert_dialog_controller.js.map +1 -1
  5. package/dist/controllers/auto_submit_controller.d.ts +2 -0
  6. package/dist/controllers/auto_submit_controller.js.map +1 -1
  7. package/dist/controllers/avatar_controller.js +47 -5
  8. package/dist/controllers/avatar_controller.js.map +1 -1
  9. package/dist/controllers/breadcrumb_controller.d.ts +2 -0
  10. package/dist/controllers/breadcrumb_controller.js.map +1 -1
  11. package/dist/controllers/bulk_select_controller.d.ts +2 -0
  12. package/dist/controllers/bulk_select_controller.js.map +1 -1
  13. package/dist/controllers/calendar_controller.d.ts +2 -0
  14. package/dist/controllers/calendar_controller.js.map +1 -1
  15. package/dist/controllers/character_counter_controller.d.ts +52 -20
  16. package/dist/controllers/character_counter_controller.js +338 -63
  17. package/dist/controllers/character_counter_controller.js.map +1 -1
  18. package/dist/controllers/checkbox_controller.d.ts +3 -0
  19. package/dist/controllers/checkbox_controller.js.map +1 -1
  20. package/dist/controllers/color_picker_controller.d.ts +3 -0
  21. package/dist/controllers/color_picker_controller.js.map +1 -1
  22. package/dist/controllers/combobox_controller.d.ts +2 -0
  23. package/dist/controllers/combobox_controller.js.map +1 -1
  24. package/dist/controllers/command_palette_controller.d.ts +2 -0
  25. package/dist/controllers/command_palette_controller.js +75 -4
  26. package/dist/controllers/command_palette_controller.js.map +1 -1
  27. package/dist/controllers/conditional_fields_controller.d.ts +42 -14
  28. package/dist/controllers/conditional_fields_controller.js +345 -51
  29. package/dist/controllers/conditional_fields_controller.js.map +1 -1
  30. package/dist/controllers/confirm_controller.d.ts +2 -0
  31. package/dist/controllers/confirm_controller.js +75 -4
  32. package/dist/controllers/confirm_controller.js.map +1 -1
  33. package/dist/controllers/count_up_controller.d.ts +2 -0
  34. package/dist/controllers/count_up_controller.js.map +1 -1
  35. package/dist/controllers/countdown_controller.d.ts +4 -0
  36. package/dist/controllers/countdown_controller.js.map +1 -1
  37. package/dist/controllers/currency_input_controller.d.ts +2 -0
  38. package/dist/controllers/currency_input_controller.js.map +1 -1
  39. package/dist/controllers/data_grid_controller.d.ts +3 -0
  40. package/dist/controllers/data_grid_controller.js.map +1 -1
  41. package/dist/controllers/dialog_controller.js +75 -4
  42. package/dist/controllers/dialog_controller.js.map +1 -1
  43. package/dist/controllers/direct_upload_controller.d.ts +56 -24
  44. package/dist/controllers/direct_upload_controller.js +201 -45
  45. package/dist/controllers/direct_upload_controller.js.map +1 -1
  46. package/dist/controllers/dirty_form_controller.d.ts +14 -6
  47. package/dist/controllers/dirty_form_controller.js +192 -29
  48. package/dist/controllers/dirty_form_controller.js.map +1 -1
  49. package/dist/controllers/dismissible_controller.d.ts +2 -0
  50. package/dist/controllers/dismissible_controller.js +83 -18
  51. package/dist/controllers/dismissible_controller.js.map +1 -1
  52. package/dist/controllers/drawer_controller.js +75 -4
  53. package/dist/controllers/drawer_controller.js.map +1 -1
  54. package/dist/controllers/empty_state_controller.d.ts +2 -0
  55. package/dist/controllers/empty_state_controller.js.map +1 -1
  56. package/dist/controllers/file_dropzone_controller.d.ts +2 -0
  57. package/dist/controllers/file_dropzone_controller.js.map +1 -1
  58. package/dist/controllers/filter_controller.d.ts +2 -0
  59. package/dist/controllers/filter_controller.js.map +1 -1
  60. package/dist/controllers/flash_controller.d.ts +4 -0
  61. package/dist/controllers/flash_controller.js.map +1 -1
  62. package/dist/controllers/focus_controller.d.ts +2 -0
  63. package/dist/controllers/focus_controller.js +75 -4
  64. package/dist/controllers/focus_controller.js.map +1 -1
  65. package/dist/controllers/form_field_controller.d.ts +50 -10
  66. package/dist/controllers/form_field_controller.js +280 -62
  67. package/dist/controllers/form_field_controller.js.map +1 -1
  68. package/dist/controllers/form_validation_controller.d.ts +10 -8
  69. package/dist/controllers/form_validation_controller.js +208 -83
  70. package/dist/controllers/form_validation_controller.js.map +1 -1
  71. package/dist/controllers/frame_loading_controller.d.ts +2 -0
  72. package/dist/controllers/frame_loading_controller.js.map +1 -1
  73. package/dist/controllers/highlight_controller.d.ts +2 -0
  74. package/dist/controllers/highlight_controller.js.map +1 -1
  75. package/dist/controllers/idle_controller.d.ts +2 -0
  76. package/dist/controllers/idle_controller.js.map +1 -1
  77. package/dist/controllers/input_mask_controller.d.ts +2 -0
  78. package/dist/controllers/input_mask_controller.js.map +1 -1
  79. package/dist/controllers/lazy_frame_controller.d.ts +2 -0
  80. package/dist/controllers/lazy_frame_controller.js.map +1 -1
  81. package/dist/controllers/listbox_controller.d.ts +2 -0
  82. package/dist/controllers/listbox_controller.js.map +1 -1
  83. package/dist/controllers/local_time_controller.d.ts +2 -0
  84. package/dist/controllers/local_time_controller.js.map +1 -1
  85. package/dist/controllers/masonry_controller.d.ts +2 -0
  86. package/dist/controllers/masonry_controller.js.map +1 -1
  87. package/dist/controllers/meter_controller.d.ts +2 -0
  88. package/dist/controllers/meter_controller.js.map +1 -1
  89. package/dist/controllers/multi_select_controller.d.ts +2 -0
  90. package/dist/controllers/multi_select_controller.js.map +1 -1
  91. package/dist/controllers/nested_form_controller.d.ts +2 -0
  92. package/dist/controllers/nested_form_controller.js.map +1 -1
  93. package/dist/controllers/network_status_controller.d.ts +2 -0
  94. package/dist/controllers/network_status_controller.js.map +1 -1
  95. package/dist/controllers/number_input_controller.d.ts +2 -0
  96. package/dist/controllers/number_input_controller.js +47 -5
  97. package/dist/controllers/number_input_controller.js.map +1 -1
  98. package/dist/controllers/otp_controller.d.ts +3 -0
  99. package/dist/controllers/otp_controller.js.map +1 -1
  100. package/dist/controllers/overflow_indicator_controller.d.ts +2 -0
  101. package/dist/controllers/overflow_indicator_controller.js.map +1 -1
  102. package/dist/controllers/overflow_menu_controller.d.ts +2 -0
  103. package/dist/controllers/overflow_menu_controller.js +2 -1
  104. package/dist/controllers/overflow_menu_controller.js.map +1 -1
  105. package/dist/controllers/pagination_controller.d.ts +2 -0
  106. package/dist/controllers/pagination_controller.js +2 -1
  107. package/dist/controllers/pagination_controller.js.map +1 -1
  108. package/dist/controllers/password_reveal_controller.d.ts +2 -0
  109. package/dist/controllers/password_reveal_controller.js.map +1 -1
  110. package/dist/controllers/password_strength_controller.d.ts +2 -0
  111. package/dist/controllers/password_strength_controller.js.map +1 -1
  112. package/dist/controllers/persist_controller.d.ts +30 -16
  113. package/dist/controllers/persist_controller.js +432 -122
  114. package/dist/controllers/persist_controller.js.map +1 -1
  115. package/dist/controllers/pointer_drag_controller.d.ts +3 -0
  116. package/dist/controllers/pointer_drag_controller.js.map +1 -1
  117. package/dist/controllers/popover_controller.js +77 -4
  118. package/dist/controllers/popover_controller.js.map +1 -1
  119. package/dist/controllers/portal_controller.d.ts +2 -0
  120. package/dist/controllers/portal_controller.js.map +1 -1
  121. package/dist/controllers/preview_guard_controller.d.ts +2 -0
  122. package/dist/controllers/preview_guard_controller.js.map +1 -1
  123. package/dist/controllers/progress_controller.d.ts +3 -0
  124. package/dist/controllers/progress_controller.js.map +1 -1
  125. package/dist/controllers/radio_group_controller.d.ts +3 -0
  126. package/dist/controllers/radio_group_controller.js.map +1 -1
  127. package/dist/controllers/rating_controller.d.ts +2 -0
  128. package/dist/controllers/rating_controller.js +9 -5
  129. package/dist/controllers/rating_controller.js.map +1 -1
  130. package/dist/controllers/reading_progress_controller.d.ts +2 -0
  131. package/dist/controllers/reading_progress_controller.js.map +1 -1
  132. package/dist/controllers/resizable_controller.d.ts +2 -0
  133. package/dist/controllers/resizable_controller.js.map +1 -1
  134. package/dist/controllers/roving_controller.d.ts +2 -0
  135. package/dist/controllers/roving_controller.js.map +1 -1
  136. package/dist/controllers/scroll_area_controller.d.ts +23 -15
  137. package/dist/controllers/scroll_area_controller.js +462 -162
  138. package/dist/controllers/scroll_area_controller.js.map +1 -1
  139. package/dist/controllers/scroll_visibility_controller.d.ts +2 -0
  140. package/dist/controllers/scroll_visibility_controller.js.map +1 -1
  141. package/dist/controllers/scrollspy_controller.d.ts +2 -0
  142. package/dist/controllers/scrollspy_controller.js.map +1 -1
  143. package/dist/controllers/separator_controller.d.ts +54 -11
  144. package/dist/controllers/separator_controller.js +354 -38
  145. package/dist/controllers/separator_controller.js.map +1 -1
  146. package/dist/controllers/sidebar_controller.js +83 -10
  147. package/dist/controllers/sidebar_controller.js.map +1 -1
  148. package/dist/controllers/skeleton_controller.d.ts +2 -0
  149. package/dist/controllers/skeleton_controller.js.map +1 -1
  150. package/dist/controllers/slider_controller.d.ts +2 -0
  151. package/dist/controllers/slider_controller.js.map +1 -1
  152. package/dist/controllers/smart_sticky_header_controller.d.ts +2 -0
  153. package/dist/controllers/smart_sticky_header_controller.js.map +1 -1
  154. package/dist/controllers/spinner_controller.d.ts +2 -0
  155. package/dist/controllers/spinner_controller.js.map +1 -1
  156. package/dist/controllers/step_indicator_controller.d.ts +2 -0
  157. package/dist/controllers/step_indicator_controller.js.map +1 -1
  158. package/dist/controllers/stepper_controller.d.ts +2 -0
  159. package/dist/controllers/stepper_controller.js.map +1 -1
  160. package/dist/controllers/stick_to_bottom_controller.d.ts +2 -0
  161. package/dist/controllers/stick_to_bottom_controller.js.map +1 -1
  162. package/dist/controllers/sticky_observer_controller.d.ts +2 -0
  163. package/dist/controllers/sticky_observer_controller.js.map +1 -1
  164. package/dist/controllers/submit_once_controller.d.ts +94 -38
  165. package/dist/controllers/submit_once_controller.js +399 -121
  166. package/dist/controllers/submit_once_controller.js.map +1 -1
  167. package/dist/controllers/switch_controller.d.ts +2 -0
  168. package/dist/controllers/switch_controller.js.map +1 -1
  169. package/dist/controllers/tags_input_controller.d.ts +2 -0
  170. package/dist/controllers/tags_input_controller.js.map +1 -1
  171. package/dist/controllers/textarea_autosize_controller.d.ts +2 -0
  172. package/dist/controllers/textarea_autosize_controller.js.map +1 -1
  173. package/dist/controllers/theme_controller.d.ts +2 -0
  174. package/dist/controllers/theme_controller.js +8 -6
  175. package/dist/controllers/theme_controller.js.map +1 -1
  176. package/dist/controllers/time_picker_controller.d.ts +2 -0
  177. package/dist/controllers/time_picker_controller.js.map +1 -1
  178. package/dist/controllers/toast_controller.d.ts +4 -0
  179. package/dist/controllers/toast_controller.js.map +1 -1
  180. package/dist/controllers/toggle_group_controller.d.ts +2 -0
  181. package/dist/controllers/toggle_group_controller.js.map +1 -1
  182. package/dist/controllers/toolbar_controller.js.map +1 -1
  183. package/dist/controllers/transition_controller.d.ts +2 -0
  184. package/dist/controllers/transition_controller.js.map +1 -1
  185. package/dist/controllers/tree_view_controller.d.ts +2 -0
  186. package/dist/controllers/tree_view_controller.js +2 -1
  187. package/dist/controllers/tree_view_controller.js.map +1 -1
  188. package/dist/index.js +2387 -861
  189. package/dist/index.js.map +1 -1
  190. package/dist/inspector/cli.d.ts +107 -3
  191. package/dist/inspector/cli.js +192 -14
  192. package/dist/inspector/cli.js.map +1 -1
  193. package/dist/inspector/cli_bin.js +254 -46
  194. package/dist/inspector/cli_bin.js.map +1 -1
  195. package/dist/inspector/examples.json +7 -7
  196. package/dist/inspector/manifest.json +434 -44
  197. package/dist/positioning/index.d.ts +2 -0
  198. package/dist/positioning/index.js.map +1 -1
  199. package/package.json +1 -1
@@ -1,60 +1,220 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/dirty_form_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/dirty_form_controller.ts
59
+ var visitCoordinators = /* @__PURE__ */ new WeakMap();
60
+ var TurboVisitCoordinator = class {
61
+ #document;
62
+ #participants = /* @__PURE__ */ new Set();
63
+ #onBeforeVisit = (event) => {
64
+ let blocked = event.defaultPrevented;
65
+ let message;
66
+ for (const participant of this.#participantsInDomOrder()) {
67
+ if (!participant.form.isConnected) continue;
68
+ const decision = participant.evaluate(event);
69
+ blocked ||= event.defaultPrevented;
70
+ if (decision === null) continue;
71
+ if (decision.kind === "block") {
72
+ blocked = true;
73
+ } else {
74
+ message ??= decision.message;
75
+ }
76
+ }
77
+ if (blocked) {
78
+ event.preventDefault();
79
+ return;
80
+ }
81
+ if (message === void 0) return;
82
+ if (!this.#document.defaultView?.confirm(message)) event.preventDefault();
83
+ };
84
+ constructor(document) {
85
+ this.#document = document;
86
+ }
87
+ add(participant) {
88
+ if (this.#participants.size === 0) {
89
+ this.#document.addEventListener("turbo:before-visit", this.#onBeforeVisit);
90
+ }
91
+ this.#participants.add(participant);
92
+ }
93
+ /** Removes a participant and reports whether the coordinator became empty. */
94
+ remove(participant) {
95
+ this.#participants.delete(participant);
96
+ if (this.#participants.size > 0) return false;
97
+ this.#document.removeEventListener("turbo:before-visit", this.#onBeforeVisit);
98
+ return true;
99
+ }
100
+ #participantsInDomOrder() {
101
+ return Array.from(this.#participants).filter(
102
+ (participant) => participant.form.isConnected && participant.form.ownerDocument === this.#document
103
+ ).sort((left, right) => {
104
+ const position = left.form.compareDocumentPosition(right.form);
105
+ return Number(Boolean(position & Node.DOCUMENT_POSITION_PRECEDING)) - Number(Boolean(position & Node.DOCUMENT_POSITION_FOLLOWING));
106
+ });
107
+ }
108
+ };
109
+ function registerVisitParticipant(document, participant) {
110
+ const coordinator = visitCoordinators.get(document) ?? new TurboVisitCoordinator(document);
111
+ visitCoordinators.set(document, coordinator);
112
+ coordinator.add(participant);
113
+ return () => {
114
+ if (coordinator.remove(participant)) visitCoordinators.delete(document);
115
+ };
116
+ }
4
117
  var DirtyFormController = class extends Controller {
5
118
  static values = {
6
119
  message: { type: String, default: "You have unsaved changes that will be lost." },
7
120
  confirmBridge: { type: Boolean, default: false }
8
121
  };
9
- static actions = ["markClean"];
122
+ static actions = ["markClean", "acceptRestore"];
10
123
  static events = ["dirty", "guard"];
11
124
  #baseline = "";
12
125
  #dirty = false;
13
126
  #beforeunloadBound = false;
14
- /** True between a form `submit` and its `turbo:submit-end`, suppressing the guard. */
127
+ #timeouts = new SafeTimeout();
128
+ /** Whether the active submission still represents the form's current values. */
15
129
  #submitting = false;
130
+ #submitAttempt = null;
131
+ #submittedBaseline = null;
132
+ #activeSubmission = null;
133
+ #unregisterVisit = null;
16
134
  #onFieldChange = () => {
17
135
  this.#submitting = false;
18
136
  this.#evaluate();
19
137
  };
20
- #onSubmit = () => {
138
+ #onSubmit = (event) => {
139
+ const attempt = { event, snapshot: this.#serialize() };
140
+ this.#submitAttempt = attempt;
21
141
  this.#submitting = true;
142
+ this.#timeouts.set(() => {
143
+ if (this.#submitAttempt !== attempt) return;
144
+ this.#submitting = false;
145
+ }, 0);
22
146
  };
23
- #onBeforeVisit = (event) => {
24
- this.#guardVisit(event);
147
+ #onFormData = () => {
148
+ if (this.#submitAttempt === null) return;
149
+ this.#submitAttempt.snapshot = this.#serialize();
150
+ };
151
+ #onSubmitStart = (event) => {
152
+ const detail = event.detail;
153
+ this.#activeSubmission = detail?.formSubmission ?? event;
154
+ this.#submittedBaseline = this.#submitAttempt?.snapshot ?? this.#serialize();
155
+ this.#submitAttempt = null;
156
+ this.#submitting = this.#serialize() === this.#submittedBaseline;
25
157
  };
26
158
  #onSubmitEnd = (event) => {
159
+ const detail = event.detail;
160
+ if (detail?.formSubmission !== void 0 && (this.#activeSubmission === null || detail.formSubmission !== this.#activeSubmission)) {
161
+ return;
162
+ }
163
+ const submittedBaseline = this.#submittedBaseline;
27
164
  this.#submitting = false;
28
- const success = event.detail?.success;
29
- if (success !== false) this.markClean();
165
+ this.#submittedBaseline = null;
166
+ this.#activeSubmission = null;
167
+ if (detail?.success === false) return;
168
+ if (submittedBaseline === null) {
169
+ this.markClean();
170
+ return;
171
+ }
172
+ this.#baseline = submittedBaseline;
173
+ this.#evaluate();
30
174
  };
31
175
  #onBeforeUnload = (event) => {
32
- if (!this.#dirty || this.#submitting) return;
176
+ if (!this.#dirty || this.#guardSuppressed()) return;
33
177
  event.preventDefault();
34
178
  event.returnValue = this.messageValue;
35
179
  };
36
180
  connect() {
181
+ this.#resetSubmission();
37
182
  this.#baseline = this.#serialize();
183
+ this.#setDirty(false);
38
184
  this.element.removeAttribute("data-dirty");
39
185
  this.element.addEventListener("input", this.#onFieldChange);
40
186
  this.element.addEventListener("change", this.#onFieldChange);
41
187
  this.element.addEventListener("submit", this.#onSubmit);
188
+ this.element.addEventListener("formdata", this.#onFormData);
189
+ this.element.addEventListener("turbo:submit-start", this.#onSubmitStart);
42
190
  this.element.addEventListener("turbo:submit-end", this.#onSubmitEnd);
43
- document.addEventListener("turbo:before-visit", this.#onBeforeVisit);
191
+ this.#unregisterVisit = registerVisitParticipant(this.element.ownerDocument, {
192
+ form: this.element,
193
+ evaluate: (event) => this.#evaluateVisit(event)
194
+ });
44
195
  }
45
196
  disconnect() {
46
197
  this.element.removeEventListener("input", this.#onFieldChange);
47
198
  this.element.removeEventListener("change", this.#onFieldChange);
48
199
  this.element.removeEventListener("submit", this.#onSubmit);
200
+ this.element.removeEventListener("formdata", this.#onFormData);
201
+ this.element.removeEventListener("turbo:submit-start", this.#onSubmitStart);
49
202
  this.element.removeEventListener("turbo:submit-end", this.#onSubmitEnd);
50
- document.removeEventListener("turbo:before-visit", this.#onBeforeVisit);
203
+ this.#unregisterVisit?.();
204
+ this.#unregisterVisit = null;
51
205
  this.#unbindBeforeUnload();
206
+ this.#resetSubmission();
52
207
  }
53
208
  /** Re-baselines to the current values and clears the dirty state (e.g. after a save). */
54
209
  markClean() {
55
210
  this.#baseline = this.#serialize();
56
211
  this.#setDirty(false);
57
212
  }
213
+ /** Adopts restored values without erasing an already-dirty user revision. */
214
+ acceptRestore() {
215
+ if (this.#dirty) return;
216
+ this.markClean();
217
+ }
58
218
  /** Recomputes dirty against the baseline and flips state when it changes. */
59
219
  #evaluate() {
60
220
  this.#setDirty(this.#serialize() !== this.#baseline);
@@ -71,21 +231,14 @@ var DirtyFormController = class extends Controller {
71
231
  }
72
232
  this.dispatch("dirty", { detail: { dirty } });
73
233
  }
74
- /** Guards a Turbo visit while dirty: consumer cancel → confirmBridge → confirm. */
75
- #guardVisit(event) {
76
- if (!this.#dirty || this.#submitting) return;
234
+ /** Evaluates this form for the document-level Turbo visit coordinator. */
235
+ #evaluateVisit(event) {
236
+ if (!this.#dirty || this.#guardSuppressed()) return null;
77
237
  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
- }
238
+ if (guard.defaultPrevented) return { kind: "block" };
239
+ if (!this.element.isConnected || !this.#dirty || this.#guardSuppressed()) return null;
240
+ if (this.confirmBridgeValue) return { kind: "block" };
241
+ return { kind: "confirm", message: this.messageValue };
89
242
  }
90
243
  #bindBeforeUnload() {
91
244
  if (this.#beforeunloadBound) return;
@@ -97,6 +250,16 @@ var DirtyFormController = class extends Controller {
97
250
  window.removeEventListener("beforeunload", this.#onBeforeUnload);
98
251
  this.#beforeunloadBound = false;
99
252
  }
253
+ #guardSuppressed() {
254
+ return this.#submitting && (this.#submitAttempt === null || !this.#submitAttempt.event.defaultPrevented);
255
+ }
256
+ #resetSubmission() {
257
+ this.#timeouts.clearAll();
258
+ this.#submitting = false;
259
+ this.#submitAttempt = null;
260
+ this.#submittedBaseline = null;
261
+ this.#activeSubmission = null;
262
+ }
100
263
  /** Stable serialization of the form's controls for change detection. */
101
264
  #serialize() {
102
265
  const parts = [];
@@ -104,15 +267,15 @@ var DirtyFormController = class extends Controller {
104
267
  const name = this.#nameOf(el);
105
268
  if (name === null) continue;
106
269
  if (el instanceof HTMLInputElement && (el.type === "checkbox" || el.type === "radio")) {
107
- parts.push(`${name}:${el.checked ? 1 : 0}`);
270
+ parts.push([el.type, name, el.value, el.checked ? 1 : 0]);
108
271
  } 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}`);
272
+ const value = el.multiple ? Array.from(el.selectedOptions).map((option) => option.value) : el.value;
273
+ parts.push([el.type, name, value]);
111
274
  } else if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
112
- parts.push(`${name}:${el.value}`);
275
+ parts.push([el.type, name, el.value]);
113
276
  }
114
277
  }
115
- return parts.join("|");
278
+ return JSON.stringify(parts);
116
279
  }
117
280
  /** A stable key for a control, or null for elements without value semantics. */
118
281
  #nameOf(el) {
@@ -1 +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"]}
1
+ {"version":3,"sources":["../../src/utils/safe_timeout.ts","../../src/controllers/dirty_form_controller.ts"],"names":[],"mappings":"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","file":"dirty_form_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\ntype VisitDecision =\n | { readonly kind: \"block\" }\n | { readonly kind: \"confirm\"; readonly message: string }\n | null;\n\ninterface VisitParticipant {\n readonly form: HTMLFormElement;\n readonly evaluate: (event: Event) => VisitDecision;\n}\n\ninterface SubmitAttempt {\n readonly event: Event;\n snapshot: string;\n}\n\ninterface TurboSubmitDetail {\n readonly formSubmission?: unknown;\n readonly success?: boolean;\n}\n\ntype SerializedControl = readonly [\n type: string,\n name: string,\n value: string | readonly string[],\n checked?: 0 | 1,\n];\n\n/**\n * Coordinates native Turbo confirmations for every dirty form in one document.\n *\n * The weak document registry owns no dirty baseline or durable form state. Each\n * controller remains the source of truth for its own state and unregisters on\n * disconnect; the shared coordinator only gives one navigation event a single,\n * deterministic decision point.\n */\nconst visitCoordinators = new WeakMap<Document, TurboVisitCoordinator>();\n\nclass TurboVisitCoordinator {\n readonly #document: Document;\n readonly #participants = new Set<VisitParticipant>();\n\n readonly #onBeforeVisit = (event: Event): void => {\n let blocked = event.defaultPrevented;\n let message: string | undefined;\n\n // Every eligible form gets its public guard event before a native prompt.\n // A later consumer can therefore block the visit without an earlier form\n // opening a redundant confirm first.\n for (const participant of this.#participantsInDomOrder()) {\n if (!participant.form.isConnected) continue;\n const decision = participant.evaluate(event);\n blocked ||= event.defaultPrevented;\n if (decision === null) continue;\n if (decision.kind === \"block\") {\n blocked = true;\n } else {\n message ??= decision.message;\n }\n }\n\n if (blocked) {\n event.preventDefault();\n return;\n }\n if (message === undefined) return;\n if (!this.#document.defaultView?.confirm(message)) event.preventDefault();\n };\n\n constructor(document: Document) {\n this.#document = document;\n }\n\n add(participant: VisitParticipant): void {\n if (this.#participants.size === 0) {\n this.#document.addEventListener(\"turbo:before-visit\", this.#onBeforeVisit);\n }\n this.#participants.add(participant);\n }\n\n /** Removes a participant and reports whether the coordinator became empty. */\n remove(participant: VisitParticipant): boolean {\n this.#participants.delete(participant);\n if (this.#participants.size > 0) return false;\n this.#document.removeEventListener(\"turbo:before-visit\", this.#onBeforeVisit);\n return true;\n }\n\n #participantsInDomOrder(): VisitParticipant[] {\n return Array.from(this.#participants)\n .filter(\n (participant) =>\n participant.form.isConnected && participant.form.ownerDocument === this.#document,\n )\n .sort((left, right) => {\n const position = left.form.compareDocumentPosition(right.form);\n return (\n Number(Boolean(position & Node.DOCUMENT_POSITION_PRECEDING)) -\n Number(Boolean(position & Node.DOCUMENT_POSITION_FOLLOWING))\n );\n });\n }\n}\n\nfunction registerVisitParticipant(document: Document, participant: VisitParticipant): () => void {\n const coordinator = visitCoordinators.get(document) ?? new TurboVisitCoordinator(document);\n visitCoordinators.set(document, coordinator);\n coordinator.add(participant);\n\n return () => {\n if (coordinator.remove(participant)) visitCoordinators.delete(document);\n };\n}\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 for each eligible form; if a consumer cancels one (or any form uses\n * `confirmBridge`) the visit is blocked, otherwise the document falls back to one\n * native `confirm(message)`, using the first eligible form in live DOM order. While a\n * submit is in flight the guard is suppressed until another edit occurs (so a\n * legitimate submit never prompts); the browser's `formdata` event captures the exact\n * revision Turbo materializes, and a successful `turbo:submit-end` accepts only that\n * revision, preserving any later edits as dirty. A failed submit re-arms the guard;\n * `markClean` clears it manually (e.g. after a custom save), while `acceptRestore`\n * adopts a restored draft only while no user edit is already dirty.\n *\n * `dirty` dispatches `{ dirty }`; `guard` dispatches `{ event }`.\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\", \"acceptRestore\"] 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 readonly #timeouts = new SafeTimeout();\n /** Whether the active submission still represents the form's current values. */\n #submitting = false;\n #submitAttempt: SubmitAttempt | null = null;\n #submittedBaseline: string | null = null;\n #activeSubmission: unknown | null = null;\n #unregisterVisit: (() => void) | null = null;\n\n readonly #onFieldChange = (): void => {\n // An edit after submission belongs to a new unsaved revision. Keep the\n // submitted snapshot for submit-end, but stop suppressing navigation now.\n this.#submitting = false;\n this.#evaluate();\n };\n\n readonly #onSubmit = (event: Event): void => {\n const attempt: SubmitAttempt = { event, snapshot: this.#serialize() };\n this.#submitAttempt = attempt;\n this.#submitting = true;\n // Native navigation runs before the next task. If it does not navigate and\n // Turbo never claims the attempt, the form must not remain suppressed.\n this.#timeouts.set(() => {\n if (this.#submitAttempt !== attempt) return;\n this.#submitting = false;\n }, 0);\n };\n\n readonly #onFormData = (): void => {\n if (this.#submitAttempt === null) return;\n // Turbo constructs FormData after target-level submit consumers have run.\n // Capturing here reflects exactly that revision, even when an asynchronous\n // confirmation delays turbo:submit-start and the user edits in the meantime.\n this.#submitAttempt.snapshot = this.#serialize();\n };\n\n readonly #onSubmitStart = (event: Event): void => {\n const detail = (event as CustomEvent<TurboSubmitDetail>).detail;\n this.#activeSubmission = detail?.formSubmission ?? event;\n this.#submittedBaseline = this.#submitAttempt?.snapshot ?? this.#serialize();\n this.#submitAttempt = null;\n this.#submitting = this.#serialize() === this.#submittedBaseline;\n };\n\n readonly #onSubmitEnd = (event: Event): void => {\n const detail = (event as CustomEvent<TurboSubmitDetail>).detail;\n if (\n detail?.formSubmission !== undefined &&\n (this.#activeSubmission === null || detail.formSubmission !== this.#activeSubmission)\n ) {\n return;\n }\n const submittedBaseline = this.#submittedBaseline;\n this.#submitting = false;\n this.#submittedBaseline = null;\n this.#activeSubmission = null;\n if (detail?.success === false) return;\n if (submittedBaseline === null) {\n this.markClean();\n return;\n }\n // The server accepted the submitted revision, not edits made after it left.\n this.#baseline = submittedBaseline;\n this.#evaluate();\n };\n\n readonly #onBeforeUnload = (event: BeforeUnloadEvent): void => {\n if (!this.#dirty || this.#guardSuppressed()) 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.#resetSubmission();\n this.#baseline = this.#serialize();\n this.#setDirty(false);\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(\"formdata\", this.#onFormData);\n this.element.addEventListener(\"turbo:submit-start\", this.#onSubmitStart);\n this.element.addEventListener(\"turbo:submit-end\", this.#onSubmitEnd);\n this.#unregisterVisit = registerVisitParticipant(this.element.ownerDocument, {\n form: this.element,\n evaluate: (event) => this.#evaluateVisit(event),\n });\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(\"formdata\", this.#onFormData);\n this.element.removeEventListener(\"turbo:submit-start\", this.#onSubmitStart);\n this.element.removeEventListener(\"turbo:submit-end\", this.#onSubmitEnd);\n this.#unregisterVisit?.();\n this.#unregisterVisit = null;\n this.#unbindBeforeUnload();\n this.#resetSubmission();\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 /** Adopts restored values without erasing an already-dirty user revision. */\n acceptRestore(): void {\n if (this.#dirty) return;\n this.markClean();\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 /** Evaluates this form for the document-level Turbo visit coordinator. */\n #evaluateVisit(event: Event): VisitDecision {\n if (!this.#dirty || this.#guardSuppressed()) return null;\n const guard = this.dispatch(\"guard\", { detail: { event }, cancelable: true });\n if (guard.defaultPrevented) return { kind: \"block\" };\n // A guard consumer may have saved, submitted, or removed the form synchronously.\n if (!this.element.isConnected || !this.#dirty || this.#guardSuppressed()) return null;\n if (this.confirmBridgeValue) return { kind: \"block\" };\n return { kind: \"confirm\", message: this.messageValue };\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 #guardSuppressed(): boolean {\n return (\n this.#submitting &&\n (this.#submitAttempt === null || !this.#submitAttempt.event.defaultPrevented)\n );\n }\n\n #resetSubmission(): void {\n this.#timeouts.clearAll();\n this.#submitting = false;\n this.#submitAttempt = null;\n this.#submittedBaseline = null;\n this.#activeSubmission = null;\n }\n\n /** Stable serialization of the form's controls for change detection. */\n #serialize(): string {\n const parts: SerializedControl[] = [];\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([el.type, name, el.value, el.checked ? 1 : 0]);\n } else if (el instanceof HTMLSelectElement) {\n // Keep multi-select values structural so option values containing commas cannot collide.\n const value = el.multiple\n ? Array.from(el.selectedOptions).map((option) => option.value)\n : el.value;\n parts.push([el.type, name, value]);\n } else if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {\n parts.push([el.type, name, el.value]);\n }\n }\n return JSON.stringify(parts);\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"]}
@@ -18,6 +18,8 @@ import { Controller } from '@hotwired/stimulus';
18
18
  * it is moved to a safe place first so the close button vanishing never strands
19
19
  * the user (WCAG 2.4.3).
20
20
  *
21
+ * `dismiss` dispatches `{ mode: "remove" | "hide" }`.
22
+ *
21
23
  * @remarks
22
24
  * Behavior only — the consumer owns any exit transition (use `hide` mode, which
23
25
  * adds `hidden`, and animate off `data-state`) and any semantics like
@@ -2,8 +2,79 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/dismissible_controller.ts
4
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"])';
5
+ // src/utils/focus_candidate.ts
6
+ function inheritsFieldsetDisabled(control) {
7
+ let fieldset = control.closest("fieldset[disabled]");
8
+ while (fieldset) {
9
+ const legend = Array.from(fieldset.children).find((child) => child.tagName === "LEGEND");
10
+ if (!legend?.contains(control)) return true;
11
+ fieldset = fieldset.parentElement?.closest("fieldset[disabled]") ?? null;
12
+ }
13
+ return false;
14
+ }
15
+ function canTakeFocus(element) {
16
+ if (element.closest("[hidden], [inert]")) return false;
17
+ if (element instanceof HTMLInputElement && element.type === "hidden") return false;
18
+ if (!("disabled" in element)) return true;
19
+ if (element.disabled) return false;
20
+ return !inheritsFieldsetDisabled(element);
21
+ }
22
+ var TAB_STOP_CANDIDATE_SELECTOR = [
23
+ "a[href]",
24
+ "area[href]",
25
+ "button",
26
+ "input",
27
+ "select",
28
+ "textarea",
29
+ "summary",
30
+ "iframe",
31
+ "audio[controls]",
32
+ "video[controls]",
33
+ "[tabindex]",
34
+ "[contenteditable]"
35
+ ].join(",");
36
+ function isRenderedForFocus(element) {
37
+ const check = element.checkVisibility;
38
+ return typeof check === "function" ? check.call(element, { visibilityProperty: true }) : true;
39
+ }
40
+ function authoredTabindex(element) {
41
+ const value = element.getAttribute("tabindex");
42
+ if (value === null || !/^[+-]?\d+$/.test(value.trim())) return null;
43
+ return Number(value);
44
+ }
45
+ function hasNativeTabStop(element) {
46
+ if (element instanceof HTMLAnchorElement || element instanceof HTMLAreaElement) {
47
+ return element.hasAttribute("href");
48
+ }
49
+ if (element instanceof HTMLButtonElement || element instanceof HTMLSelectElement || element instanceof HTMLTextAreaElement) {
50
+ return true;
51
+ }
52
+ if (element instanceof HTMLInputElement) return element.type !== "hidden";
53
+ if (element instanceof HTMLIFrameElement) return true;
54
+ if (element.tagName === "AUDIO" || element.tagName === "VIDEO") {
55
+ return element.hasAttribute("controls");
56
+ }
57
+ if (element instanceof HTMLElement && element.tagName === "SUMMARY") {
58
+ const details = element.parentElement;
59
+ return details instanceof HTMLDetailsElement && Array.from(details.children).find((child) => child.tagName === "SUMMARY") === element;
60
+ }
61
+ return false;
62
+ }
63
+ function hasEditableTabStop(element) {
64
+ const value = element.getAttribute("contenteditable")?.toLowerCase();
65
+ return value === "" || value === "true" || value === "plaintext-only";
66
+ }
67
+ function isTabStop(element) {
68
+ if (!canTakeFocus(element) || !isRenderedForFocus(element)) return false;
69
+ const tabindex = authoredTabindex(element);
70
+ if (tabindex !== null) return tabindex >= 0;
71
+ return hasNativeTabStop(element) || hasEditableTabStop(element);
72
+ }
73
+ function tabStopsWithin(root) {
74
+ return Array.from(root.querySelectorAll(TAB_STOP_CANDIDATE_SELECTOR)).filter(
75
+ isTabStop
76
+ );
77
+ }
7
78
 
8
79
  // src/controllers/dismissible_controller.ts
9
80
  var DismissibleController = class extends Controller {
@@ -80,35 +151,29 @@ var DismissibleController = class extends Controller {
80
151
  /** Yields available focus fallbacks in precedence order, stopping after focus succeeds. */
81
152
  *#focusFallbacks(root) {
82
153
  const explicitFallback = this.hasFallbackTarget ? this.fallbackTarget : null;
83
- if (explicitFallback && !root.contains(explicitFallback) && this.#isFocusAvailable(explicitFallback)) {
154
+ if (explicitFallback && !root.contains(explicitFallback) && this.#isExplicitFocusAvailable(explicitFallback)) {
84
155
  yield explicitFallback;
85
156
  }
86
- const candidates = document.querySelectorAll(FOCUSABLE);
157
+ const candidates = tabStopsWithin(document);
87
158
  for (const element of candidates) {
88
159
  if (element === explicitFallback || root.contains(element) || !(root.compareDocumentPosition(element) & Node.DOCUMENT_POSITION_FOLLOWING)) {
89
160
  continue;
90
161
  }
91
- if (this.#isFocusAvailable(element)) yield element;
162
+ yield element;
92
163
  }
93
164
  for (let index = candidates.length - 1; index >= 0; index -= 1) {
94
- const element = candidates.item(index);
165
+ const element = candidates[index];
166
+ if (!element) continue;
95
167
  if (element === explicitFallback || root.contains(element) || !(root.compareDocumentPosition(element) & Node.DOCUMENT_POSITION_PRECEDING)) {
96
168
  continue;
97
169
  }
98
- if (this.#isFocusAvailable(element)) yield element;
170
+ yield element;
99
171
  }
100
172
  }
101
- /** Whether focus is allowed by the element and its semantic ancestors. */
102
- #isFocusAvailable(element) {
103
- if (!element.matches(FOCUSABLE) && !element.hasAttribute("tabindex") && !element.isContentEditable) {
104
- return false;
105
- }
106
- if (element.matches(":disabled")) return false;
107
- if (element instanceof HTMLInputElement && element.type === "hidden") return false;
108
- for (let current = element; current; current = current.parentElement) {
109
- if (current.hidden || current.inert) return false;
110
- }
111
- return true;
173
+ /** Whether an explicit fallback can receive programmatic focus right now. */
174
+ #isExplicitFocusAvailable(element) {
175
+ const programmaticallyFocusable = element.hasAttribute("tabindex") || isTabStop(element);
176
+ return programmaticallyFocusable && canTakeFocus(element) && isRenderedForFocus(element);
112
177
  }
113
178
  };
114
179
 
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/utils/focus_trap.ts","../../src/controllers/dismissible_controller.ts"],"names":[],"mappings":"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","file":"dismissible_controller.js","sourcesContent":["import { BeforeCacheReset } from \"./before_cache_reset\";\nimport { EscapeLayer } from \"./escape_layer\";\n\n/**\n * Modal focus-trap primitive shared by the modal-overlay controllers\n * (dialog / alert-dialog / confirm / drawer / command-palette / sidebar); the\n * non-modal focus scope (focus) reuses it with the modal side effects opted out.\n *\n * The WAI-ARIA APG modal pattern is more than \"cycle Tab inside a box\": a modal\n * also locks background scroll, makes the rest of the page `inert` (so assistive\n * technology and pointer/Tab cannot reach it, honoring `aria-modal=\"true\"`),\n * sends focus inside on open, and restores it to the opener on close — and every\n * one of those side effects must be reverted if the element is torn down while\n * open (a Turbo navigation mid-dialog) and kept out of the snapshot Turbo\n * caches (`turbo:before-cache`). {@link FocusTrap} owns that whole modal\n * lifecycle so each controller only decides *when* to open/close and *what*\n * \"close\" means.\n *\n * It is intentionally **policy-free about closing**. Escape semantics differ per\n * widget (a plain dialog just closes; an alert-dialog closes *as a cancel* with a\n * reason; a drawer runs an exit transition), so the trap merely forwards Escape\n * to an {@link FocusTrapOptions.onEscape | onEscape} callback and never decides on\n * its own what closing entails.\n *\n * @remarks\n * The container is read through a getter so a controller can hand over a Stimulus\n * target without worrying about when the trap instance is constructed relative to\n * `connect()`.\n */\n\n/**\n * Selector matching the elements considered focusable. Shared by the trap's Tab\n * cycling and by form-validation's invalid-focus delegation.\n */\nexport const FOCUSABLE =\n 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex=\"-1\"])';\n\n/** Behavior hooks a controller supplies when constructing a {@link FocusTrap}. */\nexport interface FocusTrapOptions {\n /**\n * Called when `Escape` is pressed while the trap is active. When omitted,\n * `Escape` is left alone (the trap never joins the Escape stack). Dismissal\n * is resolved by the shared {@link EscapeLayer}: an Escape already consumed\n * by an inner handler is ignored, and among active layers the most recently\n * activated claiming one owns the press. The resolver consumes the event\n * before invoking the callback.\n */\n onEscape?: () => void;\n /**\n * Returns the element to focus when the trap activates. When it returns `null`\n * (or is omitted), the first focusable descendant is used, falling back to the\n * container itself (made programmatically focusable with `tabindex=-1`).\n */\n initialFocus?: () => HTMLElement | null;\n /**\n * Returns the element to focus on deactivation when nothing was focused before\n * the trap opened (e.g. the trigger). The element focused *before* opening\n * always takes precedence.\n */\n fallbackFocus?: () => HTMLElement | null;\n /**\n * Lock background scroll (`body` overflow) while active. Defaults to `true` for\n * the modal overlays; a lighter focus scope passes `false`. Read on `activate`.\n */\n lockScroll?: boolean | (() => boolean);\n /**\n * Make background siblings `inert` while active (the `aria-modal` isolation).\n * Defaults to `true` for the modal overlays; a soft focus scope can opt out so\n * the background stays reachable while `Tab` still cycles inside. Read on `activate`.\n */\n isolate?: boolean | (() => boolean);\n /**\n * Move focus inside on `activate`. Defaults to `true`; a focus scope that only\n * wants the `Tab` boundary (no focus move) passes `false`. Read on `activate`.\n */\n autoFocus?: boolean | (() => boolean);\n}\n\n/**\n * Owns the modal side effects (scroll lock, background `inert`, focus trap, focus\n * restore) for a single container, applied on {@link activate} and reverted on\n * {@link deactivate}.\n */\nexport class FocusTrap {\n /** The element focused before activation, restored on deactivation. */\n #previouslyFocused: HTMLElement | null = null;\n /** The body's inline `overflow` before locking, restored on deactivation. */\n #previousBodyOverflow = \"\";\n /** Whether scroll was locked this activation (so it is only restored if applied). */\n #scrollLocked = false;\n /** Background siblings made `inert` while active, restored on deactivation. */\n #inertedSiblings: HTMLElement[] = [];\n /** Whether the modal side effects are currently applied. */\n #activeState = false;\n /** Registers the trap on the shared Escape stack while active (see {@link EscapeLayer}). */\n readonly #escapeLayer = new EscapeLayer();\n\n /** Returns the trapped element; called on every operation for the live target. */\n readonly #getContainer: () => HTMLElement;\n /** Closing/focus hooks; see {@link FocusTrapOptions}. */\n readonly #options: FocusTrapOptions;\n\n /**\n * @param getContainer - Returns the trapped element. Called on every operation\n * so the live target is always used.\n * @param options - Closing/focus hooks; see {@link FocusTrapOptions}.\n */\n constructor(getContainer: () => HTMLElement, options: FocusTrapOptions = {}) {\n this.#getContainer = getContainer;\n this.#options = options;\n }\n\n /** Whether the trap is currently active. */\n get active(): boolean {\n return this.#activeState;\n }\n\n /**\n * Applies the trap: records the current focus, optionally locks background scroll\n * and makes background siblings `inert`, listens for `Tab`/`Escape`, and (unless\n * `autoFocus` is off) moves focus inside. No-ops if already active.\n */\n activate(): void {\n if (this.#activeState) return;\n this.#activeState = true;\n // Record the opener so it can be refocused on close. `<body>` (the default\n // active element when nothing is focused) is treated as \"nothing\", so the\n // fallback target — typically the trigger — wins in that case.\n const active = document.activeElement;\n this.#previouslyFocused =\n active instanceof HTMLElement && active !== document.body ? active : null;\n if (this.#flag(this.#options.lockScroll, true)) {\n this.#previousBodyOverflow = document.body.style.overflow;\n document.body.style.overflow = \"hidden\";\n this.#scrollLocked = true;\n }\n if (this.#flag(this.#options.isolate, true)) this.#isolateBackground();\n document.addEventListener(\"keydown\", this.#onKeydown);\n this.#beforeCache.activate();\n const onEscape = this.#options.onEscape;\n if (onEscape) this.#escapeLayer.activate(document, { onDismiss: () => onEscape() });\n if (this.#flag(this.#options.autoFocus, true)) this.#focusInitial();\n }\n\n /**\n * Reverts every side effect applied by {@link activate}. No-ops if inactive, so\n * a controller can call it defensively from both `close()` and `disconnect()`.\n *\n * @param restoreFocus - Move focus back to the opener (default `true`). Pass\n * `false` on teardown (`disconnect`), where yanking focus is undesirable.\n */\n deactivate({ restoreFocus = true }: { restoreFocus?: boolean } = {}): void {\n if (!this.#activeState) return;\n this.#activeState = false;\n this.#escapeLayer.deactivate();\n document.removeEventListener(\"keydown\", this.#onKeydown);\n this.#beforeCache.deactivate();\n if (this.#scrollLocked) {\n document.body.style.overflow = this.#previousBodyOverflow;\n this.#scrollLocked = false;\n }\n this.#releaseBackground();\n if (restoreFocus) {\n const target = this.#previouslyFocused ?? this.#options.fallbackFocus?.() ?? null;\n target?.focus();\n }\n }\n\n /** Resolves a boolean-or-getter option, defaulting when it was not provided. */\n #flag(option: boolean | (() => boolean) | undefined, fallback: boolean): boolean {\n if (option === undefined) return fallback;\n return typeof option === \"function\" ? option() : option;\n }\n\n /**\n * Reverts the side effects just before Turbo caches the page snapshot, so an\n * overlay left open does not bake the scroll lock into `body[style]` — a\n * restored page would feed that locked value back into {@link activate} as the\n * baseline, and closing would then never unlock the page. Markup state stays\n * untouched (restore-open designs reopen against a clean baseline), and focus\n * is left alone mid-navigation. The listener lives only while active.\n */\n readonly #beforeCache = new BeforeCacheReset(() => this.deactivate({ restoreFocus: false }));\n\n /**\n * Handles `Tab` (focus trap) while active. `Escape` dismissal is owned by the\n * shared {@link EscapeLayer} resolver, so Tab trapping stays independent of\n * which layer currently owns Escape.\n */\n readonly #onKeydown = (event: KeyboardEvent): void => {\n if (event.key === \"Tab\") this.#trapTab(event);\n };\n\n /** Keeps `Tab` focus cycling within the container's focusable elements. */\n #trapTab(event: KeyboardEvent): void {\n const focusable = this.#focusableElements();\n if (focusable.length === 0) {\n event.preventDefault();\n return;\n }\n const first = focusable[0];\n const last = focusable[focusable.length - 1];\n const active = document.activeElement;\n\n // If focus has somehow escaped the container, pull it back to the first item.\n if (!(active instanceof Node) || !this.#getContainer().contains(active)) {\n event.preventDefault();\n first?.focus();\n return;\n }\n\n if (event.shiftKey && active === first) {\n event.preventDefault();\n last?.focus();\n } else if (!event.shiftKey && active === last) {\n event.preventDefault();\n first?.focus();\n }\n }\n\n /**\n * Marks every element outside the container's subtree as `inert` so background\n * content cannot be focused or reached by assistive technology, honoring the\n * `aria-modal=\"true\"` contract. An element that was *already* `inert` is left\n * untracked so `#releaseBackground` does not wrongly clear it.\n */\n #isolateBackground(): void {\n const container = this.#getContainer();\n this.#inertedSiblings = [];\n for (const sibling of Array.from(document.body.children)) {\n if (!(sibling instanceof HTMLElement)) continue;\n if (sibling.contains(container) || sibling.inert) continue;\n sibling.inert = true;\n this.#inertedSiblings.push(sibling);\n }\n }\n\n /** Reverts the `inert` flags applied by `#isolateBackground`. */\n #releaseBackground(): void {\n for (const sibling of this.#inertedSiblings) {\n sibling.inert = false;\n }\n this.#inertedSiblings = [];\n }\n\n /** Moves focus to the initial target, the first focusable, or the container. */\n #focusInitial(): void {\n const preferred = this.#options.initialFocus?.();\n if (preferred) {\n preferred.focus();\n return;\n }\n const focusable = this.#focusableElements();\n if (focusable[0]) {\n focusable[0].focus();\n return;\n }\n const container = this.#getContainer();\n container.tabIndex = -1;\n container.focus();\n }\n\n /** Collects the container's currently focusable descendants in DOM order. */\n #focusableElements(): HTMLElement[] {\n return Array.from(this.#getContainer().querySelectorAll<HTMLElement>(FOCUSABLE)).filter(\n (el) => !el.hidden,\n );\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { FOCUSABLE } from \"../utils/focus_trap\";\n\n/**\n * Headless \"dismissible\" behavior for banners, notices, and inline alerts.\n *\n * Markup contract (identifier: `stimeo--dismissible`):\n * <div data-controller=\"stimeo--dismissible\"\n * data-stimeo--dismissible-mode-value=\"remove\">\n * <div data-stimeo--dismissible-target=\"root\" role=\"status\">\n * <p>Saved.</p>\n * <button type=\"button\" aria-label=\"Close\"\n * data-action=\"stimeo--dismissible#dismiss\">×</button>\n * </div>\n * </div>\n *\n * A general utility with no dedicated APG pattern. Its accessibility job is to\n * keep focus from being orphaned: if focus is inside the element being removed,\n * it is moved to a safe place first so the close button vanishing never strands\n * the user (WCAG 2.4.3).\n *\n * @remarks\n * Behavior only — the consumer owns any exit transition (use `hide` mode, which\n * adds `hidden`, and animate off `data-state`) and any semantics like\n * `role=\"alert\"`. Focus retreats to, in order: the `fallback` target → the next\n * focusable element after the root → the previous one → `document.body` as a\n * last resort (weak focus; prefer providing a `fallback`).\n */\nexport class DismissibleController extends Controller<HTMLElement> {\n static override targets = [\"root\", \"fallback\"];\n static override values = {\n mode: { type: String, default: \"remove\" },\n closeOnEscape: { type: Boolean, default: false },\n };\n static actions = [\"dismiss\"] as const;\n static events = [\"dismiss\"] as const;\n\n declare readonly rootTarget: HTMLElement;\n declare readonly fallbackTarget: HTMLElement;\n declare readonly hasRootTarget: boolean;\n declare readonly hasFallbackTarget: boolean;\n\n declare modeValue: string;\n declare closeOnEscapeValue: boolean;\n\n override connect(): void {\n const root = this.#root;\n if (!root.hasAttribute(\"data-state\")) {\n root.setAttribute(\"data-state\", \"open\");\n }\n this.#syncEscapeListener();\n }\n\n override disconnect(): void {\n this.element.removeEventListener(\"keydown\", this.#onKeydown);\n }\n\n /** Keeps the Escape listener aligned with a live `closeOnEscape` Value. */\n closeOnEscapeValueChanged(): void {\n this.#syncEscapeListener();\n }\n\n /** Dismisses the element. Bound via `data-action` (click on the close button). */\n dismiss(): void {\n this.#performDismiss();\n }\n\n /** Dismisses on Escape when `closeOnEscape` is set and focus is inside. */\n readonly #onKeydown = (event: KeyboardEvent): void => {\n // A press during IME composition cancels the conversion (e.g. in a text\n // field inside this element), never the element itself.\n if (event.key !== \"Escape\" || event.defaultPrevented || event.isComposing) return;\n if (!this.closeOnEscapeValue) return;\n const active = document.activeElement;\n if (!active || !this.element.contains(active)) return;\n event.preventDefault();\n this.#performDismiss();\n };\n\n /** Adds or removes the one stable listener reference without duplicating it. */\n #syncEscapeListener(): void {\n this.element.removeEventListener(\"keydown\", this.#onKeydown);\n if (this.closeOnEscapeValue) {\n this.element.addEventListener(\"keydown\", this.#onKeydown);\n }\n }\n\n /** The element to dismiss: the explicit `root` target, or the host element. */\n get #root(): HTMLElement {\n return this.hasRootTarget ? this.rootTarget : this.element;\n }\n\n #performDismiss(): void {\n const root = this.#root;\n const mode = this.modeValue === \"hide\" ? \"hide\" : \"remove\";\n\n this.#retreatFocus(root);\n root.setAttribute(\"data-state\", \"closing\");\n // Dispatch before removal so a listener on the (about-to-leave) element still\n // runs; `dispatch` fires synchronously on `this.element`.\n this.dispatch(\"dismiss\", { detail: { mode } });\n\n if (mode === \"hide\") {\n root.hidden = true;\n } else {\n root.remove();\n }\n }\n\n /**\n * Moves focus out of `root` *before* it is removed, but only when focus is\n * actually inside it — otherwise the user's place elsewhere is left undisturbed.\n */\n #retreatFocus(root: HTMLElement): void {\n const active = document.activeElement;\n if (!(active instanceof HTMLElement) || !root.contains(active)) return;\n\n for (const candidate of this.#focusFallbacks(root)) {\n candidate.focus();\n if (document.activeElement === candidate) return;\n }\n document.body.focus();\n }\n\n /** Yields available focus fallbacks in precedence order, stopping after focus succeeds. */\n *#focusFallbacks(root: HTMLElement): Generator<HTMLElement> {\n const explicitFallback = this.hasFallbackTarget ? this.fallbackTarget : null;\n if (\n explicitFallback &&\n !root.contains(explicitFallback) &&\n this.#isFocusAvailable(explicitFallback)\n ) {\n yield explicitFallback;\n }\n\n const candidates = document.querySelectorAll<HTMLElement>(FOCUSABLE);\n for (const element of candidates) {\n if (\n element === explicitFallback ||\n root.contains(element) ||\n !(root.compareDocumentPosition(element) & Node.DOCUMENT_POSITION_FOLLOWING)\n ) {\n continue;\n }\n if (this.#isFocusAvailable(element)) yield element;\n }\n\n for (let index = candidates.length - 1; index >= 0; index -= 1) {\n const element = candidates.item(index);\n if (\n element === explicitFallback ||\n root.contains(element) ||\n !(root.compareDocumentPosition(element) & Node.DOCUMENT_POSITION_PRECEDING)\n ) {\n continue;\n }\n if (this.#isFocusAvailable(element)) yield element;\n }\n }\n\n /** Whether focus is allowed by the element and its semantic ancestors. */\n #isFocusAvailable(element: HTMLElement): boolean {\n if (\n !element.matches(FOCUSABLE) &&\n !element.hasAttribute(\"tabindex\") &&\n !element.isContentEditable\n ) {\n return false;\n }\n if (element.matches(\":disabled\")) return false;\n if (element instanceof HTMLInputElement && element.type === \"hidden\") return false;\n\n for (let current: HTMLElement | null = element; current; current = current.parentElement) {\n if (current.hidden || current.inert) return false;\n }\n return true;\n }\n}\n"]}
1
+ {"version":3,"sources":["../../src/utils/focus_candidate.ts","../../src/controllers/dismissible_controller.ts"],"names":[],"mappings":"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","file":"dismissible_controller.js","sourcesContent":["/**\n * Whether a disabled `<fieldset>` ancestor actually reaches `control`.\n *\n * HTML exempts the contents of a fieldset's **first direct-child `<legend>`**, and\n * the exemption is per fieldset — a control legal in one legend can still be\n * disabled by a second, outer fieldset, so the walk continues upward.\n *\n * Exported for the consumer that needs this rule *without* the rest of\n * {@link canTakeFocus}: `toolbar` bounds its own `hidden` walk at the toolbar\n * root, which is a different rule, so it composes the two itself.\n */\nexport function inheritsFieldsetDisabled(control: HTMLElement): boolean {\n let fieldset: Element | null = control.closest(\"fieldset[disabled]\");\n while (fieldset) {\n const legend = Array.from(fieldset.children).find((child) => child.tagName === \"LEGEND\");\n if (!legend?.contains(control)) return true;\n fieldset = fieldset.parentElement?.closest(\"fieldset[disabled]\") ?? null;\n }\n return false;\n}\n\n/**\n * Whether an element can actually take focus, checked **before** `focus()` runs.\n *\n * A controller that must move focus off a control it is about to disable or hide\n * picks a destination and calls `focus()` on it. If that destination cannot take\n * focus, the call **fails silently**: `hidden` and natively `disabled` elements\n * swallow it, so the caret stays in the subtree that is disappearing and lands on\n * `<body>` a frame later — the exact outcome the rescue exists to prevent, minus\n * any signal that it happened.\n *\n * Testing after the fact is the obvious alternative and is deliberately not the\n * rule. Reading `document.activeElement` back only works in a real browser, and\n * looping over candidates that way performs a real focus move per failure —\n * observable to assistive technology. Checking first costs nothing and catches\n * the cases that actually occur.\n *\n * **`aria-disabled` is not disqualifying.** It is the attribute an author uses\n * for a control that must stay *discoverable*, and the roving contract keeps\n * such items reachable. Only the three conditions that make the platform refuse\n * focus are checked: `hidden` / `inert` (an ancestor's counts),\n * `input[type=\"hidden\"]`, the native `disabled` property, and `disabled`\n * inherited from an ancestor `fieldset`. CSS-only invisibility is handled by\n * {@link isRenderedForFocus} when a consumer needs sequential-focus semantics.\n *\n * Reading `:disabled` instead of walking the fieldset chain would be shorter, but\n * that pseudo-class is not evaluated consistently outside real browsers and this\n * has to be right headlessly too. happy-dom in particular focuses a `<button>`\n * inside a disabled fieldset where a real engine refuses, so the inheritance is\n * spelled out rather than delegated.\n *\n * What a consumer does when nothing survives is its own call: some fall back to\n * their landmark, while a widget whose caret already sits somewhere legitimate\n * refuses the move outright rather than relocating it.\n *\n * @example\n * ```ts\n * const target = candidates.find(canTakeFocus);\n * if (target) target.focus();\n * else {\n * this.#tabindex.lend(this.element); // nothing left: fall back to the landmark\n * this.element.focus();\n * }\n * ```\n *\n * @param element - the candidate destination\n */\nexport function canTakeFocus(element: HTMLElement): boolean {\n if (element.closest(\"[hidden], [inert]\")) return false;\n if (element instanceof HTMLInputElement && element.type === \"hidden\") return false;\n if (!(\"disabled\" in element)) return true;\n if ((element as HTMLElement & { disabled: boolean }).disabled) return false;\n return !inheritsFieldsetDisabled(element);\n}\n\n/** Elements whose semantics or authored attributes can place them in sequential focus order. */\nexport const TAB_STOP_CANDIDATE_SELECTOR = [\n \"a[href]\",\n \"area[href]\",\n \"button\",\n \"input\",\n \"select\",\n \"textarea\",\n \"summary\",\n \"iframe\",\n \"audio[controls]\",\n \"video[controls]\",\n \"[tabindex]\",\n \"[contenteditable]\",\n].join(\",\");\n\n/** Optional browser visibility API used to exclude CSS-hidden candidates. */\ninterface VisibilityCheckable {\n checkVisibility?: (options?: { visibilityProperty?: boolean }) => boolean;\n}\n\n/** Whether CSS visibility allows an otherwise eligible element to participate in focus order. */\nexport function isRenderedForFocus(element: HTMLElement): boolean {\n const check = (element as HTMLElement & VisibilityCheckable).checkVisibility;\n return typeof check === \"function\" ? check.call(element, { visibilityProperty: true }) : true;\n}\n\n/** Parses an authored `tabindex`; invalid syntax has no explicit focus-order meaning. */\nfunction authoredTabindex(element: HTMLElement): number | null {\n const value = element.getAttribute(\"tabindex\");\n if (value === null || !/^[+-]?\\d+$/.test(value.trim())) return null;\n return Number(value);\n}\n\n/** Whether the element's native semantics place it in sequential focus order. */\nfunction hasNativeTabStop(element: HTMLElement): boolean {\n if (element instanceof HTMLAnchorElement || element instanceof HTMLAreaElement) {\n return element.hasAttribute(\"href\");\n }\n if (\n element instanceof HTMLButtonElement ||\n element instanceof HTMLSelectElement ||\n element instanceof HTMLTextAreaElement\n ) {\n return true;\n }\n if (element instanceof HTMLInputElement) return element.type !== \"hidden\";\n if (element instanceof HTMLIFrameElement) return true;\n if (element.tagName === \"AUDIO\" || element.tagName === \"VIDEO\") {\n return element.hasAttribute(\"controls\");\n }\n if (element instanceof HTMLElement && element.tagName === \"SUMMARY\") {\n const details = element.parentElement;\n return (\n details instanceof HTMLDetailsElement &&\n Array.from(details.children).find((child) => child.tagName === \"SUMMARY\") === element\n );\n }\n return false;\n}\n\n/** Whether an explicit `contenteditable` value creates an editable tab stop. */\nfunction hasEditableTabStop(element: HTMLElement): boolean {\n const value = element.getAttribute(\"contenteditable\")?.toLowerCase();\n return value === \"\" || value === \"true\" || value === \"plaintext-only\";\n}\n\n/**\n * Whether an element is a usable sequential Tab stop right now.\n *\n * Native semantics, authored `tabindex`, editable hosts, inherited disabled state,\n * HTML `hidden`/`inert`, and CSS visibility are evaluated together. `aria-disabled`\n * remains focusable because it communicates unavailability without removing the\n * control from discovery order.\n */\nexport function isTabStop(element: HTMLElement): boolean {\n if (!canTakeFocus(element) || !isRenderedForFocus(element)) return false;\n\n const tabindex = authoredTabindex(element);\n if (tabindex !== null) return tabindex >= 0;\n return hasNativeTabStop(element) || hasEditableTabStop(element);\n}\n\n/** Returns every usable sequential Tab stop below `root` in document order. */\nexport function tabStopsWithin(root: ParentNode): HTMLElement[] {\n return Array.from(root.querySelectorAll<HTMLElement>(TAB_STOP_CANDIDATE_SELECTOR)).filter(\n isTabStop,\n );\n}\n\n/** Returns the first usable sequential Tab stop below `root`, if one exists. */\nexport function firstTabStop(root: ParentNode): HTMLElement | null {\n for (const candidate of root.querySelectorAll<HTMLElement>(TAB_STOP_CANDIDATE_SELECTOR)) {\n if (isTabStop(candidate)) return candidate;\n }\n return null;\n}\n\n/** Whether `root` contains at least one usable sequential Tab stop. */\nexport function hasTabStop(root: ParentNode): boolean {\n return firstTabStop(root) !== null;\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport {\n canTakeFocus,\n isRenderedForFocus,\n isTabStop,\n tabStopsWithin,\n} from \"../utils/focus_candidate\";\n\n/**\n * Headless \"dismissible\" behavior for banners, notices, and inline alerts.\n *\n * Markup contract (identifier: `stimeo--dismissible`):\n * <div data-controller=\"stimeo--dismissible\"\n * data-stimeo--dismissible-mode-value=\"remove\">\n * <div data-stimeo--dismissible-target=\"root\" role=\"status\">\n * <p>Saved.</p>\n * <button type=\"button\" aria-label=\"Close\"\n * data-action=\"stimeo--dismissible#dismiss\">×</button>\n * </div>\n * </div>\n *\n * A general utility with no dedicated APG pattern. Its accessibility job is to\n * keep focus from being orphaned: if focus is inside the element being removed,\n * it is moved to a safe place first so the close button vanishing never strands\n * the user (WCAG 2.4.3).\n *\n * `dismiss` dispatches `{ mode: \"remove\" | \"hide\" }`.\n *\n * @remarks\n * Behavior only — the consumer owns any exit transition (use `hide` mode, which\n * adds `hidden`, and animate off `data-state`) and any semantics like\n * `role=\"alert\"`. Focus retreats to, in order: the `fallback` target → the next\n * focusable element after the root → the previous one → `document.body` as a\n * last resort (weak focus; prefer providing a `fallback`).\n */\nexport class DismissibleController extends Controller<HTMLElement> {\n static override targets = [\"root\", \"fallback\"];\n static override values = {\n mode: { type: String, default: \"remove\" },\n closeOnEscape: { type: Boolean, default: false },\n };\n static actions = [\"dismiss\"] as const;\n static events = [\"dismiss\"] as const;\n\n declare readonly rootTarget: HTMLElement;\n declare readonly fallbackTarget: HTMLElement;\n declare readonly hasRootTarget: boolean;\n declare readonly hasFallbackTarget: boolean;\n\n declare modeValue: string;\n declare closeOnEscapeValue: boolean;\n\n override connect(): void {\n const root = this.#root;\n if (!root.hasAttribute(\"data-state\")) {\n root.setAttribute(\"data-state\", \"open\");\n }\n this.#syncEscapeListener();\n }\n\n override disconnect(): void {\n this.element.removeEventListener(\"keydown\", this.#onKeydown);\n }\n\n /** Keeps the Escape listener aligned with a live `closeOnEscape` Value. */\n closeOnEscapeValueChanged(): void {\n this.#syncEscapeListener();\n }\n\n /** Dismisses the element. Bound via `data-action` (click on the close button). */\n dismiss(): void {\n this.#performDismiss();\n }\n\n /** Dismisses on Escape when `closeOnEscape` is set and focus is inside. */\n readonly #onKeydown = (event: KeyboardEvent): void => {\n // A press during IME composition cancels the conversion (e.g. in a text\n // field inside this element), never the element itself.\n if (event.key !== \"Escape\" || event.defaultPrevented || event.isComposing) return;\n if (!this.closeOnEscapeValue) return;\n const active = document.activeElement;\n if (!active || !this.element.contains(active)) return;\n event.preventDefault();\n this.#performDismiss();\n };\n\n /** Adds or removes the one stable listener reference without duplicating it. */\n #syncEscapeListener(): void {\n this.element.removeEventListener(\"keydown\", this.#onKeydown);\n if (this.closeOnEscapeValue) {\n this.element.addEventListener(\"keydown\", this.#onKeydown);\n }\n }\n\n /** The element to dismiss: the explicit `root` target, or the host element. */\n get #root(): HTMLElement {\n return this.hasRootTarget ? this.rootTarget : this.element;\n }\n\n #performDismiss(): void {\n const root = this.#root;\n const mode = this.modeValue === \"hide\" ? \"hide\" : \"remove\";\n\n this.#retreatFocus(root);\n root.setAttribute(\"data-state\", \"closing\");\n // Dispatch before removal so a listener on the (about-to-leave) element still\n // runs; `dispatch` fires synchronously on `this.element`.\n this.dispatch(\"dismiss\", { detail: { mode } });\n\n if (mode === \"hide\") {\n root.hidden = true;\n } else {\n root.remove();\n }\n }\n\n /**\n * Moves focus out of `root` *before* it is removed, but only when focus is\n * actually inside it — otherwise the user's place elsewhere is left undisturbed.\n */\n #retreatFocus(root: HTMLElement): void {\n const active = document.activeElement;\n if (!(active instanceof HTMLElement) || !root.contains(active)) return;\n\n for (const candidate of this.#focusFallbacks(root)) {\n candidate.focus();\n if (document.activeElement === candidate) return;\n }\n document.body.focus();\n }\n\n /** Yields available focus fallbacks in precedence order, stopping after focus succeeds. */\n *#focusFallbacks(root: HTMLElement): Generator<HTMLElement> {\n const explicitFallback = this.hasFallbackTarget ? this.fallbackTarget : null;\n if (\n explicitFallback &&\n !root.contains(explicitFallback) &&\n this.#isExplicitFocusAvailable(explicitFallback)\n ) {\n yield explicitFallback;\n }\n\n const candidates = tabStopsWithin(document);\n for (const element of candidates) {\n if (\n element === explicitFallback ||\n root.contains(element) ||\n !(root.compareDocumentPosition(element) & Node.DOCUMENT_POSITION_FOLLOWING)\n ) {\n continue;\n }\n yield element;\n }\n\n for (let index = candidates.length - 1; index >= 0; index -= 1) {\n const element = candidates[index];\n if (!element) continue;\n if (\n element === explicitFallback ||\n root.contains(element) ||\n !(root.compareDocumentPosition(element) & Node.DOCUMENT_POSITION_PRECEDING)\n ) {\n continue;\n }\n yield element;\n }\n }\n\n /** Whether an explicit fallback can receive programmatic focus right now. */\n #isExplicitFocusAvailable(element: HTMLElement): boolean {\n const programmaticallyFocusable = element.hasAttribute(\"tabindex\") || isTabStop(element);\n return programmaticallyFocusable && canTakeFocus(element) && isRenderedForFocus(element);\n }\n}\n"]}