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 +4 -4
- data/CHANGELOG.md +39 -0
- data/README.md +103 -1
- data/app/javascript/phlex/reactive/reactive_controller.js +201 -4
- data/app/javascript/phlex/reactive/reactive_controller.min.js +2 -2
- data/app/javascript/phlex/reactive/reactive_controller.min.js.map +3 -3
- data/lib/phlex/reactive/component/dsl.rb +62 -3
- data/lib/phlex/reactive/component/helpers.rb +71 -6
- data/lib/phlex/reactive/component.rb +6 -1
- data/lib/phlex/reactive/js.rb +14 -0
- data/lib/phlex/reactive/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 86eeeabfa81b914dae1f3598e3c879892478c712afc5631b500b96cbd9b83757
|
|
4
|
+
data.tar.gz: d72882a3b92db0f2d542b5ef457a13f9e74fda538cb3ee5db7d2ac5916833515
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
763
|
-
|
|
764
|
-
|
|
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=
|
|
3
|
+
//# debugId=B0542866EC05173F64756E2164756E21
|
|
4
4
|
//# sourceMappingURL=reactive_controller.min.js.map
|