phlex-reactive 0.4.5 → 0.4.6

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 781352f05403f22ccf974061e325f0066b9809c20de36a881279fcea424c55a5
4
- data.tar.gz: dfd1747be189e0c451f390f396d535aa5c36f7e0fd8d95f31a331a584537976c
3
+ metadata.gz: 57f49d14a96bb92d0e65f8b6dc8331390538331630858145ed6a42ad80cc4ba5
4
+ data.tar.gz: 5709021682d0741191df8000fd1b90c10927d1e9bb0f59a8e459728e39a39bc6
5
5
  SHA512:
6
- metadata.gz: 14e5f543ebb18929407b21a90f6851fcbc685954d23c2ef9aa6a37ed770c85bdf7d477bb430799f81837d04a31154ea85343406e6767f128862fecec82aca401
7
- data.tar.gz: 55d32359caa8bb5aa7be4b8db1c55cdf58651b08361dd4bd5e87e4b7bf50dd4cdb32d3aaede40be35966e28cf994ff8047006c085f17fc9002bc26f9cc3cf4d7
6
+ metadata.gz: d69d38d3f339502c69caad92d5730c43f2b995d6e12202823ac62da69e02967e14eaa46dfb57536f74c11f5bd8181c1ef8bc9b4cc12067d2f7eb072b4d996cc2
7
+ data.tar.gz: 30e84a8827a9cfdd515d7a2f9daec8c70b76ea5faffd6bf302f406b6aafc00daddfbf2ba0f4a1e070421e44e34ad3525fe09ecd692b5a009bcd4b0473f1eb087
data/CHANGELOG.md CHANGED
@@ -8,6 +8,22 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
8
8
 
9
9
  ### Added
10
10
 
