plutonium 0.60.5 → 0.62.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. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium/SKILL.md +19 -1
  3. data/.claude/skills/plutonium-app/SKILL.md +41 -0
  4. data/.claude/skills/plutonium-auth/SKILL.md +40 -0
  5. data/.claude/skills/plutonium-behavior/SKILL.md +47 -1
  6. data/.claude/skills/plutonium-kanban/SKILL.md +378 -0
  7. data/.claude/skills/plutonium-resource/SKILL.md +40 -0
  8. data/.claude/skills/plutonium-tenancy/SKILL.md +43 -0
  9. data/.claude/skills/plutonium-testing/SKILL.md +38 -0
  10. data/.claude/skills/plutonium-ui/SKILL.md +51 -0
  11. data/.claude/skills/plutonium-wizard/SKILL.md +469 -0
  12. data/.cliff.toml +6 -0
  13. data/Appraisals +3 -0
  14. data/CHANGELOG.md +576 -439
  15. data/CLAUDE.md +15 -7
  16. data/app/assets/plutonium.css +1 -1
  17. data/app/assets/plutonium.js +1173 -194
  18. data/app/assets/plutonium.js.map +4 -4
  19. data/app/assets/plutonium.min.js +53 -53
  20. data/app/assets/plutonium.min.js.map +4 -4
  21. data/app/views/layouts/basic.html.erb +7 -0
  22. data/app/views/plutonium/_flash_toasts.html.erb +2 -46
  23. data/app/views/plutonium/_toast.html.erb +52 -0
  24. data/app/views/resource/_kanban_move_action_form.html.erb +1 -0
  25. data/app/views/resource/_resource_kanban.html.erb +1 -0
  26. data/app/views/resource/kanban_move_form.html.erb +1 -0
  27. data/config/brakeman.ignore +2 -2
  28. data/db/migrate/wizard/20260615000001_create_plutonium_wizard_sessions.rb +57 -0
  29. data/docs/.vitepress/config.ts +45 -1
  30. data/docs/.vitepress/sync-skills.mjs +45 -0
  31. data/docs/ai.md +99 -0
  32. data/docs/guides/index.md +2 -0
  33. data/docs/guides/kanban.md +557 -0
  34. data/docs/guides/wizards.md +447 -0
  35. data/docs/public/images/guides/kanban-after-move.png +0 -0
  36. data/docs/public/images/guides/kanban-board-light.png +0 -0
  37. data/docs/public/images/guides/kanban-board.png +0 -0
  38. data/docs/public/images/guides/kanban-show-centered-modal.png +0 -0
  39. data/docs/public/images/guides/kanban-wip-toast.png +0 -0
  40. data/docs/public/images/guides/wizards-chooser.png +0 -0
  41. data/docs/public/images/guides/wizards-completed.png +0 -0
  42. data/docs/public/images/guides/wizards-index-action.png +0 -0
  43. data/docs/public/images/guides/wizards-repeater.png +0 -0
  44. data/docs/public/images/guides/wizards-review.png +0 -0
  45. data/docs/public/images/guides/wizards-step.png +0 -0
  46. data/docs/reference/behavior/policies.md +1 -1
  47. data/docs/reference/index.md +14 -0
  48. data/docs/reference/kanban/authorization.md +82 -0
  49. data/docs/reference/kanban/dsl.md +334 -0
  50. data/docs/reference/kanban/index.md +40 -0
  51. data/docs/reference/kanban/positioning.md +162 -0
  52. data/docs/reference/resource/definition.md +26 -1
  53. data/docs/reference/resource/model.md +26 -0
  54. data/docs/reference/ui/forms.md +77 -0
  55. data/docs/reference/ui/pages.md +2 -0
  56. data/docs/reference/wizard/anchoring-resume.md +194 -0
  57. data/docs/reference/wizard/dsl.md +337 -0
  58. data/docs/reference/wizard/index.md +33 -0
  59. data/docs/reference/wizard/one-time.md +129 -0
  60. data/docs/reference/wizard/registration-launch.md +177 -0
  61. data/docs/reference/wizard/storage-config.md +151 -0
  62. data/docs/superpowers/plans/2026-06-14-form-sectioning.md +2 -2
  63. data/docs/superpowers/plans/2026-06-15-wizard-dsl.md +1619 -0
  64. data/docs/superpowers/plans/2026-06-15-wizard-dsl.md.tasks.json +68 -0
  65. data/docs/superpowers/plans/2026-06-26-kanban-dsl.md +1128 -0
  66. data/docs/superpowers/plans/2026-06-26-kanban-dsl.md.tasks.json +24 -0
  67. data/docs/superpowers/plans/2026-07-02-kanban-drop-interactions.md +714 -0
  68. data/docs/superpowers/plans/2026-07-02-kanban-drop-interactions.md.tasks.json +68 -0
  69. data/docs/superpowers/specs/2026-06-15-wizard-dsl-design.md +836 -0
  70. data/docs/superpowers/specs/2026-06-15-wizard-dsl-examples.rb +245 -0
  71. data/docs/superpowers/specs/2026-06-17-wizard-relaunch-prompt-design.md +86 -0
  72. data/docs/superpowers/specs/2026-06-18-wizard-attachments-design.md +101 -0
  73. data/docs/superpowers/specs/2026-06-18-wizard-hosting-design.md +220 -0
  74. data/docs/superpowers/specs/2026-06-26-kanban-dsl-design.md +388 -0
  75. data/docs/superpowers/specs/2026-07-03-kanban-auth-simplification.md +159 -0
  76. data/gemfiles/postgres.gemfile +8 -0
  77. data/gemfiles/postgres.gemfile.lock +321 -0
  78. data/gemfiles/rails_7.gemfile +1 -0
  79. data/gemfiles/rails_7.gemfile.lock +1 -1
  80. data/gemfiles/rails_8.0.gemfile +1 -0
  81. data/gemfiles/rails_8.0.gemfile.lock +1 -1
  82. data/gemfiles/rails_8.1.gemfile +1 -0
  83. data/gemfiles/rails_8.1.gemfile.lock +14 -1
  84. data/lib/generators/pu/gem/active_shrine/active_shrine_generator.rb +5 -0
  85. data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt +6 -1
  86. data/lib/plutonium/action/base.rb +17 -0
  87. data/lib/plutonium/auth/rodauth.rb +1 -2
  88. data/lib/plutonium/configuration.rb +16 -0
  89. data/lib/plutonium/core/controller.rb +20 -1
  90. data/lib/plutonium/definition/base.rb +25 -0
  91. data/lib/plutonium/definition/form_layout.rb +54 -35
  92. data/lib/plutonium/definition/index_views.rb +70 -1
  93. data/lib/plutonium/definition/wizards.rb +209 -0
  94. data/lib/plutonium/invites/concerns/invite_token.rb +9 -0
  95. data/lib/plutonium/invites/concerns/invite_user.rb +9 -0
  96. data/lib/plutonium/invites/controller.rb +4 -1
  97. data/lib/plutonium/kanban/action.rb +7 -0
  98. data/lib/plutonium/kanban/board.rb +40 -0
  99. data/lib/plutonium/kanban/broadcaster.rb +54 -0
  100. data/lib/plutonium/kanban/column.rb +122 -0
  101. data/lib/plutonium/kanban/context.rb +15 -0
  102. data/lib/plutonium/kanban/dsl.rb +71 -0
  103. data/lib/plutonium/kanban/grouping.rb +51 -0
  104. data/lib/plutonium/kanban/positioning.rb +75 -0
  105. data/lib/plutonium/kanban.rb +11 -0
  106. data/lib/plutonium/migrations.rb +40 -0
  107. data/lib/plutonium/models/has_cents.rb +30 -2
  108. data/lib/plutonium/positioning.rb +146 -0
  109. data/lib/plutonium/railtie.rb +33 -0
  110. data/lib/plutonium/resource/controller.rb +24 -1
  111. data/lib/plutonium/resource/controllers/crud_actions.rb +9 -1
  112. data/lib/plutonium/resource/controllers/kanban_actions.rb +851 -0
  113. data/lib/plutonium/resource/controllers/wizard_actions.rb +165 -0
  114. data/lib/plutonium/resource/policy.rb +14 -0
  115. data/lib/plutonium/routing/mapper_extensions.rb +45 -0
  116. data/lib/plutonium/routing/wizard_registration.rb +289 -0
  117. data/lib/plutonium/ui/display/components/currency.rb +41 -9
  118. data/lib/plutonium/ui/display/options/inferred_types.rb +2 -5
  119. data/lib/plutonium/ui/display/resource.rb +17 -12
  120. data/lib/plutonium/ui/form/base.rb +25 -5
  121. data/lib/plutonium/ui/form/components/currency.rb +64 -0
  122. data/lib/plutonium/ui/form/components/intl_tel_input.rb +27 -1
  123. data/lib/plutonium/ui/form/components/password.rb +126 -0
  124. data/lib/plutonium/ui/form/components/uppy.rb +26 -5
  125. data/lib/plutonium/ui/form/kanban_move.rb +46 -0
  126. data/lib/plutonium/ui/form/options/inferred_types.rb +26 -0
  127. data/lib/plutonium/ui/form/resource.rb +13 -1
  128. data/lib/plutonium/ui/form/theme.rb +7 -0
  129. data/lib/plutonium/ui/form/wizard.rb +63 -0
  130. data/lib/plutonium/ui/grid/card.rb +56 -18
  131. data/lib/plutonium/ui/kanban/card.rb +67 -0
  132. data/lib/plutonium/ui/kanban/color_dot.rb +36 -0
  133. data/lib/plutonium/ui/kanban/column.rb +411 -0
  134. data/lib/plutonium/ui/kanban/resource.rb +319 -0
  135. data/lib/plutonium/ui/layout/base.rb +1 -1
  136. data/lib/plutonium/ui/layout/resource_layout.rb +7 -1
  137. data/lib/plutonium/ui/modal/base.rb +30 -3
  138. data/lib/plutonium/ui/modal/centered.rb +5 -2
  139. data/lib/plutonium/ui/options/has_cents_field.rb +21 -0
  140. data/lib/plutonium/ui/page/index.rb +2 -1
  141. data/lib/plutonium/ui/page/interactive_action.rb +12 -2
  142. data/lib/plutonium/ui/page/kanban_move.rb +20 -0
  143. data/lib/plutonium/ui/page/show.rb +29 -1
  144. data/lib/plutonium/ui/page/wizard.rb +371 -0
  145. data/lib/plutonium/ui/page/wizard_chooser.rb +97 -0
  146. data/lib/plutonium/ui/page/wizard_completed.rb +86 -0
  147. data/lib/plutonium/ui/table/base.rb +1 -1
  148. data/lib/plutonium/ui/table/components/view_switcher.rb +2 -1
  149. data/lib/plutonium/ui/table/resource.rb +1 -1
  150. data/lib/plutonium/ui/wizard/review.rb +196 -0
  151. data/lib/plutonium/ui/wizard/stepper.rb +122 -0
  152. data/lib/plutonium/ui/wizard/summary_display.rb +92 -0
  153. data/lib/plutonium/version.rb +1 -1
  154. data/lib/plutonium/wizard/attachment_data.rb +42 -0
  155. data/lib/plutonium/wizard/attachments.rb +226 -0
  156. data/lib/plutonium/wizard/base.rb +216 -0
  157. data/lib/plutonium/wizard/base_controller.rb +31 -0
  158. data/lib/plutonium/wizard/configuration.rb +42 -0
  159. data/lib/plutonium/wizard/controller.rb +162 -0
  160. data/lib/plutonium/wizard/data.rb +134 -0
  161. data/lib/plutonium/wizard/driving.rb +639 -0
  162. data/lib/plutonium/wizard/dsl.rb +336 -0
  163. data/lib/plutonium/wizard/errors.rb +27 -0
  164. data/lib/plutonium/wizard/field_capture.rb +157 -0
  165. data/lib/plutonium/wizard/field_importer.rb +208 -0
  166. data/lib/plutonium/wizard/gate.rb +171 -0
  167. data/lib/plutonium/wizard/instance_key.rb +97 -0
  168. data/lib/plutonium/wizard/lazy_persisted.rb +77 -0
  169. data/lib/plutonium/wizard/resume.rb +250 -0
  170. data/lib/plutonium/wizard/review_step.rb +48 -0
  171. data/lib/plutonium/wizard/route_resolution.rb +40 -0
  172. data/lib/plutonium/wizard/runner.rb +684 -0
  173. data/lib/plutonium/wizard/session.rb +53 -0
  174. data/lib/plutonium/wizard/state.rb +35 -0
  175. data/lib/plutonium/wizard/step.rb +61 -0
  176. data/lib/plutonium/wizard/step_adapter.rb +103 -0
  177. data/lib/plutonium/wizard/store/active_record.rb +174 -0
  178. data/lib/plutonium/wizard/store/base.rb +42 -0
  179. data/lib/plutonium/wizard/store/memory.rb +44 -0
  180. data/lib/plutonium/wizard/sweep_job.rb +76 -0
  181. data/lib/plutonium/wizard.rb +86 -0
  182. data/lib/plutonium.rb +5 -0
  183. data/lib/rodauth/features/case_insensitive_login.rb +1 -1
  184. data/lib/tasks/release.rake +144 -191
  185. data/package.json +7 -5
  186. data/src/css/components.css +137 -0
  187. data/src/js/controllers/attachment_input_controller.js +15 -1
  188. data/src/js/controllers/currency_input_controller.js +39 -0
  189. data/src/js/controllers/dirty_form_guard_controller.js +155 -27
  190. data/src/js/controllers/intl_tel_input_controller.js +4 -0
  191. data/src/js/controllers/kanban_controller.js +717 -0
  192. data/src/js/controllers/password_sentinel_controller.js +39 -0
  193. data/src/js/controllers/register_controllers.js +8 -0
  194. data/src/js/controllers/remote_modal_controller.js +10 -0
  195. data/src/js/controllers/row_click_controller.js +14 -1
  196. data/src/js/controllers/wizard_controller.js +54 -0
  197. data/src/js/turbo/turbo_confirm.js +1 -1
  198. data/yarn.lock +945 -286
  199. metadata +113 -2
