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.
- checksums.yaml +4 -4
- data/.claude/skills/plutonium/SKILL.md +19 -1
- data/.claude/skills/plutonium-app/SKILL.md +41 -0
- data/.claude/skills/plutonium-auth/SKILL.md +40 -0
- data/.claude/skills/plutonium-behavior/SKILL.md +47 -1
- data/.claude/skills/plutonium-kanban/SKILL.md +378 -0
- data/.claude/skills/plutonium-resource/SKILL.md +40 -0
- data/.claude/skills/plutonium-tenancy/SKILL.md +43 -0
- data/.claude/skills/plutonium-testing/SKILL.md +38 -0
- data/.claude/skills/plutonium-ui/SKILL.md +51 -0
- data/.claude/skills/plutonium-wizard/SKILL.md +469 -0
- data/.cliff.toml +6 -0
- data/Appraisals +3 -0
- data/CHANGELOG.md +576 -439
- data/CLAUDE.md +15 -7
- data/app/assets/plutonium.css +1 -1
- data/app/assets/plutonium.js +1173 -194
- data/app/assets/plutonium.js.map +4 -4
- data/app/assets/plutonium.min.js +53 -53
- data/app/assets/plutonium.min.js.map +4 -4
- data/app/views/layouts/basic.html.erb +7 -0
- data/app/views/plutonium/_flash_toasts.html.erb +2 -46
- data/app/views/plutonium/_toast.html.erb +52 -0
- data/app/views/resource/_kanban_move_action_form.html.erb +1 -0
- data/app/views/resource/_resource_kanban.html.erb +1 -0
- data/app/views/resource/kanban_move_form.html.erb +1 -0
- data/config/brakeman.ignore +2 -2
- data/db/migrate/wizard/20260615000001_create_plutonium_wizard_sessions.rb +57 -0
- data/docs/.vitepress/config.ts +45 -1
- data/docs/.vitepress/sync-skills.mjs +45 -0
- data/docs/ai.md +99 -0
- data/docs/guides/index.md +2 -0
- data/docs/guides/kanban.md +557 -0
- data/docs/guides/wizards.md +447 -0
- data/docs/public/images/guides/kanban-after-move.png +0 -0
- data/docs/public/images/guides/kanban-board-light.png +0 -0
- data/docs/public/images/guides/kanban-board.png +0 -0
- data/docs/public/images/guides/kanban-show-centered-modal.png +0 -0
- data/docs/public/images/guides/kanban-wip-toast.png +0 -0
- data/docs/public/images/guides/wizards-chooser.png +0 -0
- data/docs/public/images/guides/wizards-completed.png +0 -0
- data/docs/public/images/guides/wizards-index-action.png +0 -0
- data/docs/public/images/guides/wizards-repeater.png +0 -0
- data/docs/public/images/guides/wizards-review.png +0 -0
- data/docs/public/images/guides/wizards-step.png +0 -0
- data/docs/reference/behavior/policies.md +1 -1
- data/docs/reference/index.md +14 -0
- data/docs/reference/kanban/authorization.md +82 -0
- data/docs/reference/kanban/dsl.md +334 -0
- data/docs/reference/kanban/index.md +40 -0
- data/docs/reference/kanban/positioning.md +162 -0
- data/docs/reference/resource/definition.md +26 -1
- data/docs/reference/resource/model.md +26 -0
- data/docs/reference/ui/forms.md +77 -0
- data/docs/reference/ui/pages.md +2 -0
- data/docs/reference/wizard/anchoring-resume.md +194 -0
- data/docs/reference/wizard/dsl.md +337 -0
- data/docs/reference/wizard/index.md +33 -0
- data/docs/reference/wizard/one-time.md +129 -0
- data/docs/reference/wizard/registration-launch.md +177 -0
- data/docs/reference/wizard/storage-config.md +151 -0
- data/docs/superpowers/plans/2026-06-14-form-sectioning.md +2 -2
- data/docs/superpowers/plans/2026-06-15-wizard-dsl.md +1619 -0
- data/docs/superpowers/plans/2026-06-15-wizard-dsl.md.tasks.json +68 -0
- data/docs/superpowers/plans/2026-06-26-kanban-dsl.md +1128 -0
- data/docs/superpowers/plans/2026-06-26-kanban-dsl.md.tasks.json +24 -0
- data/docs/superpowers/plans/2026-07-02-kanban-drop-interactions.md +714 -0
- data/docs/superpowers/plans/2026-07-02-kanban-drop-interactions.md.tasks.json +68 -0
- data/docs/superpowers/specs/2026-06-15-wizard-dsl-design.md +836 -0
- data/docs/superpowers/specs/2026-06-15-wizard-dsl-examples.rb +245 -0
- data/docs/superpowers/specs/2026-06-17-wizard-relaunch-prompt-design.md +86 -0
- data/docs/superpowers/specs/2026-06-18-wizard-attachments-design.md +101 -0
- data/docs/superpowers/specs/2026-06-18-wizard-hosting-design.md +220 -0
- data/docs/superpowers/specs/2026-06-26-kanban-dsl-design.md +388 -0
- data/docs/superpowers/specs/2026-07-03-kanban-auth-simplification.md +159 -0
- data/gemfiles/postgres.gemfile +8 -0
- data/gemfiles/postgres.gemfile.lock +321 -0
- data/gemfiles/rails_7.gemfile +1 -0
- data/gemfiles/rails_7.gemfile.lock +1 -1
- data/gemfiles/rails_8.0.gemfile +1 -0
- data/gemfiles/rails_8.0.gemfile.lock +1 -1
- data/gemfiles/rails_8.1.gemfile +1 -0
- data/gemfiles/rails_8.1.gemfile.lock +14 -1
- data/lib/generators/pu/gem/active_shrine/active_shrine_generator.rb +5 -0
- data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt +6 -1
- data/lib/plutonium/action/base.rb +17 -0
- data/lib/plutonium/auth/rodauth.rb +1 -2
- data/lib/plutonium/configuration.rb +16 -0
- data/lib/plutonium/core/controller.rb +20 -1
- data/lib/plutonium/definition/base.rb +25 -0
- data/lib/plutonium/definition/form_layout.rb +54 -35
- data/lib/plutonium/definition/index_views.rb +70 -1
- data/lib/plutonium/definition/wizards.rb +209 -0
- data/lib/plutonium/invites/concerns/invite_token.rb +9 -0
- data/lib/plutonium/invites/concerns/invite_user.rb +9 -0
- data/lib/plutonium/invites/controller.rb +4 -1
- data/lib/plutonium/kanban/action.rb +7 -0
- data/lib/plutonium/kanban/board.rb +40 -0
- data/lib/plutonium/kanban/broadcaster.rb +54 -0
- data/lib/plutonium/kanban/column.rb +122 -0
- data/lib/plutonium/kanban/context.rb +15 -0
- data/lib/plutonium/kanban/dsl.rb +71 -0
- data/lib/plutonium/kanban/grouping.rb +51 -0
- data/lib/plutonium/kanban/positioning.rb +75 -0
- data/lib/plutonium/kanban.rb +11 -0
- data/lib/plutonium/migrations.rb +40 -0
- data/lib/plutonium/models/has_cents.rb +30 -2
- data/lib/plutonium/positioning.rb +146 -0
- data/lib/plutonium/railtie.rb +33 -0
- data/lib/plutonium/resource/controller.rb +24 -1
- data/lib/plutonium/resource/controllers/crud_actions.rb +9 -1
- data/lib/plutonium/resource/controllers/kanban_actions.rb +851 -0
- data/lib/plutonium/resource/controllers/wizard_actions.rb +165 -0
- data/lib/plutonium/resource/policy.rb +14 -0
- data/lib/plutonium/routing/mapper_extensions.rb +45 -0
- data/lib/plutonium/routing/wizard_registration.rb +289 -0
- data/lib/plutonium/ui/display/components/currency.rb +41 -9
- data/lib/plutonium/ui/display/options/inferred_types.rb +2 -5
- data/lib/plutonium/ui/display/resource.rb +17 -12
- data/lib/plutonium/ui/form/base.rb +25 -5
- data/lib/plutonium/ui/form/components/currency.rb +64 -0
- data/lib/plutonium/ui/form/components/intl_tel_input.rb +27 -1
- data/lib/plutonium/ui/form/components/password.rb +126 -0
- data/lib/plutonium/ui/form/components/uppy.rb +26 -5
- data/lib/plutonium/ui/form/kanban_move.rb +46 -0
- data/lib/plutonium/ui/form/options/inferred_types.rb +26 -0
- data/lib/plutonium/ui/form/resource.rb +13 -1
- data/lib/plutonium/ui/form/theme.rb +7 -0
- data/lib/plutonium/ui/form/wizard.rb +63 -0
- data/lib/plutonium/ui/grid/card.rb +56 -18
- data/lib/plutonium/ui/kanban/card.rb +67 -0
- data/lib/plutonium/ui/kanban/color_dot.rb +36 -0
- data/lib/plutonium/ui/kanban/column.rb +411 -0
- data/lib/plutonium/ui/kanban/resource.rb +319 -0
- data/lib/plutonium/ui/layout/base.rb +1 -1
- data/lib/plutonium/ui/layout/resource_layout.rb +7 -1
- data/lib/plutonium/ui/modal/base.rb +30 -3
- data/lib/plutonium/ui/modal/centered.rb +5 -2
- data/lib/plutonium/ui/options/has_cents_field.rb +21 -0
- data/lib/plutonium/ui/page/index.rb +2 -1
- data/lib/plutonium/ui/page/interactive_action.rb +12 -2
- data/lib/plutonium/ui/page/kanban_move.rb +20 -0
- data/lib/plutonium/ui/page/show.rb +29 -1
- data/lib/plutonium/ui/page/wizard.rb +371 -0
- data/lib/plutonium/ui/page/wizard_chooser.rb +97 -0
- data/lib/plutonium/ui/page/wizard_completed.rb +86 -0
- data/lib/plutonium/ui/table/base.rb +1 -1
- data/lib/plutonium/ui/table/components/view_switcher.rb +2 -1
- data/lib/plutonium/ui/table/resource.rb +1 -1
- data/lib/plutonium/ui/wizard/review.rb +196 -0
- data/lib/plutonium/ui/wizard/stepper.rb +122 -0
- data/lib/plutonium/ui/wizard/summary_display.rb +92 -0
- data/lib/plutonium/version.rb +1 -1
- data/lib/plutonium/wizard/attachment_data.rb +42 -0
- data/lib/plutonium/wizard/attachments.rb +226 -0
- data/lib/plutonium/wizard/base.rb +216 -0
- data/lib/plutonium/wizard/base_controller.rb +31 -0
- data/lib/plutonium/wizard/configuration.rb +42 -0
- data/lib/plutonium/wizard/controller.rb +162 -0
- data/lib/plutonium/wizard/data.rb +134 -0
- data/lib/plutonium/wizard/driving.rb +639 -0
- data/lib/plutonium/wizard/dsl.rb +336 -0
- data/lib/plutonium/wizard/errors.rb +27 -0
- data/lib/plutonium/wizard/field_capture.rb +157 -0
- data/lib/plutonium/wizard/field_importer.rb +208 -0
- data/lib/plutonium/wizard/gate.rb +171 -0
- data/lib/plutonium/wizard/instance_key.rb +97 -0
- data/lib/plutonium/wizard/lazy_persisted.rb +77 -0
- data/lib/plutonium/wizard/resume.rb +250 -0
- data/lib/plutonium/wizard/review_step.rb +48 -0
- data/lib/plutonium/wizard/route_resolution.rb +40 -0
- data/lib/plutonium/wizard/runner.rb +684 -0
- data/lib/plutonium/wizard/session.rb +53 -0
- data/lib/plutonium/wizard/state.rb +35 -0
- data/lib/plutonium/wizard/step.rb +61 -0
- data/lib/plutonium/wizard/step_adapter.rb +103 -0
- data/lib/plutonium/wizard/store/active_record.rb +174 -0
- data/lib/plutonium/wizard/store/base.rb +42 -0
- data/lib/plutonium/wizard/store/memory.rb +44 -0
- data/lib/plutonium/wizard/sweep_job.rb +76 -0
- data/lib/plutonium/wizard.rb +86 -0
- data/lib/plutonium.rb +5 -0
- data/lib/rodauth/features/case_insensitive_login.rb +1 -1
- data/lib/tasks/release.rake +144 -191
- data/package.json +7 -5
- data/src/css/components.css +137 -0
- data/src/js/controllers/attachment_input_controller.js +15 -1
- data/src/js/controllers/currency_input_controller.js +39 -0
- data/src/js/controllers/dirty_form_guard_controller.js +155 -27
- data/src/js/controllers/intl_tel_input_controller.js +4 -0
- data/src/js/controllers/kanban_controller.js +717 -0
- data/src/js/controllers/password_sentinel_controller.js +39 -0
- data/src/js/controllers/register_controllers.js +8 -0
- data/src/js/controllers/remote_modal_controller.js +10 -0
- data/src/js/controllers/row_click_controller.js +14 -1
- data/src/js/controllers/wizard_controller.js +54 -0
- data/src/js/turbo/turbo_confirm.js +1 -1
- data/yarn.lock +945 -286
- 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
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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.
|
|
59
|
-
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
|
-
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
|
|
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
|
-
|
|
74
|
-
this
|
|
75
|
-
|
|
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.
|
|
79
|
-
this.
|
|
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
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
}
|