11
+ - **Overridable / async confirm resolver — reuse your themed dialog (#55).**
12
+ Follow-up to #52. The `confirm:` gate was hardcoded to the synchronous,
13
+ browser-native `window.confirm`, so a reactive trigger was the one interaction
14
+ a Hotwire app couldn't theme — every confirmable reactive action showed the
15
+ unstyled native chrome instead of the app's `Turbo.config.forms.confirm`
16
+ dialog. The client now resolves the confirmation through an overridable hook
17
+ (`confirmResolver`, set via `setConfirmResolver` from `phlex/reactive/confirm`),
18
+ defaulting to `window.confirm`. An app reuses its styled dialog in one line:
19
+ `setConfirmResolver((m) => window.Turbo.config.forms.confirm(m))`. The resolver
20
+ may be **async** — `dispatch` `preventDefault`s up front (preserving the #11
21
+ submit-trigger guarantee), then `await`s the resolver and enqueues only on a
22
+ truthy result; a falsy resolve or a rejection cancels the action and never
23
+ leaks an unhandled rejection. Unset, behavior is byte-for-byte identical to
24
+ 0.4.5 (sync native confirm, no dependency); the `confirm:` markup/`on(...)` API
25
+ is unchanged. README documents the one-line opt-in.
26
+
11
27
  - **`reactive_root` helper — the whole reactive root in one spread (#48).**
12
28
  `reactive_attrs` doesn't emit `id:`, so an app could put `id:` on a *child*
13
29
  element and leave the controller root's `id` empty — which silently re-opened
data/README.md CHANGED
@@ -311,6 +311,7 @@ Use in controllers: `render turbo_stream: Counter.replace(counter)`.
311
311
  | `reactive_attrs` | Marks an element reactive + carries the signed token (no `id`). Spread alongside `id:` on the **same** element: `div(id:, **reactive_attrs)`. Prefer `reactive_root`, which can't split them. |
312
312
  | `on(:action, event: "click", **params)` | Spread onto a trigger element. Adds `type=button` for clicks. |
313
313
  | `on(:action, event: "input", debounce: 300)` | Coalesce rapid events into one round trip after a quiet period (live-as-you-type). |
314
+ | `on(:action, confirm: "Sure?")` | Gate a destructive trigger behind a confirmation. Defaults to `window.confirm`; override the dialog with [`setConfirmResolver`](#custom-confirmation-dialogs-setconfirmresolver). |
314
315
  | `reactive_input(:param, **attrs)` / `reactive_select(:param, **attrs)` | Render a control already bound to an action param (no magic `name:`). |
315
316
  | `reactive_field(:param, **attrs)` | The attribute hash behind the above — spread onto any control. |
316
317
  | `nested_update!(:assoc, attrs)` | Map a nested param onto `<assoc>_attributes` with id preservation; update the record. |
@@ -478,6 +479,37 @@ end
478
479
  `nested_attributes(:address, address)` returns the id-merged hash without
479
480
  updating, if you need to combine it with other attributes.
480
481
 
482
+ ### Custom confirmation dialogs (`setConfirmResolver`)
483
+
484
+ `on(:action, confirm: "Really delete this?")` gates a destructive trigger behind
485
+ a confirmation. Because the reactive controller preempts the event (its own
486
+ `preventDefault` + POST), Hotwire's `data-turbo-confirm` — which routes through
487
+ `Turbo.config.forms.confirm` — never runs for a reactive trigger. So by default
488
+ the gate uses the browser-native `window.confirm` (synchronous, no dependency,
489
+ screen-reader friendly).
490
+
491
+ If your app already themes confirmations (the common Hotwire setup —
492
+ `Turbo.config.forms.confirm = (message) => Promise<boolean>`, backed by a styled
493
+ modal), reuse that exact dialog for reactive triggers with one line at boot:
494
+
495
+ ```js
496
+ import { setConfirmResolver } from "phlex/reactive/confirm"
497
+
498
+ // Reuse the same themed dialog the rest of the app already uses.
499
+ setConfirmResolver((message) => window.Turbo.config.forms.confirm(message))
500
+ ```
501
+
502
+ The resolver receives the `confirm:` message and returns `true`/`false` (or a
503
+ `Promise` of one). It may be **async** — the controller `await`s it, then runs
504
+ the action only on a truthy result; a falsy result (or a rejected promise — e.g.
505
+ the user dismissed the dialog) cancels the action, exactly like declining the
506
+ native prompt. The native default is always prevented up front, so a `submit`
507
+ trigger never navigates while the dialog is open.
508
+
509
+ Unset, behavior is identical to the native `window.confirm` — the `confirm:`
510
+ markup and `on(...)` API are unchanged; only the client's resolution strategy
511
+ gains a seam.
512
+
481
513
  ### `reply` — controlling the action's reply
482
514
 
483
515
  By default an action re-renders its component in place. To do more, **return**
@@ -0,0 +1,34 @@
1
+ // The overridable confirmation resolver behind `on(:action, confirm: "…")`
2
+ // (issue #55, follow-up to #52).
3
+ //
4
+ // The reactive controller preempts the click event (its own preventDefault +
5
+ // POST), so Hotwire's `data-turbo-confirm` — which routes through
6
+ // `Turbo.config.forms.confirm` — never runs for a reactive trigger. That made
7
+ // the reactive path the ONE interaction a Hotwire app couldn't theme: every
8
+ // confirmable reactive action showed the unstyled native window.confirm chrome.
9
+ //
10
+ // This module is the seam. `dispatch` resolves the confirm message through
11
+ // `confirmResolver`, which defaults to a Promise-wrapped window.confirm (so the
12
+ // 0.4.5 behavior is byte-for-byte unchanged when nothing is configured: sync,
13
+ // no dependency, screen-reader friendly). An app reuses its themed dialog with
14
+ // one line at boot:
15
+ //
16
+ // import { setConfirmResolver } from "phlex/reactive/confirm"
17
+ // setConfirmResolver((message) => window.Turbo.config.forms.confirm(message))
18
+ //
19
+ // The resolver may be sync or async — the controller awaits it via
20
+ // Promise.resolve, so a bare boolean and a Promise<boolean> both work. It must
21
+ // resolve truthy to proceed; a falsy resolve (or a rejection) cancels the
22
+ // action — exactly like declining the native prompt.
23
+
24
+ // The default: wrap the synchronous native confirm in a Promise so the call
25
+ // site can always `await` it. Read window lazily (per call), not at module load
26
+ // — under SSR / test the global may not exist yet when this module is imported.
27
+ export let confirmResolver = (message) =>
28
+ Promise.resolve(typeof window !== "undefined" ? window.confirm(message) : true)
29
+
30
+ // Override the resolver. Pass a function `(message) => boolean | Promise<boolean>`.
31
+ // Truthy resolves the action through; falsy (or a rejected Promise) cancels it.
32
+ export function setConfirmResolver(fn) {
33
+ confirmResolver = fn
34
+ }
@@ -1,4 +1,5 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
+ import { confirmResolver } from "./confirm.js"
2
3
 
3
4
  // The ONE generic controller behind every reactive Phlex component. It
4
5
  // replaces the per-feature Stimulus controllers you'd otherwise hand-write
@@ -171,37 +172,53 @@ export default class extends Controller {
171
172
  const { action, params, debounce, confirm } = event.params
172
173
  if (!action) return
173
174
 
174
- // Confirmation gate (issue #52). A destructive reactive trigger can't use
175
- // Hotwire's data-turbo-confirm — this controller preempts the event — so a
176
- // `confirm:` message threads a data-reactive-confirm-param and we prompt
177
- // HERE, BEFORE any preventDefault/enqueue/debounce. On decline we still
178
- // preventDefault (so a `submit`/click can't natively navigate on cancel)
179
- // and bail — nothing is enqueued, no timer is scheduled. window.confirm is
180
- // synchronous + screen-reader friendly and keeps the no-dependency default;
181
- // a richer/async dialog can be layered on additively later.
182
- if (confirm && !window.confirm(confirm)) {
183
- event.preventDefault()
184
- return
185
- }
186
-
187
175
  // Stop native behavior (button submit / FORM NAVIGATION) HERE, synchronously
188
- // within the event dispatch. preventDefault() only works while the event is
189
- // still being handled deferring it into the request-queue microtask (below)
190
- // is too late: a `submit` trigger would natively POST the form and navigate
191
- // before the reactive round trip runs (issue #11). For a `click` trigger
192
- // there's no default to miss, so this was previously invisible. This holds
176
+ // within the event dispatch BEFORE the (possibly async) confirm gate below.
177
+ // preventDefault() only works while the event is still being handled; once we
178
+ // await the confirm resolver it's too late, and a `submit` trigger would
179
+ // natively POST the form and navigate before the reactive round trip runs
180
+ // (issue #11). For a `click` trigger there's no default to miss. This holds
193
181
  // for debounced triggers too — the round trip is deferred, but the native
194
- // default must still be prevented now.
182
+ // default must still be prevented now. (Moved ahead of the confirm branch in
183
+ // issue #55: an async resolver means we can't preventDefault after awaiting.)
195
184
  event.preventDefault()
196
185
 
186
+ // Capture the trigger element now; #proceed runs in a later microtask (after
187
+ // the confirm resolver settles), by which point event.target may be reset.
188
+ const target = event.target
189
+
190
+ // No confirm message → proceed straight away (unchanged fast path).
191
+ if (!confirm) return this.#proceed(target, action, params, debounce)
192
+
193
+ // Confirmation gate (issue #52, made overridable + async in #55). A reactive
194
+ // trigger can't use Hotwire's data-turbo-confirm — this controller preempts
195
+ // the event — so a `confirm:` message routes through confirmResolver (default
196
+ // window.confirm; an app can override it to reuse Turbo.config.forms.confirm).
197
+ // The resolver may be sync or async; call it INSIDE the chain (via the leading
198
+ // .then) so even a SYNCHRONOUS override throw rejects this promise instead of
199
+ // escaping dispatch — a throwing dialog is treated as a cancel, like the user
200
+ // dismissing it. The .catch is scoped to the resolver step (→ false = cancel),
201
+ // so a dismissed/erroring dialog never surfaces as an unhandled rejection AND a
202
+ // genuine bug inside #proceed is NOT silently swallowed. Enqueue ONLY on a
203
+ // truthy resolution — nothing is enqueued, no timer scheduled, otherwise.
204
+ Promise.resolve()
205
+ .then(() => confirmResolver(confirm))
206
+ .catch(() => false)
207
+ .then((ok) => {
208
+ if (ok) this.#proceed(target, action, params, debounce)
209
+ })
210
+ }
211
+
212
+ // Enqueue the action — debounced if a debounce window is set, else immediately.
213
+ // Split out of dispatch so both the no-confirm fast path and the post-confirm
214
+ // microtask share one place (issue #55). `target` is captured up front because
215
+ // this can run in a later microtask, after event.target has been reset.
216
+ #proceed(target, action, params, debounce) {
197
217
  // Debounced trigger (e.g. on(:update, event: "input", debounce: 300)):
198
218
  // coalesce rapid events into ONE round trip after a quiet period, instead of
199
219
  // one POST per keystroke (issue #17). A blur flushes a pending dispatch.
200
220
  const ms = Number(debounce) || 0
201
- if (ms > 0) return this.#debounceDispatch(event.target, ms, action, params)
202
-
203
- // Capture action/params now; the queued work runs in a later microtask, by
204
- // which point the event object may have been reset by the browser.
221
+ if (ms > 0) return this.#debounceDispatch(target, ms, action, params)
205
222
  return this.#enqueue(action, params)
206
223
  }
207
224
 
@@ -24,7 +24,10 @@ module Phlex
24
24
  initializer "phlex_reactive.assets" do
25
25
  if it.config.respond_to?(:assets)
26
26
  it.config.assets.paths << root.join("app/javascript").to_s
27
- it.config.assets.precompile += %w[phlex/reactive/reactive_controller.js]
27
+ it.config.assets.precompile += %w[
28
+ phlex/reactive/reactive_controller.js
29
+ phlex/reactive/confirm.js
30
+ ]
28
31
  end
29
32
  end
30
33
 
@@ -38,6 +41,14 @@ module Phlex
38
41
  to: "phlex/reactive/reactive_controller.js",
39
42
  preload: true
40
43
  )
44
+ # The overridable confirm resolver (issue #55). reactive_controller.js
45
+ # imports it relatively (./confirm.js), and an app reuses its themed
46
+ # dialog via `import { setConfirmResolver } from "phlex/reactive/confirm"`.
47
+ it.importmap.pin(
48
+ "phlex/reactive/confirm",
49
+ to: "phlex/reactive/confirm.js",
50
+ preload: true
51
+ )
41
52
  end
42
53
  end
43
54
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Phlex
4
4
  module Reactive
5
- VERSION = "0.4.5"
5
+ VERSION = "0.4.6"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: phlex-reactive
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.5
4
+ version: 0.4.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson
@@ -117,6 +117,7 @@ files:
117
117
  - LICENSE.txt
118
118
  - README.md
119
119
  - app/controllers/phlex/reactive/actions_controller.rb
120
+ - app/javascript/phlex/reactive/confirm.js
120
121
  - app/javascript/phlex/reactive/reactive_controller.js
121
122
  - lib/generators/phlex/reactive/component/USAGE
122
123
  - lib/generators/phlex/reactive/component/component_generator.rb