@@ -0,0 +1,39 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Connects to data-controller="currency-input"
4
+ //
5
+ // Pads a currency input's left edge to exactly clear its overlaid unit prefix.
6
+ //
7
+ // The prefix ("$", "£", "GH₵", "USD", …) is absolutely positioned at the
8
+ // field's left edge; its width varies by symbol AND font, so any fixed padding
9
+ // is wrong for some currency — too little clips a wide prefix (digits collide
10
+ // with it, as with "GH₵"), too much wastes space on a bare "$". This measures
11
+ // the rendered prefix and sets padding-left to match, so the caret always sits
12
+ // just past the symbol regardless of currency or typeface.
13
+ //
14
+ // DOM contract:
15
+ // wrapper data-controller="currency-input"
16
+ // prefix data-currency-input-target="prefix" (the overlaid unit span)
17
+ // input data-currency-input-target="field" (the number input)
18
+ export default class extends Controller {
19
+ static targets = ["prefix", "field"]
20
+ // Space between the prefix and the first digit, in px.
21
+ static values = { gap: { type: Number, default: 6 } }
22
+
23
+ connect() {
24
+ this.#pad()
25
+ // Web fonts often load after connect; the fallback font's metrics differ,
26
+ // so re-measure once they're ready to correct the padding.
27
+ document.fonts.ready.then(() => this.#pad())
28
+ }
29
+
30
+ #pad() {
31
+ // Inline !important so it beats pu-input's own left padding (a plain class
32
+ // would lose the cascade — see the note this replaces in currency.rb).
33
+ this.fieldTarget.style.setProperty(
34
+ "padding-left",
35
+ `${this.prefixTarget.offsetWidth + this.gapValue}px`,
36
+ "important"
37
+ )
38
+ }
39
+ }
@@ -1,9 +1,17 @@
1
1
  import { Controller } from "@hotwired/stimulus";
