phlex-reactive 0.9.1 → 0.9.3

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: 0b8404995a25e3917e5785b4e7becaceff75359c5577dd82f8b44e8dd95e2049
4
- data.tar.gz: f1a6e61d8d840aab2273f95405fc19e3e58295c54d2bf10e40ef21bc9bef2b75
3
+ metadata.gz: 86eeeabfa81b914dae1f3598e3c879892478c712afc5631b500b96cbd9b83757
4
+ data.tar.gz: d72882a3b92db0f2d542b5ef457a13f9e74fda538cb3ee5db7d2ac5916833515
5
5
  SHA512:
6
- metadata.gz: 4218da118f87bb6319b54ec8583437f5d208305d936eedbd81b9eaf01ff17941f5a8b7baac98ef141b29ace3b6dbae937b0cee10368b482b2fef2ba9268c49f4
7
- data.tar.gz: ead67e058d25a3443286e70f9a245746604cc584de2a0160446e56a306a99c5f95f8ec0536ca0ec5a1fd1205a3c63ecf27d741e3f8eb992396155c06a84bdb58
6
+ metadata.gz: 884a4c34c7d26930bea2873bae0cf3a1ec0ff82d796179b01fe493187f8001d36283ea1e22daeaa347ab72ca8e74be31773cbba77bf0db41bb38781eb578c340
7
+ data.tar.gz: 4b75f6edec65f1d84954260305d384ad6674e21ee3fc5f51270e7055e7679a7d1bb209783d6678b2c506259bf6d1688c6a67c991cc013b22a6f66961bfec84f8
data/CHANGELOG.md CHANGED
@@ -6,6 +6,45 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ### Added
10
+
11
+ - **Value-conditional visibility — `reactive_show` (#161).** The `x-show` /
12
+ `data-show` / `wire:show` case — show/hide an element from a form field's
13
+ **current value** — no longer needs a hand-written `change`-listener Stimulus
14
+ controller. Spread `reactive_show(:mode, not: "off")` onto the element to
15
+ show/hide (also `equals:` and `in: [...]`; `equals: true` reads a checkbox's
16
+ checked state, a radio group reads the checked radio's value) and the generic
17
+ controller toggles the `hidden` attribute on every `input`/`change` —
18
+ client-only, zero round trip, no token. The predicate is a **declared literal
19
+ match**, never an expression (no eval surface); exactly one predicate is
20
+ enforced loudly at render; a missing field or malformed wire attr is
21
+ warn-and-skipped (client-side default-deny). Visibility seeds at connect and
22
+ re-syncs after a `turbo:morph-element`; a `reactive_compute` output write
23
+ dispatches a real `input` event, so derived values drive visibility too.
24
+ Ownership follows the nested-root rules (#15). Roots without a binding pay
25
+ one connect-time probe — no new listeners, byte-identical wire.
26
+
27
+ - **Cross-root text mirrors + the `text` client op (#159).** A derived value can
28
+ now be painted into a text node **outside** the computing component's reactive
29
+ root — the read-only recap in another tab pane that previously forced a
30
+ bespoke JS listener — with the library's default-deny posture intact:
31
+ - `reactive_compute ..., mirror: { sum_total: "#sum_total" }` declares
32
+ allowlisted cross-root text mirrors. Each compute pass paints every declared
33
+ name into its document-wide **id** target(s) via `textContent`
34
+ (change-guarded, never `innerHTML`, never blanks a name the pass produced no
35
+ value for). The value comes from the reducer result, a just-written output's
36
+ field, or a declared input's identity value — so it works with no reducer at
37
+ all. Non-id selectors raise at declare time AND are warn-and-skipped by the
38
+ client interpreter (two-sided default-deny). No `mirror:` → the wire is
39
+ byte-identical to before.
40
+ - `js.text(to, value, global: false)` — a new op that sets `textContent`
41
+ (stringified; `nil` clears), available to `on_client`, `reply.js`, and
42
+ `broadcast_js_to`. Strictly less powerful than `set_attr`; pair with
43
+ `global: true` for the cross-root paint.
44
+ - `global: true` is now honored on the `reactive:js` stream path: a single op
45
+ can opt out of the reply's target-root scope to document-wide resolution
46
+ (previously it was silently ignored when a `target` was set).
47
+
9
48
  ## [0.9.0] - 2026-07-03
10
49
 
11
50
  Consolidates everything since 0.2.6; tags 0.2.7–0.4.8 shipped without changelog
data/README.md CHANGED
@@ -354,11 +354,13 @@ Use in controllers: `render turbo_stream: Counter.replace(counter)`.
354
354
  | `busy_on(:save)` | Mark any element so it carries `data-reactive-busy` **only while `save` is in flight** — a spinner styled with pure CSS, zero Ruby. See [Loading states](#declarative-loading-states-loading--disable_with). |
355
355
  | `on(:action, once: true)` | Fire at most once, then unbind (Stimulus's native `:once`). |
356
356
  | `on_client(:click, js.toggle("#menu"))` | **Client-only** trigger: applies declared DOM ops with ZERO round trip — no token, no POST, ever. Takes the same `window:`/`once:`/`outside:` modifiers. See [Client-only ops](#client-only-ops-on_client--js--zero-round-trips). |
357
- | `js` | The immutable op builder behind `on_client`: `show`/`hide`/`toggle` (the `hidden` attribute, with an optional `transition:`), `add_class`/`remove_class`/`toggle_class`, `set_attr`/`remove_attr`/`toggle_attr` (allowlisted names), `focus`/`focus_first`, and `dispatch` — chainable. |
357
+ | `js` | The immutable op builder behind `on_client`: `show`/`hide`/`toggle` (the `hidden` attribute, with an optional `transition:`), `add_class`/`remove_class`/`toggle_class`, `set_attr`/`remove_attr`/`toggle_attr` (allowlisted names), `focus`/`focus_first`, `text` (set `textContent` — XSS-safe), and `dispatch` — chainable. |
358
358
  | `reactive_input(:param, **attrs)` / `reactive_select(:param, **attrs)` | Render a control already bound to an action param (no magic `name:`). |
359
359
  | `reactive_field(:param, **attrs)` | The attribute hash behind the above — spread onto any control. |
360
360
  | `reactive_text(:name, initial)` | Mirror a compute output (or a declared input) into a **text node** — a live preview heading, a character counter, `"Hello, {name}"` — via `textContent` (XSS-safe). The text sibling of `reactive_field`; carries no `name`, so it's never POSTed. See [Client-side computes](#client-side-computes-reactive_compute--reactive_text). |
361
+ | `reactive_show(:field, equals:/not:/in:)` | **Value-conditional visibility** (the `x-show`/`data-show` case): spread onto the element to show/hide — it toggles `hidden` from the named field's **current value**, client-only, zero round trip. One literal predicate: `equals:`, `not:`, or `in: [...]`; `equals: true` reads a checkbox's checked state. See [Value-conditional visibility](#value-conditional-visibility-reactive_show). |
361
362
  | `reactive_compute :name, inputs: { title: :string, qty: :number }, outputs:` | **Typed** inputs: a `:string` reaches the JS reducer raw, a `:number` is coerced through `Number`. The array form (`inputs: %i[a b]`) stays all-numeric. |
363
+ | `reactive_compute :name, ..., mirror: { sum: "#summary-sum" }` | **Cross-root text mirrors**: paint a compute value into declared, id-allowlisted nodes **outside** the reactive root (a recap in another tab pane) via `textContent` — no bespoke listener. See [Cross-root mirrors](#cross-root-mirrors-mirror--painting-a-recap-outside-the-root). |
362
364
  | `reactive_root(track_dirty: true, warn_unsaved: true)` / `reactive_field(:param, dirty: true)` | **Dirty tracking** against the DOM's own `defaultValue`/`defaultChecked`/`defaultSelected` — no client state. Marks changed fields + the root `data-reactive-dirty`; `warn_unsaved:` arms a `beforeunload`/`turbo:before-visit` guard. Style with `[data-reactive-dirty]`. See [Dirty-field tracking](#dirty-field-tracking-dirty--track_dirty--warn_unsaved). |
363
365
  | `nested_update!(:assoc, attrs)` | Map a nested param onto `<assoc>_attributes` with id preservation; update the record. |
364
366
  | `reactive_collection :name, item:, container:, count:, empty:, size:` | Declare an add/remove-row list once; actions call `reply.append`/`prepend`/`remove`. See [Reactive collections](#reactive-collections-addremove-rows--count--empty-state). |
@@ -771,6 +773,11 @@ button(**on_client(:click, js
771
773
  - **`focus(to)`** focuses the first match; **`focus_first(to)`** focuses the first
772
774
  focusable descendant of the match (e.g. the first menuitem inside an opened
773
775
  menu).
776
+ - **`text(to, value)`** sets the target's `textContent` (stringified; `nil`
777
+ clears) — **XSS-safe by construction**, never `innerHTML`, strictly less
778
+ powerful than `set_attr`. With `global: true` it is the **cross-root text
779
+ escape**: paint a value into a recap node outside the component's root
780
+ (`js.text("#sum_total", total, global: true)`).
774
781
  - **`dispatch(name, to: nil, detail: {})`** emits a **bubbling `CustomEvent`** so
775
782
  another component or a plain Stimulus controller can react to a client-only
776
783
  interaction — `to:` picks the element (default: the component root), `detail:`
@@ -849,6 +856,57 @@ free as an ordinary action-param name (`on(:switch, key: "pgbus")` still passes
849
856
  > trigger to its own element (the field saves on Enter; a Cancel button — or the
850
857
  > field's own blur — handles Escape), as above.
851
858
 
859
+ ### Value-conditional visibility (`reactive_show`)
860
+
861
+ `on_client` covers the *unconditional* client-only interactions; the last gap
862
+ was **show/hide from a form field's current value** — the Alpine `x-show` /
863
+ Datastar `data-show` / Livewire `wire:show` case ("reveal the details panel
864
+ *while* this select isn't `none`"). That used to force a hand-written
865
+ `change`-listener Stimulus controller back into an otherwise declarative form.
866
+ `reactive_show` closes it: spread it onto the element to show/hide, name the
867
+ controlling field, declare **one literal predicate** — the generic controller
868
+ toggles the `hidden` attribute from the field's current value on every
869
+ `input`/`change`. Client-only, **no token, no POST, ever**:
870
+
871
+ ```ruby
872
+ def view_template
873
+ div(**reactive_root) do
874
+ select(name: "mode") { shipping_options }
875
+ div(**reactive_show(:mode, not: "off", hidden: @order.mode == "off")) { shipping_details }
876
+
877
+ input(type: "checkbox", name: "gift")
878
+ div(**reactive_show(:gift, equals: true, hidden: true)) { gift_message_field }
879
+
880
+ select(name: "size") { size_options }
881
+ div(**reactive_show(:size, in: %w[l xl], hidden: true)) { surcharge_note }
882
+ end
883
+ end
884
+ ```
885
+
886
+ - **One predicate per binding** — `equals:`, `not:`, or `in:` (a list); zero or
887
+ two raise at render (a dead binding must not silently no-op). Values are
888
+ **stringified literals** matched against the field's value — never an
889
+ expression, so there is no eval surface.
890
+ - **Field reads follow the collection rules**: a checkbox compares its
891
+ *checked* state as `"true"`/`"false"` (so `equals: true` is the checkbox
892
+ form — its `.value` is the constant `"on"`, and it wins over the hidden
893
+ input Rails pairs with it); a radio group reads the **checked** radio's
894
+ value (`""` when none is); everything else reads `.value`. Ownership is the
895
+ usual rule — a nested reactive component's fields and bindings belong to the
896
+ nested component.
897
+ - **Initial state**: the client seeds visibility at connect and reconciles
898
+ after a morph, but render the initial `hidden:` yourself (from the same
899
+ server state that renders the field, as above) so the first paint doesn't
900
+ flash.
901
+ - **Extra attrs ride through the helper** (`reactive_show(:mode, not: "off",
902
+ class: "panel", data: { testid: "details" })`) and deep-merge — a bare
903
+ `data:` spread *beside* it would clobber the binding, same as `on(...)`.
904
+ - **Composes with computes**: a `reactive_compute` output write dispatches a
905
+ real `input` event, so a *derived* value can drive visibility too.
906
+ - Presentational only, strictly weaker than the js ops: it reads an owned
907
+ field and toggles `hidden` on an owned element — no `innerHTML`, no
908
+ attribute freedom, no cross-root writes.
909
+
852
910
  ### Client-side computes (`reactive_compute` + `reactive_text`)
853
911
 
854
912
  Some math should feel instant with **no round trip** — a NEW, unsaved record's
@@ -896,6 +954,47 @@ setComputeReducer("preview", ({ title }) => ({
896
954
  a later morph repaints stale text — the same reconcile contract the whole
897
955
  new-vs-persisted split relies on.
898
956
 
957
+ ### Cross-root mirrors (`mirror:`) — painting a recap outside the root
958
+
959
+ `reactive_text` is deliberately **root-isolated** (a nested component's nodes are
960
+ never touched — issue #15's ownership rule). But a derived value often needs to
961
+ show up in a text node that *isn't inside the computing root at all*: a read-only
962
+ recap in another tab pane, a sticky footer total. Collapsing two components into
963
+ one form-wide root just for a display mirror would be a large, risky restructure —
964
+ so the component **declares the escape** instead:
965
+
966
+ ```ruby
967
+ reactive_compute :split,
968
+ inputs: %i[a b total],
969
+ outputs: %i[a b],
970
+ mirror: { sum_a: "#sum_a", sum_total: ["#sum_total", "#footer-total"] }
971
+ ```
972
+
973
+ On every compute pass, each declared mirror name is painted into its
974
+ document-wide id target(s) via `textContent`. The value comes from wherever the
975
+ pass produced it — one declaration covers all three shapes:
976
+
977
+ - a **reducer-result key** (`sum_total:` above — an *extra*, text-only output the
978
+ reducer returns alongside its real outputs),
979
+ - a **just-written output**'s settled field value,
980
+ - a declared **input**'s identity value (works with **no reducer at all**, like
981
+ the owned-text-node identity mirror).
982
+
983
+ A name the pass produced no value for is **skipped — a mirror never blanks a
984
+ recap**. The security posture matches the rest of the library's default-deny:
985
+
986
+ - **Opt-in and declared, never implicit.** Only the selectors in the `mirror:`
987
+ map are ever written — a plain `reactive_text` node stays root-isolated.
988
+ - **Id selectors only.** A class/attribute/`*`/compound selector **raises at
989
+ declare time**, and the client interpreter re-checks the same shape
990
+ (warn-and-skip) — a hand-built attr can't widen a declared mirror into a
991
+ page-wide selector write.
992
+ - **`textContent` only, never `innerHTML`** — XSS-safe by construction, and never
993
+ a field/attribute/style write.
994
+
995
+ For a **server-driven** cross-root paint (from an action reply or a broadcast),
996
+ use the `text` op instead: `reply.js(js.text("#sum_total", total, global: true))`.
997
+
899
998
  ### Combobox keyboard navigation (`listnav:`)
900
999
 
901
1000
  A searchable list needs Arrow keys to move a highlight, Enter to pick, Escape to
@@ -1129,6 +1228,9 @@ end
1129
1228
  `target:` scopes op resolution on the client; it defaults to the bound
1130
1229
  component's id for `replace`/`morph`/`update` (so `@root` and component-relative
1131
1230
  selectors just work), and to document-scope for a subject-free `reply.with`.
1231
+ `global: true` on a single op opts it out of the target scope to document-wide
1232
+ resolution — `reply.morph.js(js.text("#sum_total", total, global: true))` paints
1233
+ a recap node outside the component while the morph stays root-targeted.
1132
1234
 
1133
1235
  The same ops broadcast to **every** subscriber of a stream over the usual
1134
1236
  transport (Action Cable **or** pgbus) — a background nudge to all viewers:
@@ -399,6 +399,15 @@ const CLIENT_OPS = Object.freeze({
399
399
  focus: (el) => el.focus?.(),
400
400
  focus_first: (el) => firstFocusable(el)?.focus?.(),
401
401
 
402
+ // Text op (issue #159): set textContent — XSS-safe by construction (never
403
+ // innerHTML), strictly less powerful than set_attr. Change-guarded like
404
+ // #mirrorText. With global: true it is the cross-root text escape: paint a
405
+ // value into a recap node OUTSIDE the component's root.
406
+ text: (el, args) => {
407
+ const text = String(args.value ?? "")
408
+ if (el.textContent !== text) el.textContent = text
409
+ },
410
+
402
411
  // Dispatch a bubbling CustomEvent (issue #96). RAW element.dispatchEvent — the
403
412
  // controller SHADOWS Stimulus's this.dispatch helper, so it must not be used.
404
413
  dispatch: (el, args) => {
@@ -421,6 +430,49 @@ function guardAttr(name) {
421
430
  return false
422
431
  }
423
432
 
433
+ // A cross-root mirror target must be a single ID selector (issue #159) — "#" +
434
+ // a CSS identifier, nothing else. The client half of the two-sided default-deny
435
+ // (reactive_compute's `mirror:` validates the SAME shape loudly at declare
436
+ // time): a hand-built mirror attr must not widen a declared text mirror into a
437
+ // page-wide selector write. A refused selector warns + skips (its siblings
438
+ // still apply), matching the attr-allowlist posture.
439
+ const MIRROR_ID_SELECTOR = /^#[A-Za-z_][\w-]*$/
440
+ function guardMirrorSelector(selector) {
441
+ if (typeof selector === "string" && MIRROR_ID_SELECTOR.test(selector)) return true
442
+ console.warn(`[phlex-reactive] refused cross-root mirror target ${JSON.stringify(selector)} — skipped`)
443
+ return false
444
+ }
445
+
446
+ // Evaluate a show binding's declared literal predicate (issue #161) against
447
+ // the controlling field's current value. Exactly one of the three predicate
448
+ // attrs decides: equals (value === literal), not (value !== literal), in
449
+ // (value ∈ a JSON string list). The vocabulary is fixed and literal-only —
450
+ // never an expression, so there is no eval surface (the reactive_show helper
451
+ // enforces the same shape loudly at render; this is the client half of the
452
+ // two-sided posture). Returns true/false for a decidable binding, or null for
453
+ // a malformed/missing predicate — the caller SKIPS a null so a hand-built or
454
+ // stale binding never flips visibility it doesn't understand (default-deny,
455
+ // like the op whitelist).
456
+ function showBindingMatches(el, value) {
457
+ const equals = el.getAttribute("data-reactive-show-equals")
458
+ if (equals !== null) return value === equals
459
+ const not = el.getAttribute("data-reactive-show-not")
460
+ if (not !== null) return value !== not
461
+ const inRaw = el.getAttribute("data-reactive-show-in")
462
+ if (inRaw !== null) {
463
+ try {
464
+ const list = JSON.parse(inRaw)
465
+ if (Array.isArray(list)) return list.includes(value)
466
+ } catch {
467
+ // fall through to the warn below — malformed JSON and a non-array both skip
468
+ }
469
+ console.warn(`[phlex-reactive] malformed reactive_show in: list ${JSON.stringify(inRaw)} — skipped`)
470
+ return null
471
+ }
472
+ console.warn("[phlex-reactive] a reactive_show binding declares no predicate — skipped")
473
+ return null
474
+ }
475
+
424
476
  // The first focusable descendant of `el`, in document order — the natural
425
477
  // keyboard target inside an opened menu/dialog. Covers the standard focusable
426
478
  // set; :not([tabindex="-1"]) drops explicitly-removed nodes. Returns null when
@@ -470,12 +522,16 @@ function applyOps(list, resolveTargets) {
470
522
  // selector with no root (no `target` attr on the stream) resolves document-wide
471
523
  // — a broadcast op anchored by a global selector (#bell) rather than a
472
524
  // component. Unlike the controller path there is no nested-reactive-root
473
- // ownership filter: a server-pushed op names its own scope explicitly.
525
+ // ownership filter: a server-pushed op names its own scope explicitly
526
+ // including `global: true`, which opts a single op out of the target-root
527
+ // scope to document-wide resolution (issue #159; the same escape the builder
528
+ // documents for the controller path).
474
529
  function streamOpTargets(args, root) {
475
530
  const to = args.to
476
531
  if (root) {
477
532
  if (to === "@root") return [root]
478
533
  if (typeof to !== "string" || to === "") return []
534
+ if (args.global) return [...document.querySelectorAll(to)]
479
535
  return [...root.querySelectorAll(to)]
480
536
  }
481
537
  // No target root: document-scoped. "@root" is meaningless here (nothing to
@@ -509,6 +565,9 @@ export default class extends Controller {
509
565
  #boundScanDirty
510
566
  #boundBeforeUnload
511
567
  #boundBeforeVisit
568
+ // Show bindings (issue #161): the ONE delegated sync handler shared by the
569
+ // root's input/change/turbo:morph-element listeners, held for teardown.
570
+ #boundSyncShow
512
571
 
513
572
  // Mark that a reactive controller actually connected, so the registration
514
573
  // guard above knows the controller was registered (issue #26 part 2).
@@ -555,6 +614,23 @@ export default class extends Controller {
555
614
  this.#armUnsavedGuard()
556
615
  }
557
616
  }
617
+
618
+ // Show bindings (issue #161) — ONLY when this root owns one, so a component
619
+ // without any pays a single probe (the dirty-tracking gate precedent). ONE
620
+ // delegated listener pair on the root (input + change bubble from every
621
+ // owned field — no per-field wiring, and a reactive_compute output write
622
+ // dispatches a real input event, so computed values drive visibility too).
623
+ // The connect sync seeds the initial state — a plain replace re-connects —
624
+ // and turbo:morph-element re-syncs after an in-place morph (which keeps the
625
+ // element connected, fires no Stimulus lifecycle, and may preserve a
626
+ // user-edited field value the server's hidden attrs don't reflect).
627
+ if (this.#showSyncEnabled()) {
628
+ this.#boundSyncShow = () => this.#syncShow()
629
+ this.element.addEventListener?.("input", this.#boundSyncShow)
630
+ this.element.addEventListener?.("change", this.#boundSyncShow)
631
+ this.element.addEventListener?.("turbo:morph-element", this.#boundSyncShow)
632
+ this.#syncShow()
633
+ }
558
634
  }
559
635
 
560
636
  // Whether this root opts into dirty tracking (issue #103): track_dirty: puts the
@@ -579,6 +655,7 @@ export default class extends Controller {
579
655
  this.#clearAllDebounces()
580
656
  this.#clearAllThrottles()
581
657
  this.#teardownDirtyTracking()
658
+ this.#teardownShowSync()
582
659
  }
583
660
 
584
661
  // Serialize requests per component. Each round trip rewrites the signed
@@ -759,9 +836,14 @@ export default class extends Controller {
759
836
  for (const name of inputs) this.#mirrorText(name, ownedField(name)?.value ?? "")
760
837
 
761
838
  const key = this.element.getAttribute("data-reactive-compute-reducer-param")
762
- if (!key) return
763
- const reduce = computeReducer(key)
764
- if (!reduce) return
839
+ const reduce = key ? computeReducer(key) : null
840
+ if (!reduce) {
841
+ // No reducer registered: the identity pass above still ran, so declared
842
+ // cross-root mirrors of the INPUT names still paint (issue #159) — a
843
+ // reducer-less binding mirrors, exactly like the owned-text-node case.
844
+ this.#applyComputeMirrors({}, ownedField)
845
+ return
846
+ }
765
847
 
766
848
  const outputs = this.#parseComputeList("data-reactive-compute-outputs-param")
767
849
 
@@ -808,6 +890,10 @@ export default class extends Controller {
808
890
  this.#mirrorText(name, result[name])
809
891
  }
810
892
  }
893
+
894
+ // Cross-root text mirrors (issue #159) — AFTER the outputs are applied, so
895
+ // a mirror keyed on a just-written output paints the settled value.
896
+ this.#applyComputeMirrors(result, ownedField)
811
897
  }
812
898
 
813
899
  // Client-side list navigation (combobox keyboard nav, issue #72). Wired by
@@ -940,6 +1026,47 @@ export default class extends Controller {
940
1026
  return Array.from(nodes).filter((el) => this.#ownsField(el))
941
1027
  }
942
1028
 
1029
+ // Cross-root text mirrors (issue #159): paint every DECLARED mirror name into
1030
+ // its allowlisted document-wide id targets via textContent — the opt-in escape
1031
+ // from root isolation (issue #15) for a recap OUTSIDE the computing root. The
1032
+ // value is the reducer's result when it produced one, else the owned field's
1033
+ // CURRENT value (an input identity mirror / a just-written output) — one
1034
+ // declaration covers all three shapes. A name with NO value this pass is
1035
+ // SKIPPED (a mirror never blanks a recap the reducer didn't feed). textContent
1036
+ // only (never innerHTML), change-guarded, and NO input dispatch — same
1037
+ // contract as #mirrorText. With no mirror declared this is one getAttribute
1038
+ // and out — the shipped compute path never touches the document.
1039
+ #applyComputeMirrors(result, ownedField) {
1040
+ const mirror = this.#parseComputeMirror()
1041
+ for (const [name, selectors] of Object.entries(mirror)) {
1042
+ const value = name in result ? result[name] : ownedField(name)?.value
1043
+ if (value === undefined || value === null) continue
1044
+ const text = String(value)
1045
+ for (const sel of Array.isArray(selectors) ? selectors : [selectors]) {
1046
+ if (!guardMirrorSelector(sel)) continue
1047
+ for (const node of document.querySelectorAll(sel)) {
1048
+ if (node.textContent === text) continue
1049
+ node.textContent = text
1050
+ }
1051
+ }
1052
+ }
1053
+ }
1054
+
1055
+ // The declared cross-root mirror map (issue #159): a JSON object of
1056
+ // { name: [id selectors] } from data-reactive-compute-mirror-param (emitted by
1057
+ // reactive_compute's `mirror:`). Absent/malformed degrades to {} — a bad
1058
+ // binding must never throw on input.
1059
+ #parseComputeMirror() {
1060
+ const raw = this.element.getAttribute("data-reactive-compute-mirror-param")
1061
+ if (!raw) return {}
1062
+ try {
1063
+ const parsed = JSON.parse(raw)
1064
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {}
1065
+ } catch {
1066
+ return {}
1067
+ }
1068
+ }
1069
+
943
1070
  // Enqueue the action — debounced if a debounce window is set, else immediately.
944
1071
  // Split out of dispatch so both the no-confirm fast path and the post-confirm
945
1072
  // microtask share one place (issue #55). `target` is captured up front because
@@ -1697,6 +1824,76 @@ export default class extends Controller {
1697
1824
  this.#boundBeforeVisit = undefined
1698
1825
  }
1699
1826
 
1827
+ // Whether this root owns a show binding (issue #161) — the connect() gate, so
1828
+ // a component without one pays only this probe (the #dirtyTrackingEnabled
1829
+ // precedent). A NESTED root's bindings don't count: its own controller
1830
+ // instance syncs them (issue #15 ownership).
1831
+ #showSyncEnabled() {
1832
+ const nodes = this.element.querySelectorAll?.("[data-reactive-show-field]") ?? []
1833
+ for (const el of nodes) if (this.#ownsField(el)) return true
1834
+ return false
1835
+ }
1836
+
1837
+ // Re-evaluate every OWNED show binding in one pass (issue #161): read the
1838
+ // controlling field's current value, evaluate the declared literal predicate,
1839
+ // toggle `hidden`. A full pass (not per-target) for the same reason as
1840
+ // #scanDirty — a radio group's deselected radio fires no event — and because
1841
+ // several bindings can hang off one field (the value read is memoized per
1842
+ // pass). A binding whose field can't be resolved, or whose predicate is
1843
+ // malformed, leaves visibility ALONE — a bad binding must never break or
1844
+ // blank the page (client-side default-deny).
1845
+ #syncShow() {
1846
+ if (typeof this.element?.querySelectorAll !== "function") return
1847
+
1848
+ const owns = this.#ownershipFilter()
1849
+ const values = new Map()
1850
+ for (const el of this.element.querySelectorAll("[data-reactive-show-field]")) {
1851
+ if (!owns(el)) continue // a nested root's binding is its own controller's job
1852
+ const name = el.getAttribute("data-reactive-show-field")
1853
+ if (!name) continue
1854
+ if (!values.has(name)) values.set(name, this.#showFieldValue(name, owns))
1855
+ const value = values.get(name)
1856
+ if (value === null) continue // no owned field with that name — leave it be
1857
+ const match = showBindingMatches(el, value)
1858
+ if (match === null) continue // malformed predicate — warned + skipped
1859
+ el.hidden = !match
1860
+ }
1861
+ }
1862
+
1863
+ // The current value of the OWNED field controlling a show binding, as the
1864
+ // string the literal predicate compares against. Mirrors #collectFields'
1865
+ // per-kind reads: a checkbox reports its checked state ("true"/"false" — its
1866
+ // .value is the constant "on", and the checkbox wins over the hidden input
1867
+ // Rails pairs with it); a radio group reports the CHECKED radio's value (""
1868
+ // when none is); anything else reports .value first-wins. Returns null when
1869
+ // no owned field carries the name — the caller then leaves visibility alone.
1870
+ #showFieldValue(name, owns) {
1871
+ let sawRadio = false
1872
+ let first = null
1873
+ for (const el of this.element.querySelectorAll(`[name="${name}"]`)) {
1874
+ if (!owns(el)) continue
1875
+ if (el.type === "checkbox") return el.checked ? "true" : "false"
1876
+ if (el.type === "radio") {
1877
+ if (el.checked) return el.value ?? ""
1878
+ sawRadio = true
1879
+ continue
1880
+ }
1881
+ first ??= el
1882
+ }
1883
+ if (first) return first.value ?? ""
1884
+ return sawRadio ? "" : null
1885
+ }
1886
+
1887
+ // Remove the show-sync listeners on disconnect, so a stray event after a
1888
+ // Turbo morph/navigation never re-evaluates against a detached root.
1889
+ #teardownShowSync() {
1890
+ if (!this.#boundSyncShow) return
1891
+ this.element.removeEventListener?.("input", this.#boundSyncShow)
1892
+ this.element.removeEventListener?.("change", this.#boundSyncShow)
1893
+ this.element.removeEventListener?.("turbo:morph-element", this.#boundSyncShow)
1894
+ this.#boundSyncShow = undefined
1895
+ }
1896
+
1700
1897
  // Build the multipart body (issue #34). `token`/`act` are flat fields the
1701
1898
  // endpoint reads from params[:token]/params[:act]; scalar params nest under
1702
1899
  // params[<key>] (Rails parses the bracket into params[:params]); each file is
@@ -1,4 +1,4 @@
1
- import{Controller as B}from"@hotwired/stimulus";import{confirmResolver as T}from"phlex/reactive/confirm";import{computeReducer as w}from"phlex/reactive/compute";function k(){let j=window.Turbo?.StreamActions;if(!j||j["reactive:visit"])return;j["reactive:visit"]=function(){let z=this.getAttribute("data-url");if(z)window.Turbo.visit(z,{action:"advance"})}}function b(){let j=window.Turbo?.StreamActions;if(!j||j["reactive:token"])return;j["reactive:token"]=function(){let z=this.getAttribute("data-reactive-token-value"),Q=this.getAttribute("target");if(!z||!Q)return;let W=document.getElementById(Q);if(W)W.setAttribute("data-reactive-token-value",z)}}function y(){let j=window.Turbo?.StreamActions;if(!j||j["reactive:js"])return;j["reactive:js"]=function(){let z=E(this.getAttribute("data-reactive-ops"));if(!z.length)return;let Q=this.getAttribute("target"),W=Q?document.getElementById(Q):null;if(Q&&!W)return;R(z,(X)=>r(X,W))}}var O=!1;function h(){if(O)return;if(typeof document>"u"||!document.addEventListener)return;O=!0,document.addEventListener("turbo:before-stream-render",v)}function v(j){let z=j.detail,Q=z?.render;if(typeof Q!=="function"||Q.__reactiveDismissWrapped){if(typeof requestAnimationFrame==="function")requestAnimationFrame(N);else setTimeout(N,0);return}let W=async(X)=>{await Q(X),N()};W.__reactiveDismissWrapped=!0,z.render=W}function N(){let j=document.querySelectorAll("[data-reactive-dismiss-after]");for(let z of j){if(z.hasAttribute("data-reactive-dismiss-scheduled"))continue;let Q=Number(z.getAttribute("data-reactive-dismiss-after"));if(!Number.isFinite(Q)||Q<=0)continue;z.setAttribute("data-reactive-dismiss-scheduled",""),setTimeout(()=>z.remove(),Q)}}function e(){O=!1}var D=!1;function f(){if(D)return;if(typeof window>"u"||typeof document>"u")return;if(typeof window.addEventListener!=="function")return;D=!0;let j=()=>{let z=document.documentElement;if(typeof z?.toggleAttribute!=="function")return;z.toggleAttribute("data-reactive-offline",globalThis.navigator?.onLine===!1)};j(),window.addEventListener("online",j),window.addEventListener("offline",j)}function jj(){D=!1}var F="phlex-reactive:latency",I=!1;function u(j){if(typeof sessionStorage>"u")return;sessionStorage.setItem(F,String(j))}function p(){if(typeof sessionStorage>"u")return;sessionStorage.removeItem(F),I=!1}function m(){if(typeof window>"u"||typeof document>"u")return;if(document.querySelector?.('meta[name="phlex-reactive-env"]')?.content!=="development")return;window.PhlexReactive={enableLatencySim:u,disableLatencySim:p}}function zj(){I=!1}function S(){k(),b(),y(),h(),f(),m()}function c(j){return j.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")}if(typeof window<"u")if(window.Turbo)S();else document.addEventListener("turbo:load",S,{once:!0});var L=!1;function g(){if(L)return;if(typeof document>"u")return;let j=document.querySelectorAll('[data-controller~="reactive"]');if(!j||j.length===0)return;console.warn("[phlex-reactive] found "+j.length+' element(s) with data-controller="reactive" '+"but the reactive controller never connected. It is loaded but not registered — "+'add `application.register("reactive", ReactiveController)` (importmap) or import it into app/javascript/controllers/ for lazyLoadControllersFrom apps. See the README.')}function Qj(){L=!1}function Wj(){L=!0}if(typeof window<"u"&&typeof document<"u"){let j=()=>setTimeout(g,0);if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",j,{once:!0});else j()}var d=new Set(["href","src","srcdoc","action","formaction","xlink:href","style"]);function l(j){let z=String(j).toLowerCase();return z.startsWith("on")||d.has(z)}function s(j,z,Q){let[W,X,Z]=z;j.classList.add(W,X),Q(),requestAnimationFrame(()=>{j.classList.remove(X),j.classList.add(Z)});let $=!1,G=()=>{if($)return;$=!0,j.classList.remove(W,Z)};j.addEventListener("animationend",G,{once:!0}),setTimeout(G,350)}var C=Object.freeze({show:(j,z)=>A(j,!1,z),hide:(j,z)=>A(j,!0,z),toggle:(j,z)=>A(j,!j.hidden,z),add_class:(j,z)=>j.classList.add(...z.classes??[]),remove_class:(j,z)=>j.classList.remove(...z.classes??[]),toggle_class:(j,z)=>(z.classes??[]).forEach((Q)=>j.classList.toggle(Q)),set_attr:(j,z)=>{if(x(z.name))j.setAttribute(z.name,z.value??"")},remove_attr:(j,z)=>{if(x(z.name))j.removeAttribute(z.name)},toggle_attr:(j,z)=>{if(!x(z.name))return;if(j.hasAttribute(z.name))j.removeAttribute(z.name);else j.setAttribute(z.name,"")},focus:(j)=>j.focus?.(),focus_first:(j)=>n(j)?.focus?.(),dispatch:(j,z)=>{j.dispatchEvent(new CustomEvent(z.name,{bubbles:!0,composed:!0,detail:z.detail??{}}))}});function A(j,z,Q){if(Q?.transition)s(j,Q.transition,()=>j.hidden=z);else j.hidden=z}function x(j){if(!l(j))return!0;return console.warn(`[phlex-reactive] refused client attr op on ${JSON.stringify(j)} — skipped`),!1}var o='a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';function n(j){return j.querySelectorAll?.(o)?.[0]??null}function E(j){if(Array.isArray(j))return j;if(typeof j!=="string")return[];try{let z=JSON.parse(j);return Array.isArray(z)?z:[]}catch{return[]}}function R(j,z){for(let Q of j){if(!Array.isArray(Q))continue;let[W,X={}]=Q;if(!Object.hasOwn(C,W)){console.warn(`[phlex-reactive] unknown client op ${JSON.stringify(W)} — skipped`);continue}for(let Z of z(X))C[W](Z,X)}}function r(j,z){let Q=j.to;if(z){if(Q==="@root")return[z];if(typeof Q!=="string"||Q==="")return[];return[...z.querySelectorAll(Q)]}if(typeof Q!=="string"||Q===""||Q==="@root")return[];return[...document.querySelectorAll(Q)]}class Xj extends B{static values={token:String};#F;#M=new Map;#$=new Map;#y;#H;#P;#U=0;#G=new Map;#I=new WeakMap;#Y=new Map;#J;#q;#K;connect(){if(L=!0,this.element.id==="")console.warn("[phlex-reactive] a reactive root has no id; its next-action token can't self-match "+"and may fall back to the first token in the response → a silent HTTP 403 on the NEXT action. "+"Put id: on the SAME element as reactive_attrs — use div(**reactive_root) (emits id + token together), "+"or div(id:, **reactive_attrs). The id: must NOT be on a child. See the README.");if(this.#h()){if(this.#J=()=>this.#x(),this.element.addEventListener?.("turbo:morph-element",this.#J),this.#x(),this.element.getAttribute?.("data-reactive-warn-unsaved")==="true")this.#zj()}}#h(){if((this.element.getAttribute?.("data-action")??"").includes("reactive#trackDirty"))return!0;let j=this.element.querySelectorAll?.('[data-action*="reactive#trackDirty"]')??[];for(let z of j)if(this.#j(z))return!0;return!1}disconnect(){this.#c(),this.#d(),this.#Qj()}dispatch(j){let{action:z,params:Q,debounce:W,throttle:X,confirm:Z,outside:$,window:G,optimistic:K,loading:M}=j.params;if(!z)return;if($&&this.element.contains(j.target))return;let V=j.currentTarget??j.target;if(!G&&!this.#Gj(K,V))j.preventDefault();if(!Z)return this.#E(V,z,Q,W,X,K,M);Promise.resolve().then(()=>T(Z)).catch(()=>!1).then((J)=>{if(J)this.#E(V,z,Q,W,X,K,M)})}runOps(j){let{ops:z,outside:Q,window:W}=j.params;if(Q&&this.element.contains(j.target))return;if(!W)j.preventDefault();this.#$j(this.#Zj(z))}trackDirty(){this.#x()}recompute(j){let z=this.#f(),Q=z.map(([J])=>J),W=this.#A(),X=new Map,Z=(J)=>{if(X.has(J))return X.get(J);let q=null;for(let _ of this.element.querySelectorAll(`[name="${J}"]`))if(W(_)){q=_;break}return X.set(J,q),q};for(let J of Q)this.#C(J,Z(J)?.value??"");let $=this.element.getAttribute("data-reactive-compute-reducer-param");if(!$)return;let G=w($);if(!G)return;let K=this.#v("data-reactive-compute-outputs-param"),M={};for(let[J,q]of z){let _=Z(J);if(q==="string")M[J]=_?.value??"";else{let H=Number(_?.value);M[J]=Number.isFinite(H)?H:0}}let V=G(M,{changed:this.#u(j,Q)})||{};for(let J of K){if(!(J in V))continue;let q=Z(J);if(q){if(String(V[J])===q.value)continue;q.value=V[J],q.dispatchEvent(new Event("input",{bubbles:!0}))}else this.#C(J,V[J])}}listnavNext(j){this.#S(j,1)}listnavPrev(j){this.#S(j,-1)}listnavPick(j){let z=this.#L(j),Q=z.findIndex((W)=>W.hasAttribute("data-reactive-highlighted"));if(Q<0)return;j.preventDefault(),z[Q].click()}listnavClose(j){for(let z of this.#L(j))z.removeAttribute("data-reactive-highlighted")}#S(j,z){let Q=this.#L(j);if(!Q.length)return;j.preventDefault();let W=Q.findIndex(($)=>$.hasAttribute("data-reactive-highlighted")),X=W<0?z>0?0:Q.length-1:(W+z+Q.length)%Q.length;for(let $ of Q)$.removeAttribute("data-reactive-highlighted");let Z=Q[X];Z.setAttribute("data-reactive-highlighted","true"),Z.scrollIntoView?.({block:"nearest"})}#L(j){let Q=(j?.currentTarget??j?.target??this.element).getAttribute?.("data-reactive-listnav-option-param")??this.element.getAttribute("data-reactive-listnav-option-param");if(!Q)return[];let W=this.#A();return Array.from(this.element.querySelectorAll(Q)).filter(W)}#v(j){let z=this.element.getAttribute(j);if(!z)return[];try{let Q=JSON.parse(z);return Array.isArray(Q)?Q:[]}catch{return[]}}#f(){let j=this.element.getAttribute("data-reactive-compute-inputs-param");if(!j)return[];try{let z=JSON.parse(j);if(Array.isArray(z))return z.map((Q)=>[Q,"number"]);if(z&&typeof z==="object")return Object.entries(z);return[]}catch{return[]}}#u(j,z){let Q=j?.target;if(!Q?.name||typeof Q.closest!=="function")return null;if(!z.includes(Q.name))return null;return this.#j(Q)?Q.name:null}#C(j,z){let Q=String(z);for(let W of this.#p(j)){if(W.textContent===Q)continue;W.textContent=Q}}#p(j){let z=this.element.querySelectorAll(`[data-reactive-text="${j}"]`);return Array.from(z).filter((Q)=>this.#j(Q))}#E(j,z,Q,W,X,Z,$){if(this.#_("reactive:before-dispatch",{action:z,params:this.#k(Q),element:this.element},{cancelable:!0}).defaultPrevented)return;let K=Number(W)||0;if(K>0)return this.#m(j,K,z,Q,Z,$);let M=Number(X)||0;if(M>0)return this.#g(j,M,z,Q,Z,$);return this.#V(z,Q,Z,j,$)}#V(j,z,Q,W,X){let Z=this.#Jj(Q,W),$=this.#Kj(j,W,X);return this.queue=(this.queue??Promise.resolve()).then(()=>this.#i(j,z,Z,$)),this.queue}#m(j,z,Q,W,X,Z){this.#N(j);let $=()=>{this.#N(j),this.#V(Q,W,X,j,Z)},G=setTimeout($,z);j?.addEventListener?.("blur",$,{once:!0}),this.#M.set(j,{timer:G,flush:$})}#N(j){let z=this.#M.get(j);if(!z)return;clearTimeout(z.timer),j?.removeEventListener?.("blur",z.flush),this.#M.delete(j)}#c(){for(let j of[...this.#M.keys()])this.#N(j)}#g(j,z,Q,W,X,Z){let $=this.#$.get(j)??new Map;if($.has(Q))return;let G=setTimeout(()=>{if($.delete(Q),$.size===0)this.#$.delete(j)},z);return $.set(Q,G),this.#$.set(j,$),this.#V(Q,W,X,j,Z)}#d(){for(let j of this.#$.values())for(let z of j.values())clearTimeout(z);this.#$.clear()}#_(j,z,{cancelable:Q=!1}={}){let W=new CustomEvent(j,{bubbles:!0,composed:!0,cancelable:Q,detail:z});return(this.element.isConnected?this.element:document).dispatchEvent(W),W}#z(j,z,Q,W){let X=()=>{if(!this.element.isConnected){console.warn("[phlex-reactive] retry() ignored — the reactive root left the DOM");return}return this.#V(j,z)};this.#_("reactive:error",{action:j,params:Q,...W,retry:X})}#Q(j){if(this.element?.isConnected===!1)return;this.element?.setAttribute?.("data-reactive-error",j)}#l(){this.element?.removeAttribute?.("data-reactive-error")}#s(){let j=document.querySelector("[data-reactive-error-flash]");if(!j?.content)return;let z=j.getAttribute("data-reactive-error-flash")||"flash",Q=document.getElementById(z);if(!Q)return;Q.appendChild(j.content.cloneNode(!0))}#o(){if(typeof sessionStorage>"u")return Promise.resolve();let j=Number(sessionStorage.getItem(F));if(!Number.isFinite(j)||j<=0)return Promise.resolve();if(!I)I=!0,console.warn(`[phlex-reactive] latency simulator ACTIVE — every action is delayed by ${j}ms. Call PhlexReactive.disableLatencySim() (or clear sessionStorage) to turn it off.`);return new Promise((z)=>setTimeout(z,j))}#n(){return this.element?.getAttribute?.("data-reactive-debug")==="true"}#R(){return typeof performance<"u"&&typeof performance.now==="function"?performance.now():Date.now()}#r(j){if(!j)return[];let z=[],Q=/<turbo-stream\b([^>]*)>/g,W;while((W=Q.exec(j))!==null){let X=W[1],Z=X.match(/\baction="([^"]*)"/)?.[1]??"?",$=X.match(/\btarget="([^"]*)"/)?.[1];z.push($?`${Z} → #${$}`:Z)}return z}#a(j){let{action:z,status:Q,ms:W}=j,Z=`reactive ${this.element?.id?`#${this.element.id} `:""}${z} → ${Q??"—"} (${Math.round(W)}ms)`;if(console.groupCollapsed(Z),console.log(`params: [${j.paramNames.join(", ")}] + collected: [${j.fieldNames.join(", ")}]`),console.log(`encoding: ${j.encoding}`),j.streams.length)console.log(`streams: ${j.streams.join(" ")}`);console.log(`token: ${j.tokenRefreshed?"refreshed ✓":"unchanged"}`),console.groupEnd()}async#i(j,z,Q,W){let{fields:X,files:Z}=this.#e(),$=this.#k(z),G={...X,...$},K=this.#W,M=Z.length>0,V=M?this.#Wj(K,j,G,Z):JSON.stringify({token:K,act:j,params:G}),J=this.#n()?{action:j,paramNames:Object.keys($),fieldNames:Object.keys(X),encoding:M?"multipart":"json",status:null,streams:[],tokenRefreshed:!1,started:this.#R()}:null;await this.#o();try{if(navigator.onLine===!1){this.#X(Q),this.#Q("offline"),this.#z(j,z,G,{kind:"offline"});return}let q;try{let Y={Accept:"text/vnd.turbo-stream.html","X-CSRF-Token":this.#Lj()};if(!M)Y["Content-Type"]="application/json";let U=this.#Nj();if(U)Y["X-Pgbus-Connection"]=U;q=await fetch(this.#Uj(),{method:"POST",headers:Y,body:V,credentials:"same-origin",signal:AbortSignal.timeout(this.#Ij())})}catch(Y){if(console.error("[phlex-reactive] action error",Y),this.#X(Q),Y?.name==="TimeoutError"||Y?.name==="AbortError"){this.#Q("timeout"),this.#z(j,z,G,{kind:"timeout"});return}this.#s(),this.#Q("network"),this.#z(j,z,G,{kind:"network"});return}if(J)J.status=q.status;if(q.redirected){console.error("[phlex-reactive] action was redirected (auth/CSRF?) — no update applied"),this.#X(Q),this.#Q("redirected"),this.#z(j,z,G,{kind:"redirected",status:q.status});return}if(!q.ok){let Y=await q.text();if(console.error(`[phlex-reactive] action failed: HTTP ${q.status}`,Y),this.#X(Q),(q.headers.get("Content-Type")||"").includes("turbo-stream")){let U=this.#T(Y);if(this.#W=U??this.#W,J)this.#B(J,Y,U);window.Turbo.renderStreamMessage(Y)}this.#Q("http"),this.#z(j,z,G,{kind:"http",status:q.status,body:Y});return}let _=q.headers.get("Content-Type")||"";if(!_.includes("turbo-stream")){console.error(`[phlex-reactive] expected a turbo-stream, got "${_}" — no update applied`),this.#X(Q),this.#Q("content-type"),this.#z(j,z,G,{kind:"content-type",status:q.status});return}let H=await q.text(),P=this.#T(H);if(this.#W=P??this.#W,J)this.#B(J,H,P);window.Turbo.renderStreamMessage(H),this.#l(),this.#_("reactive:applied",{action:j,params:G,html:H})}catch(q){console.error("[phlex-reactive] action error",q),this.#X(Q),this.#_("reactive:error",{action:j,params:G,kind:"apply"})}finally{if(W?.(),J)this.#a({...J,ms:this.#R()-J.started})}}#B(j,z,Q){j.streams=this.#r(z),j.tokenRefreshed=Q!=null}get#W(){return this.#F??this.tokenValue}set#W(j){this.#F=j}#T(j){let z=this.element.id;if(!z)return j.match(/data-reactive-token-value="([^"]+)"/)?.[1];let{token:Q,self:W}=this.#t(z),X=j.match(Q);if(X)return X[1];let Z=j.match(W);if(Z)return Z[1].match(/data-reactive-token-value="([^"]+)"/)?.[1];return}#t(j){let z=this.#P;if(z&&z.id===j)return z;let Q=c(j);return this.#P={id:j,token:new RegExp(`<turbo-stream\\b[^>]*\\baction="reactive:token"[^>]*\\btarget="${Q}"[^>]*\\bdata-reactive-token-value="([^"]+)"`),self:new RegExp(`<turbo-stream\\b[^>]*\\baction="(?:replace|update)"[^>]*\\btarget="${Q}"[^>]*>([\\s\\S]*?)</turbo-stream>`)}}#j(j){return j.closest('[data-controller~="reactive"]')===this.element}#A(){if(this.element.querySelectorAll('[data-controller~="reactive"]').length===0)return()=>!0;return(z)=>this.#j(z)}#e(){let j={},z=[],Q=this.#A();return this.element.querySelectorAll("input[name], select[name], textarea[name]").forEach((W)=>{if(!Q(W))return;if(W.type==="file")for(let X of W.files??[])z.push({name:W.name,file:X,multiple:W.multiple});else if(W.type==="checkbox")j[W.name]=W.checked;else if(W.type==="radio"){if(W.checked)j[W.name]=W.value}else j[W.name]=W.value}),this.element.querySelectorAll("[name]:is(lexxy-editor, trix-editor, [contenteditable=''], [contenteditable=true], [contenteditable=plaintext-only])").forEach((W)=>{if(!Q(W))return;let X=W.getAttribute("name");if(!X)return;let Z=j[X];if(Z==null||Z==="")j[X]=W.value??W.textContent??W.innerHTML??""}),{fields:j,files:z}}#x(){if(typeof this.element?.querySelectorAll!=="function")return;let j=0;if(this.element.querySelectorAll("input[name], select[name], textarea[name]").forEach((z)=>{if(!this.#j(z))return;if(z.type==="file")return;if(this.#jj(z))z.setAttribute("data-reactive-dirty","true"),j++;else z.removeAttribute("data-reactive-dirty")}),j>0)this.element.setAttribute("data-reactive-dirty",String(j));else this.element.removeAttribute("data-reactive-dirty")}#jj(j){if(j.type==="checkbox"||j.type==="radio")return j.checked!==j.defaultChecked;if(j.tag==="select"||j.options)return Array.from(j.options??[]).some((z)=>z.selected!==z.defaultSelected);return j.value!==j.defaultValue}#w(){let j=this.element.getAttribute?.("data-reactive-dirty"),z=Number(j);return Number.isFinite(z)&&z>0?z:0}#zj(){if(typeof window>"u"||typeof window.addEventListener!=="function")return;this.#q=(j)=>{if(this.#w()===0)return;return j.preventDefault(),j.returnValue="You have unsaved changes.",j.returnValue},this.#K=(j)=>{if(this.#w()===0)return;if(!(typeof window.confirm==="function"?window.confirm("You have unsaved changes. Leave anyway?"):!0))j.preventDefault?.()},window.addEventListener("beforeunload",this.#q),window.addEventListener("turbo:before-visit",this.#K)}#Qj(){if(this.#J)this.element.removeEventListener?.("turbo:morph-element",this.#J),this.#J=void 0;if(typeof window<"u"&&typeof window.removeEventListener==="function"){if(this.#q)window.removeEventListener("beforeunload",this.#q);if(this.#K)window.removeEventListener("turbo:before-visit",this.#K)}this.#q=void 0,this.#K=void 0}#Wj(j,z,Q,W){let X=new FormData;X.append("token",j),X.append("act",z);for(let[$,G]of Object.entries(Q))this.#O(X,`params[${$}]`,G);let Z=this.#Xj(W);for(let{name:$,file:G,multiple:K}of W){let V=K||Z.has($)?`params[${$}][]`:`params[${$}]`;X.append(V,G,G.name)}return X}#O(j,z,Q){if(Q==null)j.append(z,"");else if(Array.isArray(Q))Q.forEach((W,X)=>this.#O(j,`${z}[${X}]`,W));else if(typeof Q==="object")for(let[W,X]of Object.entries(Q))this.#O(j,`${z}[${W}]`,X);else j.append(z,String(Q))}#Xj(j){let z=new Map;for(let{name:Q}of j)z.set(Q,(z.get(Q)??0)+1);return new Set([...z].filter(([,Q])=>Q>1).map(([Q])=>Q))}#k(j){if(!j)return{};try{return typeof j==="string"?JSON.parse(j):j}catch{return{}}}#Zj(j){return E(j)}#$j(j){R(j,(z)=>this.#D(z))}#D(j){let z=j.to;if(z==="@root")return[this.element];if(typeof z!=="string"||z==="")return[];if(j.global)return[...document.querySelectorAll(z)];return[...this.element.querySelectorAll(z)].filter((Q)=>this.#j(Q))}#Gj(j,z){if(j?.checked!=="keep")return!1;let Q=z?.type;return Q==="checkbox"||Q==="radio"}#Jj(j,z){if(!j)return null;let Q=this.#qj(j,z),W=[];for(let X of Q){if(j.add_class){let Z=j.add_class.filter(($)=>!X.classList.contains($));if(X.classList.add(...Z),Z.length)W.push(()=>X.classList.remove(...Z))}if(j.remove_class){let Z=j.remove_class.filter(($)=>X.classList.contains($));if(X.classList.remove(...Z),Z.length)W.push(()=>X.classList.add(...Z))}if(j.toggle_class)j.toggle_class.forEach((Z)=>X.classList.toggle(Z)),W.push(()=>j.toggle_class.forEach((Z)=>X.classList.toggle(Z)));if(j.hide)X.hidden=!0,W.push(()=>X.hidden=!1)}if(j.checked==="keep"&&z&&"checked"in z){let X=z.checked;W.push(()=>z.checked=!X)}return W.length?W:null}#X(j){if(!j)return;if(!this.element.isConnected)return;for(let z of j)z()}#qj(j,z){if(j.to==null)return z?[z]:[];return this.#D({to:j.to})}#Kj(j,z,Q){this.#Mj(j,z);let W=this.#Vj(j,z,Q),X=!1;return()=>{if(X)return;X=!0,this.#Yj(j,z),W()}}#Mj(j,z){if(this.#Z(z,j,1),this.#Z(this.element,j,1),this.#G.set(j,(this.#G.get(j)??0)+1),this.#U++===0)this.element.setAttribute("aria-busy","true");for(let Q of this.#b(j))this.#Z(Q,j,1)}#Yj(j,z){this.#Z(z,j,-1),this.#Z(this.element,j,-1);let Q=(this.#G.get(j)??1)-1;if(Q<=0)this.#G.delete(j);else this.#G.set(j,Q);if(--this.#U<=0)this.#U=0,this.element.removeAttribute("aria-busy");for(let W of this.#b(j))this.#Z(W,j,-1)}#Z(j,z,Q){if(!j||typeof j.getAttribute!=="function")return;let W=this.#I.get(j)??new Map,X=(W.get(z)??0)+Q;if(X<=0)W.delete(z);else W.set(z,X);if(W.size===0){this.#I.delete(j),j.removeAttribute("data-reactive-busy");return}this.#I.set(j,W),j.setAttribute("data-reactive-busy",[...W.keys()].join(" "))}#b(j){return[...this.element.querySelectorAll?.("[data-reactive-busy-on]")??[]].filter((Q)=>Q.getAttribute("data-reactive-busy-on")===j&&this.#j(Q))}#Vj(j,z,Q){if(!Q||!z)return()=>{};let W=this.#Hj(Q,z),X=Array.isArray(Q.class)?Q.class:[],Z=[];for(let G of W){let K=X.filter((M)=>!G.classList.contains(M));if(G.classList.add(...K),K.length)Z.push([G,K])}let $=this.#Y.get(z);if($)$.count++;else if(Q.disable||Q.text!=null)this.#Y.set(z,{count:1,disabled:z.disabled,text:z.textContent,hadText:Q.text!=null});if(Q.disable)z.disabled=!0;if(Q.text!=null)z.textContent=Q.text;return()=>{for(let[G,K]of Z)if(G.isConnected)G.classList.remove(...K);this.#_j(z,Q)}}#_j(j,z){let Q=this.#Y.get(j);if(!Q)return;if(--Q.count>0)return;if(this.#Y.delete(j),!j.isConnected)return;if(z.disable)j.disabled=Q.disabled;if(Q.hadText&&j.textContent===z.text)j.textContent=Q.text}#Hj(j,z){if(j.to==null)return z?[z]:[];return this.#D({to:j.to})}#Uj(){return this.#y??=document.querySelector('meta[name="phlex-reactive-action-path"]')?.content||"/reactive/actions"}#Ij(){if(this.#H!=null)return this.#H;let j=document.querySelector('meta[name="phlex-reactive-timeout"]')?.content,z=Number(j);return this.#H=Number.isFinite(z)&&z>0?z:30000}#Lj(){return document.querySelector('meta[name="csrf-token"]')?.content??""}#Nj(){return document.querySelector("pgbus-stream-source[connection-id]")?.getAttribute("connection-id")||document.querySelector('meta[name="pgbus-connection-id"]')?.content||null}}export{k as registerReactiveVisit,b as registerReactiveToken,f as registerReactiveOffline,y as registerReactiveJs,h as registerReactiveDismiss,S as registerReactiveActions,c as escapeRegExp,u as enableLatencySim,p as disableLatencySim,Xj as default,g as checkReactiveRegistration,Qj as __resetReactiveRegistrationForTest,jj as __resetReactiveOfflineForTest,zj as __resetReactiveLatencyForTest,e as __resetReactiveDismissForTest,Wj as __markReactiveConnectedForTest,F as LATENCY_KEY};
1
+ import{Controller as S}from"@hotwired/stimulus";import{confirmResolver as T}from"phlex/reactive/confirm";import{computeReducer as k}from"phlex/reactive/compute";function b(){let j=window.Turbo?.StreamActions;if(!j||j["reactive:visit"])return;j["reactive:visit"]=function(){let z=this.getAttribute("data-url");if(z)window.Turbo.visit(z,{action:"advance"})}}function w(){let j=window.Turbo?.StreamActions;if(!j||j["reactive:token"])return;j["reactive:token"]=function(){let z=this.getAttribute("data-reactive-token-value"),Q=this.getAttribute("target");if(!z||!Q)return;let W=document.getElementById(Q);if(W)W.setAttribute("data-reactive-token-value",z)}}function y(){let j=window.Turbo?.StreamActions;if(!j||j["reactive:js"])return;j["reactive:js"]=function(){let z=B(this.getAttribute("data-reactive-ops"));if(!z.length)return;let Q=this.getAttribute("target"),W=Q?document.getElementById(Q):null;if(Q&&!W)return;R(z,(X)=>t(X,W))}}var O=!1;function h(){if(O)return;if(typeof document>"u"||!document.addEventListener)return;O=!0,document.addEventListener("turbo:before-stream-render",v)}function v(j){let z=j.detail,Q=z?.render;if(typeof Q!=="function"||Q.__reactiveDismissWrapped){if(typeof requestAnimationFrame==="function")requestAnimationFrame(L);else setTimeout(L,0);return}let W=async(X)=>{await Q(X),L()};W.__reactiveDismissWrapped=!0,z.render=W}function L(){let j=document.querySelectorAll("[data-reactive-dismiss-after]");for(let z of j){if(z.hasAttribute("data-reactive-dismiss-scheduled"))continue;let Q=Number(z.getAttribute("data-reactive-dismiss-after"));if(!Number.isFinite(Q)||Q<=0)continue;z.setAttribute("data-reactive-dismiss-scheduled",""),setTimeout(()=>z.remove(),Q)}}function Qj(){O=!1}var x=!1;function f(){if(x)return;if(typeof window>"u"||typeof document>"u")return;if(typeof window.addEventListener!=="function")return;x=!0;let j=()=>{let z=document.documentElement;if(typeof z?.toggleAttribute!=="function")return;z.toggleAttribute("data-reactive-offline",globalThis.navigator?.onLine===!1)};j(),window.addEventListener("online",j),window.addEventListener("offline",j)}function Wj(){x=!1}var P="phlex-reactive:latency",M=!1;function u(j){if(typeof sessionStorage>"u")return;sessionStorage.setItem(P,String(j))}function p(){if(typeof sessionStorage>"u")return;sessionStorage.removeItem(P),M=!1}function m(){if(typeof window>"u"||typeof document>"u")return;if(document.querySelector?.('meta[name="phlex-reactive-env"]')?.content!=="development")return;window.PhlexReactive={enableLatencySim:u,disableLatencySim:p}}function Xj(){M=!1}function F(){b(),w(),y(),h(),f(),m()}function c(j){return j.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")}if(typeof window<"u")if(window.Turbo)F();else document.addEventListener("turbo:load",F,{once:!0});var N=!1;function g(){if(N)return;if(typeof document>"u")return;let j=document.querySelectorAll('[data-controller~="reactive"]');if(!j||j.length===0)return;console.warn("[phlex-reactive] found "+j.length+' element(s) with data-controller="reactive" '+"but the reactive controller never connected. It is loaded but not registered — "+'add `application.register("reactive", ReactiveController)` (importmap) or import it into app/javascript/controllers/ for lazyLoadControllersFrom apps. See the README.')}function Zj(){N=!1}function $j(){N=!0}if(typeof window<"u"&&typeof document<"u"){let j=()=>setTimeout(g,0);if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",j,{once:!0});else j()}var d=new Set(["href","src","srcdoc","action","formaction","xlink:href","style"]);function l(j){let z=String(j).toLowerCase();return z.startsWith("on")||d.has(z)}function s(j,z,Q){let[W,X,Z]=z;j.classList.add(W,X),Q(),requestAnimationFrame(()=>{j.classList.remove(X),j.classList.add(Z)});let $=!1,G=()=>{if($)return;$=!0,j.classList.remove(W,Z)};j.addEventListener("animationend",G,{once:!0}),setTimeout(G,350)}var C=Object.freeze({show:(j,z)=>A(j,!1,z),hide:(j,z)=>A(j,!0,z),toggle:(j,z)=>A(j,!j.hidden,z),add_class:(j,z)=>j.classList.add(...z.classes??[]),remove_class:(j,z)=>j.classList.remove(...z.classes??[]),toggle_class:(j,z)=>(z.classes??[]).forEach((Q)=>j.classList.toggle(Q)),set_attr:(j,z)=>{if(D(z.name))j.setAttribute(z.name,z.value??"")},remove_attr:(j,z)=>{if(D(z.name))j.removeAttribute(z.name)},toggle_attr:(j,z)=>{if(!D(z.name))return;if(j.hasAttribute(z.name))j.removeAttribute(z.name);else j.setAttribute(z.name,"")},focus:(j)=>j.focus?.(),focus_first:(j)=>i(j)?.focus?.(),text:(j,z)=>{let Q=String(z.value??"");if(j.textContent!==Q)j.textContent=Q},dispatch:(j,z)=>{j.dispatchEvent(new CustomEvent(z.name,{bubbles:!0,composed:!0,detail:z.detail??{}}))}});function A(j,z,Q){if(Q?.transition)s(j,Q.transition,()=>j.hidden=z);else j.hidden=z}function D(j){if(!l(j))return!0;return console.warn(`[phlex-reactive] refused client attr op on ${JSON.stringify(j)} — skipped`),!1}var o=/^#[A-Za-z_][\w-]*$/;function n(j){if(typeof j==="string"&&o.test(j))return!0;return console.warn(`[phlex-reactive] refused cross-root mirror target ${JSON.stringify(j)} — skipped`),!1}function r(j,z){let Q=j.getAttribute("data-reactive-show-equals");if(Q!==null)return z===Q;let W=j.getAttribute("data-reactive-show-not");if(W!==null)return z!==W;let X=j.getAttribute("data-reactive-show-in");if(X!==null){try{let Z=JSON.parse(X);if(Array.isArray(Z))return Z.includes(z)}catch{}return console.warn(`[phlex-reactive] malformed reactive_show in: list ${JSON.stringify(X)} — skipped`),null}return console.warn("[phlex-reactive] a reactive_show binding declares no predicate — skipped"),null}var a='a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';function i(j){return j.querySelectorAll?.(a)?.[0]??null}function B(j){if(Array.isArray(j))return j;if(typeof j!=="string")return[];try{let z=JSON.parse(j);return Array.isArray(z)?z:[]}catch{return[]}}function R(j,z){for(let Q of j){if(!Array.isArray(Q))continue;let[W,X={}]=Q;if(!Object.hasOwn(C,W)){console.warn(`[phlex-reactive] unknown client op ${JSON.stringify(W)} — skipped`);continue}for(let Z of z(X))C[W](Z,X)}}function t(j,z){let Q=j.to;if(z){if(Q==="@root")return[z];if(typeof Q!=="string"||Q==="")return[];if(j.global)return[...document.querySelectorAll(Q)];return[...z.querySelectorAll(Q)]}if(typeof Q!=="string"||Q===""||Q==="@root")return[];return[...document.querySelectorAll(Q)]}class Gj extends S{static values={token:String};#E;#H=new Map;#G=new Map;#f;#M;#F;#N=0;#J=new Map;#L=new WeakMap;#V=new Map;#K;#q;#Y;#j;connect(){if(N=!0,this.element.id==="")console.warn("[phlex-reactive] a reactive root has no id; its next-action token can't self-match "+"and may fall back to the first token in the response → a silent HTTP 403 on the NEXT action. "+"Put id: on the SAME element as reactive_attrs — use div(**reactive_root) (emits id + token together), "+"or div(id:, **reactive_attrs). The id: must NOT be on a child. See the README.");if(this.#u()){if(this.#K=()=>this.#O(),this.element.addEventListener?.("turbo:morph-element",this.#K),this.#O(),this.element.getAttribute?.("data-reactive-warn-unsaved")==="true")this.#Zj()}if(this.#Gj())this.#j=()=>this.#y(),this.element.addEventListener?.("input",this.#j),this.element.addEventListener?.("change",this.#j),this.element.addEventListener?.("turbo:morph-element",this.#j),this.#y()}#u(){if((this.element.getAttribute?.("data-action")??"").includes("reactive#trackDirty"))return!0;let j=this.element.querySelectorAll?.('[data-action*="reactive#trackDirty"]')??[];for(let z of j)if(this.#z(z))return!0;return!1}disconnect(){this.#s(),this.#n(),this.#$j(),this.#Kj()}dispatch(j){let{action:z,params:Q,debounce:W,throttle:X,confirm:Z,outside:$,window:G,optimistic:q,loading:Y}=j.params;if(!z)return;if($&&this.element.contains(j.target))return;let H=j.currentTarget??j.target;if(!G&&!this.#Uj(q,H))j.preventDefault();if(!Z)return this.#S(H,z,Q,W,X,q,Y);Promise.resolve().then(()=>T(Z)).catch(()=>!1).then((J)=>{if(J)this.#S(H,z,Q,W,X,q,Y)})}runOps(j){let{ops:z,outside:Q,window:W}=j.params;if(Q&&this.element.contains(j.target))return;if(!W)j.preventDefault();this.#Vj(this.#Hj(z))}trackDirty(){this.#O()}recompute(j){let z=this.#m(),Q=z.map(([J])=>J),W=this.#I(),X=new Map,Z=(J)=>{if(X.has(J))return X.get(J);let K=null;for(let U of this.element.querySelectorAll(`[name="${J}"]`))if(W(U)){K=U;break}return X.set(J,K),K};for(let J of Q)this.#B(J,Z(J)?.value??"");let $=this.element.getAttribute("data-reactive-compute-reducer-param"),G=$?k($):null;if(!G){this.#R({},Z);return}let q=this.#p("data-reactive-compute-outputs-param"),Y={};for(let[J,K]of z){let U=Z(J);if(K==="string")Y[J]=U?.value??"";else{let _=Number(U?.value);Y[J]=Number.isFinite(_)?_:0}}let H=G(Y,{changed:this.#c(j,Q)})||{};for(let J of q){if(!(J in H))continue;let K=Z(J);if(K){if(String(H[J])===K.value)continue;K.value=H[J],K.dispatchEvent(new Event("input",{bubbles:!0}))}else this.#B(J,H[J])}this.#R(H,Z)}listnavNext(j){this.#C(j,1)}listnavPrev(j){this.#C(j,-1)}listnavPick(j){let z=this.#A(j),Q=z.findIndex((W)=>W.hasAttribute("data-reactive-highlighted"));if(Q<0)return;j.preventDefault(),z[Q].click()}listnavClose(j){for(let z of this.#A(j))z.removeAttribute("data-reactive-highlighted")}#C(j,z){let Q=this.#A(j);if(!Q.length)return;j.preventDefault();let W=Q.findIndex(($)=>$.hasAttribute("data-reactive-highlighted")),X=W<0?z>0?0:Q.length-1:(W+z+Q.length)%Q.length;for(let $ of Q)$.removeAttribute("data-reactive-highlighted");let Z=Q[X];Z.setAttribute("data-reactive-highlighted","true"),Z.scrollIntoView?.({block:"nearest"})}#A(j){let Q=(j?.currentTarget??j?.target??this.element).getAttribute?.("data-reactive-listnav-option-param")??this.element.getAttribute("data-reactive-listnav-option-param");if(!Q)return[];let W=this.#I();return Array.from(this.element.querySelectorAll(Q)).filter(W)}#p(j){let z=this.element.getAttribute(j);if(!z)return[];try{let Q=JSON.parse(z);return Array.isArray(Q)?Q:[]}catch{return[]}}#m(){let j=this.element.getAttribute("data-reactive-compute-inputs-param");if(!j)return[];try{let z=JSON.parse(j);if(Array.isArray(z))return z.map((Q)=>[Q,"number"]);if(z&&typeof z==="object")return Object.entries(z);return[]}catch{return[]}}#c(j,z){let Q=j?.target;if(!Q?.name||typeof Q.closest!=="function")return null;if(!z.includes(Q.name))return null;return this.#z(Q)?Q.name:null}#B(j,z){let Q=String(z);for(let W of this.#g(j)){if(W.textContent===Q)continue;W.textContent=Q}}#g(j){let z=this.element.querySelectorAll(`[data-reactive-text="${j}"]`);return Array.from(z).filter((Q)=>this.#z(Q))}#R(j,z){let Q=this.#d();for(let[W,X]of Object.entries(Q)){let Z=W in j?j[W]:z(W)?.value;if(Z===void 0||Z===null)continue;let $=String(Z);for(let G of Array.isArray(X)?X:[X]){if(!n(G))continue;for(let q of document.querySelectorAll(G)){if(q.textContent===$)continue;q.textContent=$}}}}#d(){let j=this.element.getAttribute("data-reactive-compute-mirror-param");if(!j)return{};try{let z=JSON.parse(j);return z&&typeof z==="object"&&!Array.isArray(z)?z:{}}catch{return{}}}#S(j,z,Q,W,X,Z,$){if(this.#_("reactive:before-dispatch",{action:z,params:this.#h(Q),element:this.element},{cancelable:!0}).defaultPrevented)return;let q=Number(W)||0;if(q>0)return this.#l(j,q,z,Q,Z,$);let Y=Number(X)||0;if(Y>0)return this.#o(j,Y,z,Q,Z,$);return this.#U(z,Q,Z,j,$)}#U(j,z,Q,W,X){let Z=this.#_j(Q,W),$=this.#Mj(j,W,X);return this.queue=(this.queue??Promise.resolve()).then(()=>this.#zj(j,z,Z,$)),this.queue}#l(j,z,Q,W,X,Z){this.#D(j);let $=()=>{this.#D(j),this.#U(Q,W,X,j,Z)},G=setTimeout($,z);j?.addEventListener?.("blur",$,{once:!0}),this.#H.set(j,{timer:G,flush:$})}#D(j){let z=this.#H.get(j);if(!z)return;clearTimeout(z.timer),j?.removeEventListener?.("blur",z.flush),this.#H.delete(j)}#s(){for(let j of[...this.#H.keys()])this.#D(j)}#o(j,z,Q,W,X,Z){let $=this.#G.get(j)??new Map;if($.has(Q))return;let G=setTimeout(()=>{if($.delete(Q),$.size===0)this.#G.delete(j)},z);return $.set(Q,G),this.#G.set(j,$),this.#U(Q,W,X,j,Z)}#n(){for(let j of this.#G.values())for(let z of j.values())clearTimeout(z);this.#G.clear()}#_(j,z,{cancelable:Q=!1}={}){let W=new CustomEvent(j,{bubbles:!0,composed:!0,cancelable:Q,detail:z});return(this.element.isConnected?this.element:document).dispatchEvent(W),W}#Q(j,z,Q,W){let X=()=>{if(!this.element.isConnected){console.warn("[phlex-reactive] retry() ignored — the reactive root left the DOM");return}return this.#U(j,z)};this.#_("reactive:error",{action:j,params:Q,...W,retry:X})}#W(j){if(this.element?.isConnected===!1)return;this.element?.setAttribute?.("data-reactive-error",j)}#r(){this.element?.removeAttribute?.("data-reactive-error")}#a(){let j=document.querySelector("[data-reactive-error-flash]");if(!j?.content)return;let z=j.getAttribute("data-reactive-error-flash")||"flash",Q=document.getElementById(z);if(!Q)return;Q.appendChild(j.content.cloneNode(!0))}#i(){if(typeof sessionStorage>"u")return Promise.resolve();let j=Number(sessionStorage.getItem(P));if(!Number.isFinite(j)||j<=0)return Promise.resolve();if(!M)M=!0,console.warn(`[phlex-reactive] latency simulator ACTIVE — every action is delayed by ${j}ms. Call PhlexReactive.disableLatencySim() (or clear sessionStorage) to turn it off.`);return new Promise((z)=>setTimeout(z,j))}#t(){return this.element?.getAttribute?.("data-reactive-debug")==="true"}#T(){return typeof performance<"u"&&typeof performance.now==="function"?performance.now():Date.now()}#e(j){if(!j)return[];let z=[],Q=/<turbo-stream\b([^>]*)>/g,W;while((W=Q.exec(j))!==null){let X=W[1],Z=X.match(/\baction="([^"]*)"/)?.[1]??"?",$=X.match(/\btarget="([^"]*)"/)?.[1];z.push($?`${Z} → #${$}`:Z)}return z}#jj(j){let{action:z,status:Q,ms:W}=j,Z=`reactive ${this.element?.id?`#${this.element.id} `:""}${z} → ${Q??"—"} (${Math.round(W)}ms)`;if(console.groupCollapsed(Z),console.log(`params: [${j.paramNames.join(", ")}] + collected: [${j.fieldNames.join(", ")}]`),console.log(`encoding: ${j.encoding}`),j.streams.length)console.log(`streams: ${j.streams.join(" ")}`);console.log(`token: ${j.tokenRefreshed?"refreshed ✓":"unchanged"}`),console.groupEnd()}async#zj(j,z,Q,W){let{fields:X,files:Z}=this.#Wj(),$=this.#h(z),G={...X,...$},q=this.#X,Y=Z.length>0,H=Y?this.#qj(q,j,G,Z):JSON.stringify({token:q,act:j,params:G}),J=this.#t()?{action:j,paramNames:Object.keys($),fieldNames:Object.keys(X),encoding:Y?"multipart":"json",status:null,streams:[],tokenRefreshed:!1,started:this.#T()}:null;await this.#i();try{if(navigator.onLine===!1){this.#Z(Q),this.#W("offline"),this.#Q(j,z,G,{kind:"offline"});return}let K;try{let V={Accept:"text/vnd.turbo-stream.html","X-CSRF-Token":this.#Ej()};if(!Y)V["Content-Type"]="application/json";let I=this.#Fj();if(I)V["X-Pgbus-Connection"]=I;K=await fetch(this.#xj(),{method:"POST",headers:V,body:H,credentials:"same-origin",signal:AbortSignal.timeout(this.#Pj())})}catch(V){if(console.error("[phlex-reactive] action error",V),this.#Z(Q),V?.name==="TimeoutError"||V?.name==="AbortError"){this.#W("timeout"),this.#Q(j,z,G,{kind:"timeout"});return}this.#a(),this.#W("network"),this.#Q(j,z,G,{kind:"network"});return}if(J)J.status=K.status;if(K.redirected){console.error("[phlex-reactive] action was redirected (auth/CSRF?) — no update applied"),this.#Z(Q),this.#W("redirected"),this.#Q(j,z,G,{kind:"redirected",status:K.status});return}if(!K.ok){let V=await K.text();if(console.error(`[phlex-reactive] action failed: HTTP ${K.status}`,V),this.#Z(Q),(K.headers.get("Content-Type")||"").includes("turbo-stream")){let I=this.#b(V);if(this.#X=I??this.#X,J)this.#k(J,V,I);window.Turbo.renderStreamMessage(V)}this.#W("http"),this.#Q(j,z,G,{kind:"http",status:K.status,body:V});return}let U=K.headers.get("Content-Type")||"";if(!U.includes("turbo-stream")){console.error(`[phlex-reactive] expected a turbo-stream, got "${U}" — no update applied`),this.#Z(Q),this.#W("content-type"),this.#Q(j,z,G,{kind:"content-type",status:K.status});return}let _=await K.text(),E=this.#b(_);if(this.#X=E??this.#X,J)this.#k(J,_,E);window.Turbo.renderStreamMessage(_),this.#r(),this.#_("reactive:applied",{action:j,params:G,html:_})}catch(K){console.error("[phlex-reactive] action error",K),this.#Z(Q),this.#_("reactive:error",{action:j,params:G,kind:"apply"})}finally{if(W?.(),J)this.#jj({...J,ms:this.#T()-J.started})}}#k(j,z,Q){j.streams=this.#e(z),j.tokenRefreshed=Q!=null}get#X(){return this.#E??this.tokenValue}set#X(j){this.#E=j}#b(j){let z=this.element.id;if(!z)return j.match(/data-reactive-token-value="([^"]+)"/)?.[1];let{token:Q,self:W}=this.#Qj(z),X=j.match(Q);if(X)return X[1];let Z=j.match(W);if(Z)return Z[1].match(/data-reactive-token-value="([^"]+)"/)?.[1];return}#Qj(j){let z=this.#F;if(z&&z.id===j)return z;let Q=c(j);return this.#F={id:j,token:new RegExp(`<turbo-stream\\b[^>]*\\baction="reactive:token"[^>]*\\btarget="${Q}"[^>]*\\bdata-reactive-token-value="([^"]+)"`),self:new RegExp(`<turbo-stream\\b[^>]*\\baction="(?:replace|update)"[^>]*\\btarget="${Q}"[^>]*>([\\s\\S]*?)</turbo-stream>`)}}#z(j){return j.closest('[data-controller~="reactive"]')===this.element}#I(){if(this.element.querySelectorAll('[data-controller~="reactive"]').length===0)return()=>!0;return(z)=>this.#z(z)}#Wj(){let j={},z=[],Q=this.#I();return this.element.querySelectorAll("input[name], select[name], textarea[name]").forEach((W)=>{if(!Q(W))return;if(W.type==="file")for(let X of W.files??[])z.push({name:W.name,file:X,multiple:W.multiple});else if(W.type==="checkbox")j[W.name]=W.checked;else if(W.type==="radio"){if(W.checked)j[W.name]=W.value}else j[W.name]=W.value}),this.element.querySelectorAll("[name]:is(lexxy-editor, trix-editor, [contenteditable=''], [contenteditable=true], [contenteditable=plaintext-only])").forEach((W)=>{if(!Q(W))return;let X=W.getAttribute("name");if(!X)return;let Z=j[X];if(Z==null||Z==="")j[X]=W.value??W.textContent??W.innerHTML??""}),{fields:j,files:z}}#O(){if(typeof this.element?.querySelectorAll!=="function")return;let j=0;if(this.element.querySelectorAll("input[name], select[name], textarea[name]").forEach((z)=>{if(!this.#z(z))return;if(z.type==="file")return;if(this.#Xj(z))z.setAttribute("data-reactive-dirty","true"),j++;else z.removeAttribute("data-reactive-dirty")}),j>0)this.element.setAttribute("data-reactive-dirty",String(j));else this.element.removeAttribute("data-reactive-dirty")}#Xj(j){if(j.type==="checkbox"||j.type==="radio")return j.checked!==j.defaultChecked;if(j.tag==="select"||j.options)return Array.from(j.options??[]).some((z)=>z.selected!==z.defaultSelected);return j.value!==j.defaultValue}#w(){let j=this.element.getAttribute?.("data-reactive-dirty"),z=Number(j);return Number.isFinite(z)&&z>0?z:0}#Zj(){if(typeof window>"u"||typeof window.addEventListener!=="function")return;this.#q=(j)=>{if(this.#w()===0)return;return j.preventDefault(),j.returnValue="You have unsaved changes.",j.returnValue},this.#Y=(j)=>{if(this.#w()===0)return;if(!(typeof window.confirm==="function"?window.confirm("You have unsaved changes. Leave anyway?"):!0))j.preventDefault?.()},window.addEventListener("beforeunload",this.#q),window.addEventListener("turbo:before-visit",this.#Y)}#$j(){if(this.#K)this.element.removeEventListener?.("turbo:morph-element",this.#K),this.#K=void 0;if(typeof window<"u"&&typeof window.removeEventListener==="function"){if(this.#q)window.removeEventListener("beforeunload",this.#q);if(this.#Y)window.removeEventListener("turbo:before-visit",this.#Y)}this.#q=void 0,this.#Y=void 0}#Gj(){let j=this.element.querySelectorAll?.("[data-reactive-show-field]")??[];for(let z of j)if(this.#z(z))return!0;return!1}#y(){if(typeof this.element?.querySelectorAll!=="function")return;let j=this.#I(),z=new Map;for(let Q of this.element.querySelectorAll("[data-reactive-show-field]")){if(!j(Q))continue;let W=Q.getAttribute("data-reactive-show-field");if(!W)continue;if(!z.has(W))z.set(W,this.#Jj(W,j));let X=z.get(W);if(X===null)continue;let Z=r(Q,X);if(Z===null)continue;Q.hidden=!Z}}#Jj(j,z){let Q=!1,W=null;for(let X of this.element.querySelectorAll(`[name="${j}"]`)){if(!z(X))continue;if(X.type==="checkbox")return X.checked?"true":"false";if(X.type==="radio"){if(X.checked)return X.value??"";Q=!0;continue}W??=X}if(W)return W.value??"";return Q?"":null}#Kj(){if(!this.#j)return;this.element.removeEventListener?.("input",this.#j),this.element.removeEventListener?.("change",this.#j),this.element.removeEventListener?.("turbo:morph-element",this.#j),this.#j=void 0}#qj(j,z,Q,W){let X=new FormData;X.append("token",j),X.append("act",z);for(let[$,G]of Object.entries(Q))this.#x(X,`params[${$}]`,G);let Z=this.#Yj(W);for(let{name:$,file:G,multiple:q}of W){let H=q||Z.has($)?`params[${$}][]`:`params[${$}]`;X.append(H,G,G.name)}return X}#x(j,z,Q){if(Q==null)j.append(z,"");else if(Array.isArray(Q))Q.forEach((W,X)=>this.#x(j,`${z}[${X}]`,W));else if(typeof Q==="object")for(let[W,X]of Object.entries(Q))this.#x(j,`${z}[${W}]`,X);else j.append(z,String(Q))}#Yj(j){let z=new Map;for(let{name:Q}of j)z.set(Q,(z.get(Q)??0)+1);return new Set([...z].filter(([,Q])=>Q>1).map(([Q])=>Q))}#h(j){if(!j)return{};try{return typeof j==="string"?JSON.parse(j):j}catch{return{}}}#Hj(j){return B(j)}#Vj(j){R(j,(z)=>this.#P(z))}#P(j){let z=j.to;if(z==="@root")return[this.element];if(typeof z!=="string"||z==="")return[];if(j.global)return[...document.querySelectorAll(z)];return[...this.element.querySelectorAll(z)].filter((Q)=>this.#z(Q))}#Uj(j,z){if(j?.checked!=="keep")return!1;let Q=z?.type;return Q==="checkbox"||Q==="radio"}#_j(j,z){if(!j)return null;let Q=this.#Ij(j,z),W=[];for(let X of Q){if(j.add_class){let Z=j.add_class.filter(($)=>!X.classList.contains($));if(X.classList.add(...Z),Z.length)W.push(()=>X.classList.remove(...Z))}if(j.remove_class){let Z=j.remove_class.filter(($)=>X.classList.contains($));if(X.classList.remove(...Z),Z.length)W.push(()=>X.classList.add(...Z))}if(j.toggle_class)j.toggle_class.forEach((Z)=>X.classList.toggle(Z)),W.push(()=>j.toggle_class.forEach((Z)=>X.classList.toggle(Z)));if(j.hide)X.hidden=!0,W.push(()=>X.hidden=!1)}if(j.checked==="keep"&&z&&"checked"in z){let X=z.checked;W.push(()=>z.checked=!X)}return W.length?W:null}#Z(j){if(!j)return;if(!this.element.isConnected)return;for(let z of j)z()}#Ij(j,z){if(j.to==null)return z?[z]:[];return this.#P({to:j.to})}#Mj(j,z,Q){this.#Nj(j,z);let W=this.#Aj(j,z,Q),X=!1;return()=>{if(X)return;X=!0,this.#Lj(j,z),W()}}#Nj(j,z){if(this.#$(z,j,1),this.#$(this.element,j,1),this.#J.set(j,(this.#J.get(j)??0)+1),this.#N++===0)this.element.setAttribute("aria-busy","true");for(let Q of this.#v(j))this.#$(Q,j,1)}#Lj(j,z){this.#$(z,j,-1),this.#$(this.element,j,-1);let Q=(this.#J.get(j)??1)-1;if(Q<=0)this.#J.delete(j);else this.#J.set(j,Q);if(--this.#N<=0)this.#N=0,this.element.removeAttribute("aria-busy");for(let W of this.#v(j))this.#$(W,j,-1)}#$(j,z,Q){if(!j||typeof j.getAttribute!=="function")return;let W=this.#L.get(j)??new Map,X=(W.get(z)??0)+Q;if(X<=0)W.delete(z);else W.set(z,X);if(W.size===0){this.#L.delete(j),j.removeAttribute("data-reactive-busy");return}this.#L.set(j,W),j.setAttribute("data-reactive-busy",[...W.keys()].join(" "))}#v(j){return[...this.element.querySelectorAll?.("[data-reactive-busy-on]")??[]].filter((Q)=>Q.getAttribute("data-reactive-busy-on")===j&&this.#z(Q))}#Aj(j,z,Q){if(!Q||!z)return()=>{};let W=this.#Oj(Q,z),X=Array.isArray(Q.class)?Q.class:[],Z=[];for(let G of W){let q=X.filter((Y)=>!G.classList.contains(Y));if(G.classList.add(...q),q.length)Z.push([G,q])}let $=this.#V.get(z);if($)$.count++;else if(Q.disable||Q.text!=null)this.#V.set(z,{count:1,disabled:z.disabled,text:z.textContent,hadText:Q.text!=null});if(Q.disable)z.disabled=!0;if(Q.text!=null)z.textContent=Q.text;return()=>{for(let[G,q]of Z)if(G.isConnected)G.classList.remove(...q);this.#Dj(z,Q)}}#Dj(j,z){let Q=this.#V.get(j);if(!Q)return;if(--Q.count>0)return;if(this.#V.delete(j),!j.isConnected)return;if(z.disable)j.disabled=Q.disabled;if(Q.hadText&&j.textContent===z.text)j.textContent=Q.text}#Oj(j,z){if(j.to==null)return z?[z]:[];return this.#P({to:j.to})}#xj(){return this.#f??=document.querySelector('meta[name="phlex-reactive-action-path"]')?.content||"/reactive/actions"}#Pj(){if(this.#M!=null)return this.#M;let j=document.querySelector('meta[name="phlex-reactive-timeout"]')?.content,z=Number(j);return this.#M=Number.isFinite(z)&&z>0?z:30000}#Ej(){return document.querySelector('meta[name="csrf-token"]')?.content??""}#Fj(){return document.querySelector("pgbus-stream-source[connection-id]")?.getAttribute("connection-id")||document.querySelector('meta[name="pgbus-connection-id"]')?.content||null}}export{b as registerReactiveVisit,w as registerReactiveToken,f as registerReactiveOffline,y as registerReactiveJs,h as registerReactiveDismiss,F as registerReactiveActions,c as escapeRegExp,u as enableLatencySim,p as disableLatencySim,Gj as default,g as checkReactiveRegistration,Zj as __resetReactiveRegistrationForTest,Wj as __resetReactiveOfflineForTest,Xj as __resetReactiveLatencyForTest,Qj as __resetReactiveDismissForTest,$j as __markReactiveConnectedForTest,P as LATENCY_KEY};
2
2
 
3
- //# debugId=34DC09D454B3A5E264756E2164756E21
3
+ //# debugId=B0542866EC05173F64756E2164756E21
4
4
  //# sourceMappingURL=reactive_controller.min.js.map