2
2
 
3
3
  // Connects to data-controller="dirty-form-guard"
4
- // Prompts before dismissing a modal form whose contents have changed.
5
- // Self-disables when not inside a <dialog>, so it's safe to attach to
6
- // every form unconditionally.
4
+ // Prompts before discarding a form's unsaved changes. Two guard surfaces,
5
+ // both driven off the same dirtiness diff:
6
+ //
7
+ // • Modal dismissal — when the form is inside a <dialog>, guard Esc / the
8
+ // close button / backdrop cancel (the original behaviour).
9
+ // • Full-page leave — guard a click on any control marked
10
+ // `data-dirty-form-guard-leave="<message>"` that posts WITHOUT this form's
11
+ // fields (e.g. a wizard Back/Cancel). The attribute value is the prompt.
12
+ //
13
+ // Safe to attach to every form unconditionally: with no dialog and no leave
14
+ // controls it never prompts.
7
15
  //
8
16
  // Dirtiness is a diff against a baseline captured at the user's *first*
9
17
  // real interaction — not at connect. Field widgets (intl-tel-input,
@@ -46,52 +54,79 @@ export default class extends Controller {
46
54
 
47
55
  connect() {
48
56
  this.dialog = this.element.closest("dialog");
49
- if (!this.dialog) return;
50
57
 
51
58
  this.baseline = null;
52
59
  this.forceClose = false;
53
60
  this.submitting = false;
54
61
 
55
62
  this.onFirstIntent = this.#onFirstIntent.bind(this);
56
- this.onCancel = this.#onCancel.bind(this);
57
63
  this.onSubmit = this.#onSubmit.bind(this);
58
- this.onCloseButtonClick = this.#onCloseButtonClick.bind(this);
59
- this.onConfirmCancel = this.#onConfirmCancel.bind(this);
60
- this.onKeydown = this.#onKeydown.bind(this);
64
+ this.onLeaveClick = this.#onLeaveClick.bind(this);
65
+ this.onSettled = this.#onSettled.bind(this);
61
66
 
62
67
  // A trusted pointer/key action inside the form is the user starting to
63
68
  // edit — capture the (settled, pre-edit) baseline then. Capture phase so
64
- // a widget that stops propagation can't hide it from us.
69
+ // a widget that stops propagation can't hide it from us. Applies in both
70
+ // modal and full-page modes.
65
71
  this.element.addEventListener("pointerdown", this.onFirstIntent, true);
66
72
  this.element.addEventListener("keydown", this.onFirstIntent, true);
73
+ this.element.addEventListener("submit", this.onSubmit);
67
74
 
68
- document.addEventListener("keydown", this.onKeydown, true);
69
- // Capture phase so this runs before remote-modal's cancel handler
70
- // that way `defaultPrevented` is visible there if we intervene.
71
- this.dialog.addEventListener("cancel", this.onCancel, true);
75
+ // When a submission settles, reset the transient guard flags. A failed submit
76
+ // (e.g. a validation error) can re-render the SAME form via Turbo morph, which
77
+ // preserves this element WITHOUT reconnecting the controller so `submitting`
78
+ // / `forceClose` would otherwise stay set and silently kill the guard for the
79
+ // rest of the form's life. Also drop the baseline so the re-rendered form
80
+ // re-baselines against its new values on the next interaction.
81
+ this.element.addEventListener("turbo:submit-end", this.onSettled);
72
82
 
73
- this.element.addEventListener("submit", this.onSubmit);
74
- this.#closeButtons().forEach((btn) =>
75
- btn.addEventListener("click", this.onCloseButtonClick, true),
76
- );
83
+ // Full-page leave guard: a `data-dirty-form-guard-leave` control can live
84
+ // outside this form (a sibling nav strip), so listen at the document in the
85
+ // capture phase to intercept its click before the form it submits. Only for
86
+ // non-modal forms — a modal is guarded by the Esc/close/cancel handlers below,
87
+ // so a modal form needn't install a document-wide listener.
88
+ if (!this.dialog) {
89
+ document.addEventListener("click", this.onLeaveClick, true);
90
+ }
77
91
 
78
- if (this.hasConfirmDialogTarget) {
79
- this.confirmDialogTarget.addEventListener("cancel", this.onConfirmCancel);
92
+ if (this.dialog) {
93
+ this.onCancel = this.#onCancel.bind(this);
94
+ this.onCloseButtonClick = this.#onCloseButtonClick.bind(this);
95
+ this.onConfirmCancel = this.#onConfirmCancel.bind(this);
96
+ this.onKeydown = this.#onKeydown.bind(this);
97
+
98
+ document.addEventListener("keydown", this.onKeydown, true);
99
+ // Capture phase so this runs before remote-modal's cancel handler
100
+ // — that way `defaultPrevented` is visible there if we intervene.
101
+ this.dialog.addEventListener("cancel", this.onCancel, true);
102
+ this.#closeButtons().forEach((btn) =>
103
+ btn.addEventListener("click", this.onCloseButtonClick, true),
104
+ );
105
+
106
+ if (this.hasConfirmDialogTarget) {
107
+ this.confirmDialogTarget.addEventListener("cancel", this.onConfirmCancel);
108
+ }
80
109
  }
81
110
  }
82
111
 
83
112
  disconnect() {
84
- if (!this.dialog) return;
85
113
  this.element.removeEventListener("pointerdown", this.onFirstIntent, true);
86
114
  this.element.removeEventListener("keydown", this.onFirstIntent, true);
87
- document.removeEventListener("keydown", this.onKeydown, true);
88
- this.dialog.removeEventListener("cancel", this.onCancel, true);
89
115
  this.element.removeEventListener("submit", this.onSubmit);
90
- this.#closeButtons().forEach((btn) =>
91
- btn.removeEventListener("click", this.onCloseButtonClick, true),
92
- );
93
- if (this.hasConfirmDialogTarget) {
94
- this.confirmDialogTarget.removeEventListener("cancel", this.onConfirmCancel);
116
+ this.element.removeEventListener("turbo:submit-end", this.onSettled);
117
+ if (!this.dialog) {
118
+ document.removeEventListener("click", this.onLeaveClick, true);
119
+ }
120
+
121
+ if (this.dialog) {
122
+ document.removeEventListener("keydown", this.onKeydown, true);
123
+ this.dialog.removeEventListener("cancel", this.onCancel, true);
124
+ this.#closeButtons().forEach((btn) =>
125
+ btn.removeEventListener("click", this.onCloseButtonClick, true),
126
+ );
127
+ if (this.hasConfirmDialogTarget) {
128
+ this.confirmDialogTarget.removeEventListener("cancel", this.onConfirmCancel);
129
+ }
95
130
  }
96
131
  }
97
132
 
@@ -156,6 +191,99 @@ export default class extends Controller {
156
191
  this.submitting = true;
157
192
  }
158
193
 
194
+ // A submission settled. Reset the transient guards so a same-URL Turbo-morph
195
+ // re-render (which keeps this element and does NOT reconnect the controller)
196
+ // doesn't leave the guard permanently disabled. Re-baseline on next interaction.
197
+ #onSettled() {
198
+ this.submitting = false;
199
+ this.forceClose = false;
200
+ this.baseline = null;
201
+ }
202
+
203
+ // Full-page leave guard. A control marked `data-dirty-form-guard-leave` posts
204
+ // without this form's fields, so its unsaved edits would be lost. If the form
205
+ // is dirty, confirm first through the app's themed dialog; the attribute's
206
+ // value is the prompt. We always intercept the click (the themed confirm is
207
+ // async), then re-submit the trigger's form if the user confirms.
208
+ async #onLeaveClick(event) {
209
+ const trigger = event.target.closest("[data-dirty-form-guard-leave]");
210
+ if (!trigger) return;
211
+ // The document listener fires for every guarded form on the page. A leave
212
+ // control discards exactly one form — the one it bypasses — so only that
213
+ // form's instance responds; otherwise unrelated forms would double-prompt.
214
+ if (this.#guardedFormFor(trigger) !== this.element) return;
215
+ if (this.forceClose || this.submitting) return;
216
+ if (!this.#isDirty()) return;
217
+
218
+ event.preventDefault();
219
+ event.stopPropagation();
220
+
221
+ const message =
222
+ trigger.getAttribute("data-dirty-form-guard-leave") ||
223
+ "You have unsaved changes that will be lost. Continue?";
224
+ const confirmed = await this.#confirm(message);
225
+ if (!confirmed) return;
226
+
227
+ // Approved — let the original navigation through without re-prompting. Pass
228
+ // the trigger as the SUBMITTER when it is itself a submit control, so its
229
+ // name/value (e.g. `_direction=back`) is included in the POST — `requestSubmit()`
230
+ // with no submitter drops it, which would silently turn a Back/Cancel into a
231
+ // finalize.
232
+ this.forceClose = true;
233
+ const form = trigger.closest("form");
234
+ if (form) {
235
+ const submitter = trigger.matches("button, input[type=submit], input[type=image]") ? trigger : null;
236
+ form.requestSubmit(submitter);
237
+ }
238
+ }
239
+
240
+ // The CSS selector for a guarded form. The guard is attached as a Stimulus
241
+ // controller (`data-controller="… dirty-form-guard"`), NOT as a CSS class — so
242
+ // match on the controller token, not `form.dirty-form-guard` (which never
243
+ // matches the framework's forms, leaving the leave guard silently dormant).
244
+ static GUARDED_FORM_SELECTOR = "form[data-controller~='dirty-form-guard']";
245
+
246
+ // The single guarded form a leave control discards: the one containing it, or —
247
+ // for a control outside any form (a wizard's sibling nav strip) — the closest
248
+ // guarded form, i.e. the one sharing the deepest common ancestor with the
249
+ // trigger. Returns the only guarded form on simple pages.
250
+ #guardedFormFor(trigger) {
251
+ const selector = this.constructor.GUARDED_FORM_SELECTOR;
252
+ const inside = trigger.closest(selector);
253
+ if (inside) return inside;
254
+
255
+ let best = null;
256
+ let bestDepth = -1;
257
+ document.querySelectorAll(selector).forEach((form) => {
258
+ let ancestor = form;
259
+ while (ancestor && !ancestor.contains(trigger)) ancestor = ancestor.parentElement;
260
+ if (!ancestor) return;
261
+ const depth = this.#depthOf(ancestor);
262
+ if (depth > bestDepth) {
263
+ bestDepth = depth;
264
+ best = form;
265
+ }
266
+ });
267
+ return best;
268
+ }
269
+
270
+ #depthOf(node) {
271
+ let depth = 0;
272
+ while ((node = node.parentElement)) depth++;
273
+ return depth;
274
+ }
275
+
276
+ // Defer to the themed Turbo confirm dialog the app installs as the global
277
+ // confirm method (a styled <dialog>, not the native chrome bar); fall back to
278
+ // window.confirm only if it isn't available. Returns a Promise<boolean>.
279
+ #confirm(message) {
280
+ const turboConfirm = window.Turbo?.config?.forms?.confirm;
281
+ if (typeof turboConfirm === "function") {
282
+ return Promise.resolve(turboConfirm(message));
283
+ }
284
+ return Promise.resolve(window.confirm(message));
285
+ }
286
+
159
287
  #confirmIsOpen() {
160
288
  return this.hasConfirmDialogTarget && this.confirmDialogTarget.open;
161
289
  }
@@ -3,6 +3,7 @@ import { Controller } from "@hotwired/stimulus"
3
3
  // Connects to data-controller="intl-tel-input"
4
4
  export default class extends Controller {
5
5
  static targets = ["input"]
6
+ static values = { options: Object }
6
7
 
7
8
  connect() {
8
9
  }
@@ -49,10 +50,13 @@ export default class extends Controller {
49
50
  }
50
51
 
51
52
  #buildOptions() {
53
+ // Defaults first; the definition-supplied `options` value (e.g.
54
+ // { initialCountry: "gh", strictMode: false }) is spread last so it wins.
52
55
  return {
53
56
  strictMode: true,
54
57
  hiddenInput: () => ({ phone: this.inputTarget.attributes.name.value }),
55
58
  loadUtilsOnInit: "https://cdn.jsdelivr.net/npm/intl-tel-input@24.8.1/build/js/utils.js",
59
+ ...this.optionsValue,
56
60
  }
57
61
  }
58
62
  }