phlex-reactive 0.14.4 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +70 -0
  3. data/README.md +191 -1
  4. data/app/controllers/phlex/reactive/actions_controller.rb +28 -18
  5. data/app/javascript/phlex/reactive/core.js +5 -5
  6. data/app/javascript/phlex/reactive/core.min.js +3 -3
  7. data/app/javascript/phlex/reactive/core.min.js.map +4 -4
  8. data/app/javascript/phlex/reactive/early.js +28 -9
  9. data/app/javascript/phlex/reactive/early.min.js +2 -2
  10. data/app/javascript/phlex/reactive/early.min.js.map +3 -3
  11. data/app/javascript/phlex/reactive/features/bindings.js +125 -140
  12. data/app/javascript/phlex/reactive/features/bindings.min.js +2 -2
  13. data/app/javascript/phlex/reactive/features/bindings.min.js.map +3 -3
  14. data/app/javascript/phlex/reactive/features/form.js +63 -41
  15. data/app/javascript/phlex/reactive/features/form.min.js +2 -2
  16. data/app/javascript/phlex/reactive/features/form.min.js.map +3 -3
  17. data/app/javascript/phlex/reactive/features/presence.js +205 -0
  18. data/app/javascript/phlex/reactive/features/presence.min.js +4 -0
  19. data/app/javascript/phlex/reactive/features/presence.min.js.map +10 -0
  20. data/app/javascript/phlex/reactive/inspect.js +5 -1
  21. data/app/javascript/phlex/reactive/inspect.min.js +2 -2
  22. data/app/javascript/phlex/reactive/inspect.min.js.map +3 -3
  23. data/app/javascript/phlex/reactive/reactive_controller.js +3 -1
  24. data/app/javascript/phlex/reactive/reactive_controller.min.js +3 -3
  25. data/app/javascript/phlex/reactive/reactive_controller.min.js.map +8 -7
  26. data/app/javascript/phlex/reactive/runtime.js +175 -14
  27. data/lib/phlex/reactive/component/dsl.rb +5 -0
  28. data/lib/phlex/reactive/component/helpers.rb +79 -8
  29. data/lib/phlex/reactive/component/presence_declaration.rb +118 -0
  30. data/lib/phlex/reactive/component/registry.rb +3 -1
  31. data/lib/phlex/reactive/component.rb +1 -0
  32. data/lib/phlex/reactive/defer.rb +5 -0
  33. data/lib/phlex/reactive/engine.rb +9 -4
  34. data/lib/phlex/reactive/js.rb +24 -0
  35. data/lib/phlex/reactive/presence/component_methods.rb +130 -0
  36. data/lib/phlex/reactive/presence/roster.rb +90 -0
  37. data/lib/phlex/reactive/presence/transport.rb +154 -0
  38. data/lib/phlex/reactive/presence.rb +162 -0
  39. data/lib/phlex/reactive/presence_render_job.rb +26 -0
  40. data/lib/phlex/reactive/shortcut.rb +105 -0
  41. data/lib/phlex/reactive/streamable.rb +7 -3
  42. data/lib/phlex/reactive/version.rb +1 -1
  43. data/lib/phlex/reactive.rb +10 -0
  44. metadata +11 -1
@@ -12,7 +12,7 @@
12
12
  // This module never imports the core: it reaches a controller through the
13
13
  // `core` handle (core.owns — is this control this root's own, issue #15).
14
14
 
15
- // controller -> the removals of what connect() added, for disconnect() to run.
15
+ // controller -> [the removals of what connect() added, its reseed].
16
16
  const wired = new WeakMap()
17
17
  // The turbo:before-visit events a warn_unsaved root already prompted for: one
18
18
  // prompt per visit, however many dirty roots guard the page (issue #298).
@@ -21,62 +21,84 @@ const askedVisits = new WeakSet()
21
21
  export function connect(controller, core) {
22
22
  const root = controller.element
23
23
  const offs = []
24
- wired.set(controller, offs)
24
+ const seeds = []
25
25
  // Add a listener and keep its removal. Closures, not a record of named
26
26
  // handlers: the minifier renames a local, never a property (issue #310).
27
27
  const listen = (target, type, handler) => {
28
28
  target.addEventListener?.(type, handler)
29
29
  offs.push(() => target.removeEventListener?.(type, handler))
30
30
  }
31
+ // Each pass below is wired once, the first time its gate reads true: here,
32
+ // and again from reseed() — a stream can render the root's first dirty field
33
+ // or paste trigger into it after connect (issue #356).
34
+ let tracking
35
+ let gating
36
+ const wire = () => {
37
+ // Dirty tracking (issue #103) — ONLY when this root opts in (track_dirty: or a
38
+ // reactive_field(dirty:)), so a component that never uses it pays nothing (no
39
+ // baseline scan, no morph listener on every broadcast). A plain (outerHTML)
40
+ // replace re-connects the controller, so seed the baseline scan here — the root
41
+ // reflects current-vs-default WITHOUT waiting for the first input. An in-place
42
+ // morph / broadcast morph keeps the element CONNECTED and fires no Stimulus
43
+ // lifecycle, so ALSO listen for turbo:morph-element on the root to re-scan
44
+ // after the morph writes fresh default* attributes (reactive:applied is NOT a
45
+ // valid hook — it fires when streams are handed to Turbo, BEFORE the DOM
46
+ // mutation). Both listeners are torn down in disconnect().
47
+ if (!tracking && (tracking = dirtyTrackingEnabled(root, core))) {
48
+ const rescan = () => scan(controller, core)
49
+ listen(root, "turbo:morph-element", rescan)
50
+ seeds.push(rescan)
51
+ rescan()
52
+
53
+ // warn_unsaved: arm a navigate-away guard gated on a LIVE dirty-count read
54
+ // (never a cached snapshot — the count is re-derived from the DOM each time).
55
+ // beforeunload covers a real browser unload; turbo:before-visit covers a
56
+ // Turbo in-app navigation (it does NOT fire on restoration visits — the
57
+ // documented gap). Registered on window only when the marker is present.
58
+ if (root.getAttribute?.("data-reactive-warn-unsaved") === "true") armUnsavedGuard(root, listen)
59
+ }
31
60
 
32
- // Dirty tracking (issue #103) — ONLY when this root opts in (track_dirty: or a
33
- // reactive_field(dirty:)), so a component that never uses it pays nothing (no
34
- // baseline scan, no morph listener on every broadcast). A plain (outerHTML)
35
- // replace re-connects the controller, so seed the baseline scan here — the root
36
- // reflects current-vs-default WITHOUT waiting for the first input. An in-place
37
- // morph / broadcast morph keeps the element CONNECTED and fires no Stimulus
38
- // lifecycle, so ALSO listen for turbo:morph-element on the root to re-scan
39
- // after the morph writes fresh default* attributes (reactive:applied is NOT a
40
- // valid hook — it fires when streams are handed to Turbo, BEFORE the DOM
41
- // mutation). Both listeners are torn down in disconnect().
42
- if (dirtyTrackingEnabled(root, core)) {
43
- const rescan = () => scan(controller, core)
44
- listen(root, "turbo:morph-element", rescan)
45
- rescan()
46
-
47
- // warn_unsaved: arm a navigate-away guard gated on a LIVE dirty-count read
48
- // (never a cached snapshot — the count is re-derived from the DOM each time).
49
- // beforeunload covers a real browser unload; turbo:before-visit covers a
50
- // Turbo in-app navigation (it does NOT fire on restoration visits — the
51
- // documented gap). Registered on window only when the marker is present.
52
- if (root.getAttribute?.("data-reactive-warn-unsaved") === "true") armUnsavedGuard(root, listen)
53
- }
54
-
55
- // Clipboard-trigger availability gate (issue #228) — ONLY when this root
56
- // owns a paste trigger (on_client marks one with data-reactive-clipboard).
57
- // The Async Clipboard API is absent in insecure contexts and some webviews;
58
- // a paste button that can never work must not show. The gate OWNS a marked
59
- // trigger's `hidden` flag: author the trigger `hidden` and this pass reveals
60
- // it where the API exists (a dead button never paints); turbo:morph-element
61
- // re-syncs because a morph rewrites the trigger back to its authored hidden
62
- // state. The decision is made ONCE at connect — render the paste trigger
63
- // unconditionally: a trigger first INTRODUCED by a later morph of a root
64
- // that already tracks dirtiness stays ungated (hidden) until a full replace
65
- // re-connects the controller.
66
- if (clipboardGateEnabled(root, core)) {
67
- const sync = () => syncClipboardTriggers(root, core)
68
- listen(root, "turbo:morph-element", sync)
69
- sync()
61
+ // Clipboard-trigger availability gate (issue #228) — ONLY when this root
62
+ // owns a paste trigger (on_client marks one with data-reactive-clipboard).
63
+ // The Async Clipboard API is absent in insecure contexts and some webviews;
64
+ // a paste button that can never work must not show. The gate OWNS a marked
65
+ // trigger's `hidden` flag: author the trigger `hidden` and this pass reveals
66
+ // it where the API exists (a dead button never paints); turbo:morph-element
67
+ // re-syncs because a morph rewrites the trigger back to its authored hidden
68
+ // state. A trigger first INTRODUCED by a later morph of a root that already
69
+ // tracks dirtiness stays ungated (hidden) until a full replace re-connects
70
+ // the controller; one a stream renders into the root is gated then.
71
+ if (!gating && (gating = clipboardGateEnabled(root, core))) {
72
+ const sync = () => syncClipboardTriggers(root, core)
73
+ listen(root, "turbo:morph-element", sync)
74
+ seeds.push(sync)
75
+ sync()
76
+ }
70
77
  }
78
+ wired.set(controller, [
79
+ offs,
80
+ () => {
81
+ for (const run of seeds) run()
82
+ wire()
83
+ },
84
+ ])
85
+ wire()
71
86
  }
72
87
 
73
88
  // Remove the listeners on disconnect (Turbo morph/navigation) so a morph
74
89
  // re-scan or a navigate-away guard never runs against a detached root.
75
90
  export function disconnect(controller) {
76
- for (const off of wired.get(controller) ?? []) off()
91
+ for (const off of wired.get(controller)?.[0] ?? []) off()
77
92
  wired.delete(controller)
78
93
  }
79
94
 
95
+ // Re-run the passes wired at connect, then wire any whose gate reads true only
96
+ // now — for content that changed after connect() ran (the draft restore, a
97
+ // stream rendered into the root, issue #356).
98
+ export function reseed(controller) {
99
+ wired.get(controller)?.[1]()
100
+ }
101
+
80
102
  // Whether this root opts into dirty tracking (issue #103): track_dirty: puts the
81
103
  // trackDirty descriptor on the ROOT's data-action; a per-field reactive_field(
82
104
  // dirty:) puts it on a descendant field. Either turns tracking on. A quick
@@ -1,4 +1,4 @@
1
- var u=new WeakMap,s=new WeakSet;function w(e,n){let t=e.element,a=[];u.set(e,a);let i=(r,o,c)=>{r.addEventListener?.(o,c),a.push(()=>r.removeEventListener?.(o,c))};if(f(t,n)){let r=()=>l(e,n);if(i(t,"turbo:morph-element",r),r(),t.getAttribute?.("data-reactive-warn-unsaved")==="true")b(t,i)}if(v(t,n)){let r=()=>y(t,n);i(t,"turbo:morph-element",r),r()}}function m(e){for(let n of u.get(e)??[])n();u.delete(e)}function f(e,n){if((e.getAttribute?.("data-action")??"").includes("reactive#trackDirty"))return!0;let t=e.querySelectorAll?.('[data-action*="reactive#trackDirty"]')??[];for(let a of t)if(n.owns(a))return!0;return!1}function l(e,n){let t=e.element;if(typeof t?.querySelectorAll!=="function")return;let a=0,i=(r,o)=>{if(o)r.setAttribute("data-reactive-dirty","true"),a++;else r.removeAttribute("data-reactive-dirty")};if(t.querySelectorAll("input[name], select[name], textarea[name]").forEach((r)=>{if(!n.owns(r))return;if(r.type==="file")return;i(r,p(r))}),t.querySelectorAll("[data-reactive-editable][name]").forEach((r)=>{if(n.owns(r))i(r,n.text(r)!==n.served(r))}),a>0)t.setAttribute("data-reactive-dirty",String(a));else t.removeAttribute("data-reactive-dirty")}function p(e){if(e.type==="checkbox"||e.type==="radio")return e.checked!==e.defaultChecked;if(e.tag==="select"||e.options){let n=Array.from(e.options??[]),t=!e.multiple&&!(e.size>1),a=n.filter((i)=>i.defaultSelected).pop()??n.find((i)=>!i.closest?.("optgroup")?.disabled&&!i.disabled);return n.some((i)=>i.selected!==(t?i===a:i.defaultSelected))}return e.value!==e.defaultValue}function d(e){let n=e.getAttribute?.("data-reactive-dirty"),t=Number(n);return Number.isFinite(t)&&t>0?t:0}function b(e,n){if(typeof window>"u"||typeof window.addEventListener!=="function")return;n(window,"beforeunload",(t)=>{if(d(e)===0)return;return t.preventDefault(),t.returnValue="You have unsaved changes.",t.returnValue}),n(window,"turbo:before-visit",(t)=>{if(d(e)===0||s.has(t))return;if(s.add(t),!(typeof window.confirm==="function"?window.confirm("You have unsaved changes. Leave anyway?"):!0))t.preventDefault?.()})}function v(e,n){if(e.getAttribute?.("data-reactive-clipboard"))return!0;let t=e.querySelectorAll?.("[data-reactive-clipboard]")??[];for(let a of t)if(n.owns(a))return!0;return!1}function y(e,n){let t=typeof globalThis.navigator?.clipboard?.readText==="function";if(e.getAttribute?.("data-reactive-clipboard"))e.hidden=!t;for(let a of e.querySelectorAll?.("[data-reactive-clipboard]")??[])if(n.owns(a))a.hidden=!t}export{w as connect,m as disconnect,l as scan};
1
+ var c=new WeakMap,p=new WeakSet;function A(e,n){let t=e.element,r=[],i=[],a=(o,f,l)=>{o.addEventListener?.(f,l),r.push(()=>o.removeEventListener?.(f,l))},u,s,d=()=>{if(!u&&(u=v(t,n))){let o=()=>y(e,n);if(a(t,"turbo:morph-element",o),i.push(o),o(),t.getAttribute?.("data-reactive-warn-unsaved")==="true")m(t,a)}if(!s&&(s=g(t,n))){let o=()=>h(t,n);a(t,"turbo:morph-element",o),i.push(o),o()}};c.set(e,[r,()=>{for(let o of i)o();d()}]),d()}function k(e){for(let n of c.get(e)?.[0]??[])n();c.delete(e)}function S(e){c.get(e)?.[1]()}function v(e,n){if((e.getAttribute?.("data-action")??"").includes("reactive#trackDirty"))return!0;let t=e.querySelectorAll?.('[data-action*="reactive#trackDirty"]')??[];for(let r of t)if(n.owns(r))return!0;return!1}function y(e,n){let t=e.element;if(typeof t?.querySelectorAll!=="function")return;let r=0,i=(a,u)=>{if(u)a.setAttribute("data-reactive-dirty","true"),r++;else a.removeAttribute("data-reactive-dirty")};if(t.querySelectorAll("input[name], select[name], textarea[name]").forEach((a)=>{if(!n.owns(a))return;if(a.type==="file")return;i(a,w(a))}),t.querySelectorAll("[data-reactive-editable][name]").forEach((a)=>{if(n.owns(a))i(a,n.text(a)!==n.served(a))}),r>0)t.setAttribute("data-reactive-dirty",String(r));else t.removeAttribute("data-reactive-dirty")}function w(e){if(e.type==="checkbox"||e.type==="radio")return e.checked!==e.defaultChecked;if(e.tag==="select"||e.options){let n=Array.from(e.options??[]),t=!e.multiple&&!(e.size>1),r=n.filter((i)=>i.defaultSelected).pop()??n.find((i)=>!i.closest?.("optgroup")?.disabled&&!i.disabled);return n.some((i)=>i.selected!==(t?i===r:i.defaultSelected))}return e.value!==e.defaultValue}function b(e){let n=e.getAttribute?.("data-reactive-dirty"),t=Number(n);return Number.isFinite(t)&&t>0?t:0}function m(e,n){if(typeof window>"u"||typeof window.addEventListener!=="function")return;n(window,"beforeunload",(t)=>{if(b(e)===0)return;return t.preventDefault(),t.returnValue="You have unsaved changes.",t.returnValue}),n(window,"turbo:before-visit",(t)=>{if(b(e)===0||p.has(t))return;if(p.add(t),!(typeof window.confirm==="function"?window.confirm("You have unsaved changes. Leave anyway?"):!0))t.preventDefault?.()})}function g(e,n){if(e.getAttribute?.("data-reactive-clipboard"))return!0;let t=e.querySelectorAll?.("[data-reactive-clipboard]")??[];for(let r of t)if(n.owns(r))return!0;return!1}function h(e,n){let t=typeof globalThis.navigator?.clipboard?.readText==="function";if(e.getAttribute?.("data-reactive-clipboard"))e.hidden=!t;for(let r of e.querySelectorAll?.("[data-reactive-clipboard]")??[])if(n.owns(r))r.hidden=!t}export{A as connect,k as disconnect,S as reseed,y as scan};
2
2
 
3
- //# debugId=3DF1547567D3898464756E2164756E21
3
+ //# debugId=E49EE6DCC948366F64756E2164756E21
4
4
  //# sourceMappingURL=form.min.js.map
@@ -2,9 +2,9 @@
2
2
  "version": 3,
3
3
  "sources": ["features/form.js"],
4
4
  "sourcesContent": [
5
- "// phlex/reactive/features/form — what a form root sets up when it connects\n// and re-syncs after a morph, with no round trip: dirty tracking and the\n// navigate-away guard (issue #103), and the paste-trigger availability gate\n// (issue #228). One of the feature modules the core imports on demand (issue\n// #275); in the default client it is part of the one file.\n//\n// The two share a module because they share a shape, not a page: a marker\n// check at connect, one pass over the root's own controls, the same pass\n// again on turbo:morph-element. The gate alone is smaller than what a module\n// of its own costs the core.\n//\n// This module never imports the core: it reaches a controller through the\n// `core` handle (core.owns — is this control this root's own, issue #15).\n\n// controller -> the removals of what connect() added, for disconnect() to run.\nconst wired = new WeakMap()\n// The turbo:before-visit events a warn_unsaved root already prompted for: one\n// prompt per visit, however many dirty roots guard the page (issue #298).\nconst askedVisits = new WeakSet()\n\nexport function connect(controller, core) {\n const root = controller.element\n const offs = []\n wired.set(controller, offs)\n // Add a listener and keep its removal. Closures, not a record of named\n // handlers: the minifier renames a local, never a property (issue #310).\n const listen = (target, type, handler) => {\n target.addEventListener?.(type, handler)\n offs.push(() => target.removeEventListener?.(type, handler))\n }\n\n // Dirty tracking (issue #103) — ONLY when this root opts in (track_dirty: or a\n // reactive_field(dirty:)), so a component that never uses it pays nothing (no\n // baseline scan, no morph listener on every broadcast). A plain (outerHTML)\n // replace re-connects the controller, so seed the baseline scan here — the root\n // reflects current-vs-default WITHOUT waiting for the first input. An in-place\n // morph / broadcast morph keeps the element CONNECTED and fires no Stimulus\n // lifecycle, so ALSO listen for turbo:morph-element on the root to re-scan\n // after the morph writes fresh default* attributes (reactive:applied is NOT a\n // valid hook — it fires when streams are handed to Turbo, BEFORE the DOM\n // mutation). Both listeners are torn down in disconnect().\n if (dirtyTrackingEnabled(root, core)) {\n const rescan = () => scan(controller, core)\n listen(root, \"turbo:morph-element\", rescan)\n rescan()\n\n // warn_unsaved: arm a navigate-away guard gated on a LIVE dirty-count read\n // (never a cached snapshot — the count is re-derived from the DOM each time).\n // beforeunload covers a real browser unload; turbo:before-visit covers a\n // Turbo in-app navigation (it does NOT fire on restoration visits — the\n // documented gap). Registered on window only when the marker is present.\n if (root.getAttribute?.(\"data-reactive-warn-unsaved\") === \"true\") armUnsavedGuard(root, listen)\n }\n\n // Clipboard-trigger availability gate (issue #228) — ONLY when this root\n // owns a paste trigger (on_client marks one with data-reactive-clipboard).\n // The Async Clipboard API is absent in insecure contexts and some webviews;\n // a paste button that can never work must not show. The gate OWNS a marked\n // trigger's `hidden` flag: author the trigger `hidden` and this pass reveals\n // it where the API exists (a dead button never paints); turbo:morph-element\n // re-syncs because a morph rewrites the trigger back to its authored hidden\n // state. The decision is made ONCE at connect — render the paste trigger\n // unconditionally: a trigger first INTRODUCED by a later morph of a root\n // that already tracks dirtiness stays ungated (hidden) until a full replace\n // re-connects the controller.\n if (clipboardGateEnabled(root, core)) {\n const sync = () => syncClipboardTriggers(root, core)\n listen(root, \"turbo:morph-element\", sync)\n sync()\n }\n}\n\n// Remove the listeners on disconnect (Turbo morph/navigation) so a morph\n// re-scan or a navigate-away guard never runs against a detached root.\nexport function disconnect(controller) {\n for (const off of wired.get(controller) ?? []) off()\n wired.delete(controller)\n}\n\n// Whether this root opts into dirty tracking (issue #103): track_dirty: puts the\n// trackDirty descriptor on the ROOT's data-action; a per-field reactive_field(\n// dirty:) puts it on a descendant field. Either turns tracking on. A quick\n// attribute read + one scoped query, evaluated once per connect (a cold path).\nfunction dirtyTrackingEnabled(root, core) {\n if ((root.getAttribute?.(\"data-action\") ?? \"\").includes(\"reactive#trackDirty\")) return true\n const nodes = root.querySelectorAll?.('[data-action*=\"reactive#trackDirty\"]') ?? []\n for (const el of nodes) if (core.owns(el)) return true\n return false\n}\n\n// Dirty tracking (issue #103). Wired by reactive_field(dirty: true) /\n// reactive_root(track_dirty: true): an `input` on an owned field runs a FULL\n// re-scan of every field this root owns (the controller's trackDirty action\n// calls this). NO round trip, NO shipped state.\n//\n// Re-compute the dirty flag for EVERY field this root owns in one pass, then\n// reflect the total onto the root. Called on an owned field's input, on\n// connect (baseline seed), and after a turbo:morph-element re-render (fresh\n// default* attrs). dirty = current ≠ the DOM's own default:\n// checkbox/radio → checked !== defaultChecked\n// select → some option.selected !== its reset state (issue #297)\n// else → value !== defaultValue\n// A full pass (not per-target) is REQUIRED: a radio group's previously-checked\n// radio flips to checked=false with NO input event, so per-target toggling\n// would leave its flag stale. File inputs are skipped — a file has no server\n// default baseline. Per-dirty-field data-reactive-dirty=\"true\" (\"true\" STRING,\n// not a valueless boolean attr — mirrors the on() flag convention); the root\n// carries data-reactive-dirty=\"<count>\" and DROPS the attr at zero, so\n// `[data-reactive-dirty]` styles the whole form and `[data-reactive-dirty]`\n// on a field styles just the changed control — both pure CSS, zero JS.\nexport function scan(controller, core) {\n const root = controller.element\n // Runs at bootstrap (the connect baseline seed) as well as on input/morph, so\n // it must never throw — degrade to a no-op if the root can't be queried (a real\n // reactive root always can; this guards minimal/test element stubs).\n if (typeof root?.querySelectorAll !== \"function\") return\n\n let count = 0\n const mark = (field, dirty) => {\n if (dirty) {\n field.setAttribute(\"data-reactive-dirty\", \"true\")\n count++\n } else {\n field.removeAttribute(\"data-reactive-dirty\")\n }\n }\n root.querySelectorAll(\"input[name], select[name], textarea[name]\").forEach((field) => {\n if (!core.owns(field)) return // skip a nested reactive root's fields (issue #15)\n if (field.type === \"file\") return // no server default baseline to diff against\n mark(field, fieldDirty(field))\n })\n // A reactive_editable (issue #351) diffs its text against the text of its\n // last server render, which the core keeps.\n root.querySelectorAll(\"[data-reactive-editable][name]\").forEach((field) => {\n if (core.owns(field)) mark(field, core.text(field) !== core.served(field))\n })\n\n if (count > 0) root.setAttribute(\"data-reactive-dirty\", String(count))\n else root.removeAttribute(\"data-reactive-dirty\")\n}\n\n// Whether a single owned control differs from its server-rendered default.\nfunction fieldDirty(field) {\n if (field.type === \"checkbox\" || field.type === \"radio\") {\n return field.checked !== field.defaultChecked\n }\n if (field.tag === \"select\" || field.options) {\n // Any option whose selected state diverges from the select's RESET state.\n // Guard for a stub/absent options list (degrade to clean). A one-row single\n // select resets to its last defaultSelected option or, with none, to its\n // first enabled one — the browser selects it while its defaultSelected stays\n // false, so a pristine form must not read as dirty (issue #297). A multiple\n // (or size > 1) select resets to exactly its defaultSelected options. An\n // option inside a disabled <optgroup> is disabled too (the browser skips it).\n const options = Array.from(field.options ?? [])\n const single = !field.multiple && !(field.size > 1)\n const reset = options.filter((o) => o.defaultSelected).pop() ?? options.find((o) => !o.closest?.(\"optgroup\")?.disabled && !o.disabled)\n return options.some((o) => o.selected !== (single ? o === reset : o.defaultSelected))\n }\n return field.value !== field.defaultValue\n}\n\n// The live dirty-field count, re-derived from the DOM (never a cached snapshot)\n// — the source of truth for the warn_unsaved guard's gate.\nfunction dirtyCount(root) {\n const raw = root.getAttribute?.(\"data-reactive-dirty\")\n const n = Number(raw)\n return Number.isFinite(n) && n > 0 ? n : 0\n}\n\n// Arm the navigate-away guard (warn_unsaved: true, issue #103). beforeunload\n// blocks a real browser unload; turbo:before-visit blocks a Turbo in-app\n// navigation (it does NOT fire on restoration visits — the documented gap).\n// Both read the LIVE dirty count, so a clean form never blocks. Added through\n// connect()'s `listen`, so disconnect() removes exactly them.\nfunction armUnsavedGuard(root, listen) {\n if (typeof window === \"undefined\" || typeof window.addEventListener !== \"function\") return\n\n listen(window, \"beforeunload\", (event) => {\n if (dirtyCount(root) === 0) return undefined\n // The spec dance: preventDefault + a truthy returnValue triggers the native\n // \"leave site?\" prompt. The string is legacy (modern browsers show their own\n // copy) but must be non-empty/truthy to arm the dialog.\n event.preventDefault()\n event.returnValue = \"You have unsaved changes.\"\n return event.returnValue\n })\n // Every warn_unsaved root arms its own turbo:before-visit handler, and one\n // visit is one event dispatched to all of them: the first DIRTY root asks,\n // and the rest see the event already asked (issue #298). A clean root never\n // claims the event, so any dirty root on the page can still veto the visit.\n listen(window, \"turbo:before-visit\", (event) => {\n if (dirtyCount(root) === 0 || askedVisits.has(event)) return\n askedVisits.add(event)\n const ok = typeof window.confirm === \"function\" ? window.confirm(\"You have unsaved changes. Leave anyway?\") : true\n if (!ok) event.preventDefault?.()\n })\n}\n\n// Whether this root owns a clipboard-marked paste trigger (issue #228). The\n// ROOT itself counts (a button-only component that mixes on_client(paste_into)\n// onto reactive_root), then one scoped query; a NESTED root's triggers are\n// its own controller's to gate (issue #15 ownership).\nfunction clipboardGateEnabled(root, core) {\n if (root.getAttribute?.(\"data-reactive-clipboard\")) return true\n const nodes = root.querySelectorAll?.(\"[data-reactive-clipboard]\") ?? []\n for (const el of nodes) if (core.owns(el)) return true\n return false\n}\n\n// Set every owned paste trigger's `hidden` from clipboard availability\n// (issue #228): available → revealed (the authored `hidden` was only the\n// no-dead-button first paint), missing → hidden (insecure context /\n// webview). The gate owns the flag on MARKED elements only — nothing else\n// is ever touched. A marked ROOT is gated too: when the component IS the\n// paste button, hiding the root is exactly \"the dead button never shows\".\nfunction syncClipboardTriggers(root, core) {\n const available = typeof globalThis.navigator?.clipboard?.readText === \"function\"\n if (root.getAttribute?.(\"data-reactive-clipboard\")) root.hidden = !available\n for (const el of root.querySelectorAll?.(\"[data-reactive-clipboard]\") ?? []) {\n if (core.owns(el)) el.hidden = !available\n }\n}\n"
5
+ "// phlex/reactive/features/form — what a form root sets up when it connects\n// and re-syncs after a morph, with no round trip: dirty tracking and the\n// navigate-away guard (issue #103), and the paste-trigger availability gate\n// (issue #228). One of the feature modules the core imports on demand (issue\n// #275); in the default client it is part of the one file.\n//\n// The two share a module because they share a shape, not a page: a marker\n// check at connect, one pass over the root's own controls, the same pass\n// again on turbo:morph-element. The gate alone is smaller than what a module\n// of its own costs the core.\n//\n// This module never imports the core: it reaches a controller through the\n// `core` handle (core.owns — is this control this root's own, issue #15).\n\n// controller -> [the removals of what connect() added, its reseed].\nconst wired = new WeakMap()\n// The turbo:before-visit events a warn_unsaved root already prompted for: one\n// prompt per visit, however many dirty roots guard the page (issue #298).\nconst askedVisits = new WeakSet()\n\nexport function connect(controller, core) {\n const root = controller.element\n const offs = []\n const seeds = []\n // Add a listener and keep its removal. Closures, not a record of named\n // handlers: the minifier renames a local, never a property (issue #310).\n const listen = (target, type, handler) => {\n target.addEventListener?.(type, handler)\n offs.push(() => target.removeEventListener?.(type, handler))\n }\n // Each pass below is wired once, the first time its gate reads true: here,\n // and again from reseed() — a stream can render the root's first dirty field\n // or paste trigger into it after connect (issue #356).\n let tracking\n let gating\n const wire = () => {\n // Dirty tracking (issue #103) — ONLY when this root opts in (track_dirty: or a\n // reactive_field(dirty:)), so a component that never uses it pays nothing (no\n // baseline scan, no morph listener on every broadcast). A plain (outerHTML)\n // replace re-connects the controller, so seed the baseline scan here — the root\n // reflects current-vs-default WITHOUT waiting for the first input. An in-place\n // morph / broadcast morph keeps the element CONNECTED and fires no Stimulus\n // lifecycle, so ALSO listen for turbo:morph-element on the root to re-scan\n // after the morph writes fresh default* attributes (reactive:applied is NOT a\n // valid hook — it fires when streams are handed to Turbo, BEFORE the DOM\n // mutation). Both listeners are torn down in disconnect().\n if (!tracking && (tracking = dirtyTrackingEnabled(root, core))) {\n const rescan = () => scan(controller, core)\n listen(root, \"turbo:morph-element\", rescan)\n seeds.push(rescan)\n rescan()\n\n // warn_unsaved: arm a navigate-away guard gated on a LIVE dirty-count read\n // (never a cached snapshot — the count is re-derived from the DOM each time).\n // beforeunload covers a real browser unload; turbo:before-visit covers a\n // Turbo in-app navigation (it does NOT fire on restoration visits — the\n // documented gap). Registered on window only when the marker is present.\n if (root.getAttribute?.(\"data-reactive-warn-unsaved\") === \"true\") armUnsavedGuard(root, listen)\n }\n\n // Clipboard-trigger availability gate (issue #228) — ONLY when this root\n // owns a paste trigger (on_client marks one with data-reactive-clipboard).\n // The Async Clipboard API is absent in insecure contexts and some webviews;\n // a paste button that can never work must not show. The gate OWNS a marked\n // trigger's `hidden` flag: author the trigger `hidden` and this pass reveals\n // it where the API exists (a dead button never paints); turbo:morph-element\n // re-syncs because a morph rewrites the trigger back to its authored hidden\n // state. A trigger first INTRODUCED by a later morph of a root that already\n // tracks dirtiness stays ungated (hidden) until a full replace re-connects\n // the controller; one a stream renders into the root is gated then.\n if (!gating && (gating = clipboardGateEnabled(root, core))) {\n const sync = () => syncClipboardTriggers(root, core)\n listen(root, \"turbo:morph-element\", sync)\n seeds.push(sync)\n sync()\n }\n }\n wired.set(controller, [\n offs,\n () => {\n for (const run of seeds) run()\n wire()\n },\n ])\n wire()\n}\n\n// Remove the listeners on disconnect (Turbo morph/navigation) so a morph\n// re-scan or a navigate-away guard never runs against a detached root.\nexport function disconnect(controller) {\n for (const off of wired.get(controller)?.[0] ?? []) off()\n wired.delete(controller)\n}\n\n// Re-run the passes wired at connect, then wire any whose gate reads true only\n// now — for content that changed after connect() ran (the draft restore, a\n// stream rendered into the root, issue #356).\nexport function reseed(controller) {\n wired.get(controller)?.[1]()\n}\n\n// Whether this root opts into dirty tracking (issue #103): track_dirty: puts the\n// trackDirty descriptor on the ROOT's data-action; a per-field reactive_field(\n// dirty:) puts it on a descendant field. Either turns tracking on. A quick\n// attribute read + one scoped query, evaluated once per connect (a cold path).\nfunction dirtyTrackingEnabled(root, core) {\n if ((root.getAttribute?.(\"data-action\") ?? \"\").includes(\"reactive#trackDirty\")) return true\n const nodes = root.querySelectorAll?.('[data-action*=\"reactive#trackDirty\"]') ?? []\n for (const el of nodes) if (core.owns(el)) return true\n return false\n}\n\n// Dirty tracking (issue #103). Wired by reactive_field(dirty: true) /\n// reactive_root(track_dirty: true): an `input` on an owned field runs a FULL\n// re-scan of every field this root owns (the controller's trackDirty action\n// calls this). NO round trip, NO shipped state.\n//\n// Re-compute the dirty flag for EVERY field this root owns in one pass, then\n// reflect the total onto the root. Called on an owned field's input, on\n// connect (baseline seed), and after a turbo:morph-element re-render (fresh\n// default* attrs). dirty = current ≠ the DOM's own default:\n// checkbox/radio → checked !== defaultChecked\n// select → some option.selected !== its reset state (issue #297)\n// else → value !== defaultValue\n// A full pass (not per-target) is REQUIRED: a radio group's previously-checked\n// radio flips to checked=false with NO input event, so per-target toggling\n// would leave its flag stale. File inputs are skipped — a file has no server\n// default baseline. Per-dirty-field data-reactive-dirty=\"true\" (\"true\" STRING,\n// not a valueless boolean attr — mirrors the on() flag convention); the root\n// carries data-reactive-dirty=\"<count>\" and DROPS the attr at zero, so\n// `[data-reactive-dirty]` styles the whole form and `[data-reactive-dirty]`\n// on a field styles just the changed control — both pure CSS, zero JS.\nexport function scan(controller, core) {\n const root = controller.element\n // Runs at bootstrap (the connect baseline seed) as well as on input/morph, so\n // it must never throw — degrade to a no-op if the root can't be queried (a real\n // reactive root always can; this guards minimal/test element stubs).\n if (typeof root?.querySelectorAll !== \"function\") return\n\n let count = 0\n const mark = (field, dirty) => {\n if (dirty) {\n field.setAttribute(\"data-reactive-dirty\", \"true\")\n count++\n } else {\n field.removeAttribute(\"data-reactive-dirty\")\n }\n }\n root.querySelectorAll(\"input[name], select[name], textarea[name]\").forEach((field) => {\n if (!core.owns(field)) return // skip a nested reactive root's fields (issue #15)\n if (field.type === \"file\") return // no server default baseline to diff against\n mark(field, fieldDirty(field))\n })\n // A reactive_editable (issue #351) diffs its text against the text of its\n // last server render, which the core keeps.\n root.querySelectorAll(\"[data-reactive-editable][name]\").forEach((field) => {\n if (core.owns(field)) mark(field, core.text(field) !== core.served(field))\n })\n\n if (count > 0) root.setAttribute(\"data-reactive-dirty\", String(count))\n else root.removeAttribute(\"data-reactive-dirty\")\n}\n\n// Whether a single owned control differs from its server-rendered default.\nfunction fieldDirty(field) {\n if (field.type === \"checkbox\" || field.type === \"radio\") {\n return field.checked !== field.defaultChecked\n }\n if (field.tag === \"select\" || field.options) {\n // Any option whose selected state diverges from the select's RESET state.\n // Guard for a stub/absent options list (degrade to clean). A one-row single\n // select resets to its last defaultSelected option or, with none, to its\n // first enabled one — the browser selects it while its defaultSelected stays\n // false, so a pristine form must not read as dirty (issue #297). A multiple\n // (or size > 1) select resets to exactly its defaultSelected options. An\n // option inside a disabled <optgroup> is disabled too (the browser skips it).\n const options = Array.from(field.options ?? [])\n const single = !field.multiple && !(field.size > 1)\n const reset = options.filter((o) => o.defaultSelected).pop() ?? options.find((o) => !o.closest?.(\"optgroup\")?.disabled && !o.disabled)\n return options.some((o) => o.selected !== (single ? o === reset : o.defaultSelected))\n }\n return field.value !== field.defaultValue\n}\n\n// The live dirty-field count, re-derived from the DOM (never a cached snapshot)\n// — the source of truth for the warn_unsaved guard's gate.\nfunction dirtyCount(root) {\n const raw = root.getAttribute?.(\"data-reactive-dirty\")\n const n = Number(raw)\n return Number.isFinite(n) && n > 0 ? n : 0\n}\n\n// Arm the navigate-away guard (warn_unsaved: true, issue #103). beforeunload\n// blocks a real browser unload; turbo:before-visit blocks a Turbo in-app\n// navigation (it does NOT fire on restoration visits — the documented gap).\n// Both read the LIVE dirty count, so a clean form never blocks. Added through\n// connect()'s `listen`, so disconnect() removes exactly them.\nfunction armUnsavedGuard(root, listen) {\n if (typeof window === \"undefined\" || typeof window.addEventListener !== \"function\") return\n\n listen(window, \"beforeunload\", (event) => {\n if (dirtyCount(root) === 0) return undefined\n // The spec dance: preventDefault + a truthy returnValue triggers the native\n // \"leave site?\" prompt. The string is legacy (modern browsers show their own\n // copy) but must be non-empty/truthy to arm the dialog.\n event.preventDefault()\n event.returnValue = \"You have unsaved changes.\"\n return event.returnValue\n })\n // Every warn_unsaved root arms its own turbo:before-visit handler, and one\n // visit is one event dispatched to all of them: the first DIRTY root asks,\n // and the rest see the event already asked (issue #298). A clean root never\n // claims the event, so any dirty root on the page can still veto the visit.\n listen(window, \"turbo:before-visit\", (event) => {\n if (dirtyCount(root) === 0 || askedVisits.has(event)) return\n askedVisits.add(event)\n const ok = typeof window.confirm === \"function\" ? window.confirm(\"You have unsaved changes. Leave anyway?\") : true\n if (!ok) event.preventDefault?.()\n })\n}\n\n// Whether this root owns a clipboard-marked paste trigger (issue #228). The\n// ROOT itself counts (a button-only component that mixes on_client(paste_into)\n// onto reactive_root), then one scoped query; a NESTED root's triggers are\n// its own controller's to gate (issue #15 ownership).\nfunction clipboardGateEnabled(root, core) {\n if (root.getAttribute?.(\"data-reactive-clipboard\")) return true\n const nodes = root.querySelectorAll?.(\"[data-reactive-clipboard]\") ?? []\n for (const el of nodes) if (core.owns(el)) return true\n return false\n}\n\n// Set every owned paste trigger's `hidden` from clipboard availability\n// (issue #228): available → revealed (the authored `hidden` was only the\n// no-dead-button first paint), missing → hidden (insecure context /\n// webview). The gate owns the flag on MARKED elements only — nothing else\n// is ever touched. A marked ROOT is gated too: when the component IS the\n// paste button, hiding the root is exactly \"the dead button never shows\".\nfunction syncClipboardTriggers(root, core) {\n const available = typeof globalThis.navigator?.clipboard?.readText === \"function\"\n if (root.getAttribute?.(\"data-reactive-clipboard\")) root.hidden = !available\n for (const el of root.querySelectorAll?.(\"[data-reactive-clipboard]\") ?? []) {\n if (core.owns(el)) el.hidden = !available\n }\n}\n"
6
6
  ],
7
- "mappings": "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",
8
- "debugId": "3DF1547567D3898464756E2164756E21",
7
+ "mappings": "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",
8
+ "debugId": "E49EE6DCC948366F64756E2164756E21",
9
9
  "names": []
10
10
  }
@@ -0,0 +1,205 @@
1
+ // phlex/reactive/features/presence — who is viewing a stream, one seat per
2
+ // tab (issue #358). One of the feature modules the core imports on demand
3
+ // (issue #275); in the default client it is part of the one file.
4
+ //
5
+ // The markup is the Ruby side's (reactive_presence and its helpers):
6
+ //
7
+ // data-reactive-presence="<signed src>" the roster root; its stream
8
+ // data-reactive-presence-roster="<seats>" on the root: the seats rendered
9
+ // data-reactive-presence-peer="<seat>" a tab's element
10
+ // data-reactive-presence-member="<id>" a person's (or a tab's) element
11
+ //
12
+ // and what this module sets, for the app's CSS, after the first paint and
13
+ // after every morph of the root:
14
+ //
15
+ // data-reactive-presence-me this tab's own peer element
16
+ // data-reactive-presence-same-member this person's other elements
17
+ //
18
+ // The roster is rendered ONCE for every viewer (one broadcast), so the server
19
+ // cannot say which row is "me": the module does, from the tab's seat.
20
+ //
21
+ // The subscription. The root carries its presence stream's signed src; the
22
+ // module mints a <pgbus-stream-source presence> for it in <body>, with a
23
+ // deterministic id (reactive-presence-src-<root id>), as the defer push lane
24
+ // mints its one-shot source. pgbus's element mints the tab's seat (one per
25
+ // document), sends it as ?seat= and exposes it as element.pgbusSeat: the
26
+ // transport stores it on the tab's presence row, a reconnect (a new
27
+ // connection id) keeps it, and this module reads it to find "me". A plain
28
+ // replace of the root disconnects and
29
+ // reconnects the controller: the source is removed only a microtask after a
30
+ // disconnect, and kept when the same root id comes back.
31
+ //
32
+ // Announcing. On every pgbus:connected (the first one and each reconnect) the
33
+ // module sends presence_set with the tab's last state ({} at first), through
34
+ // the root's own pipeline (core.proceed): the signed token, the typed state,
35
+ // the request queue. That write puts the tab on the roster for everyone (and
36
+ // the actor's own reply shows it), and a reconnect, which is a fresh row with
37
+ // no state, gets its state back. The runtime sends the source's connection id
38
+ // as X-Reactive-Presence, so the write lands on this tab's row.
39
+ //
40
+ // Events. The module keeps the seats it last saw in the root (the root's
41
+ // data-reactive-presence-roster list); when a morph changes them it dispatches
42
+ // reactive:presence on the root with { joined, left, me } — from the DOM, so a
43
+ // replayed frame that changes nothing fires nothing.
44
+ //
45
+ // This module never imports the core: it reaches a controller through the
46
+ // `core` handle (core.proceed, core.emit).
47
+
48
+ const SOURCE = "reactive-presence-src-"
49
+ const PEER = "data-reactive-presence-peer"
50
+ const MEMBER = "data-reactive-presence-member"
51
+ const ROSTER = "data-reactive-presence-roster"
52
+
53
+ // root id -> { root, core, state, seats, removing, off }: what a root's
54
+ // connection left behind, kept across a replace of the root.
55
+ const roots = new Map()
56
+ // The sources this module minted and listens to (a Turbo cache restore brings
57
+ // back a clone without the listener).
58
+ const minted = new WeakSet()
59
+
60
+ export function connect(controller, core) {
61
+ const root = controller.element
62
+ let entry = roots.get(root.id)
63
+ if (!entry) roots.set(root.id, (entry = { state: null, seats: null }))
64
+ entry.root = root
65
+ entry.core = core
66
+ entry.removing = false
67
+ subscribe(root)
68
+ const morphed = (event) => event.target === root && queueMicrotask(() => paint(entry))
69
+ // A failed announce (a network error, a 5xx) leaves the tab's row without
70
+ // its note and state until the next js.presence or reconnect: say so.
71
+ const failed = (event) =>
72
+ event.target === root &&
73
+ event.detail?.action === "presence_set" &&
74
+ console.error(`[phlex-reactive] #${root.id}: its presence_set request failed`, event.detail)
75
+ root.addEventListener("turbo:morph-element", morphed)
76
+ root.addEventListener("reactive:error", failed)
77
+ entry.off = () => {
78
+ root.removeEventListener("turbo:morph-element", morphed)
79
+ root.removeEventListener("reactive:error", failed)
80
+ }
81
+ paint(entry)
82
+ }
83
+
84
+ export function disconnect(controller) {
85
+ const root = controller.element
86
+ const entry = roots.get(root.id)
87
+ if (!entry || entry.root !== root) return
88
+ entry.off()
89
+ entry.removing = true
90
+ queueMicrotask(() => {
91
+ if (!entry.removing) return
92
+ roots.delete(root.id)
93
+ root.ownerDocument.getElementById(SOURCE + root.id)?.remove()
94
+ })
95
+ }
96
+
97
+ // js.presence(**state): merge into the tab's state and send it. Before the
98
+ // tab has a connection the state waits for the announce on connect.
99
+ export function set(root, state) {
100
+ if (!root?.hasAttribute?.("data-reactive-presence")) {
101
+ console.warn("[phlex-reactive] js.presence ran outside a reactive_presence root — ignored")
102
+ return
103
+ }
104
+ let entry = roots.get(root.id)
105
+ if (!entry) roots.set(root.id, (entry = { root, state: null, seats: null }))
106
+ entry.state = { ...entry.state, ...state }
107
+ send(entry)
108
+ }
109
+
110
+ function subscribe(root) {
111
+ const id = SOURCE + root.id
112
+ const found = document.getElementById(id)
113
+ if (found && minted.has(found)) return
114
+ found?.remove()
115
+ // pgbus's client is an async module script, so this controller can connect
116
+ // before <pgbus-stream-source> is defined: mint the element anyway (it
117
+ // upgrades when pgbus defines it), and call a still-missing client a
118
+ // misconfiguration only after a grace period.
119
+ if (!defined()) {
120
+ setTimeout(() => {
121
+ if (!defined()) {
122
+ console.error(
123
+ "[phlex-reactive] reactive_presence needs the pgbus client (<pgbus-stream-source>) on this page — " +
124
+ `#${root.id} is not subscribed to its presence stream`,
125
+ )
126
+ }
127
+ }, 5000)
128
+ }
129
+ const source = document.createElement("pgbus-stream-source")
130
+ source.id = id
131
+ source.setAttribute("src", root.getAttribute("data-reactive-presence"))
132
+ source.setAttribute("presence", "")
133
+ source.hidden = true
134
+ source.addEventListener("pgbus:connected", () => {
135
+ const entry = roots.get(root.id)
136
+ if (!entry || entry.removing) return
137
+ send(entry)
138
+ check(entry)
139
+ })
140
+ minted.add(source)
141
+ document.body.appendChild(source)
142
+ }
143
+
144
+ const defined = () => !!globalThis.customElements?.get?.("pgbus-stream-source")
145
+
146
+ // pgbus fires pgbus:connected only after the tab's seat has joined, so the
147
+ // announce lands on its row. A tab still off its roster a moment later has no
148
+ // identity (pgbus joins an anonymous connection to nothing), or its announce
149
+ // failed: say so once. No retry (an accepted risk).
150
+ function check(entry) {
151
+ clearTimeout(entry.timer)
152
+ entry.timer = setTimeout(() => {
153
+ const root = entry.root
154
+ if (entry.removing || !root.isConnected) return
155
+ const mine = root.ownerDocument.getElementById(SOURCE + root.id)?.pgbusSeat
156
+ if (mine && seatsOf(root).has(mine)) return
157
+ console.warn(
158
+ `[phlex-reactive] #${root.id} never appeared on its presence roster: config.streams_authorize ` +
159
+ "returned no identity for this stream, or its presence_set request failed (logged above)",
160
+ )
161
+ }, 2500)
162
+ }
163
+
164
+ // Send the tab's state through presence_set — only once the source has a
165
+ // connection (the row the write lands on). Until then the announce on
166
+ // pgbus:connected sends it.
167
+ function send(entry) {
168
+ const root = entry.root
169
+ if (!entry.core || !root.ownerDocument.getElementById(SOURCE + root.id)?.getAttribute("connection-id")) return
170
+ entry.core.proceed(root, "presence_set", JSON.stringify(entry.state ?? {}))
171
+ }
172
+
173
+ // The seats the root's render saw: the list the server names on the root
174
+ // (data-reactive-presence-roster: a template need not render an element per
175
+ // tab), or, without it, the tabs' own elements.
176
+ function seatsOf(root) {
177
+ const named = root.getAttribute(ROSTER)
178
+ if (named != null) return new Set(named.split(" ").filter(Boolean))
179
+ return new Set([...root.querySelectorAll(`[${PEER}]`)].map((el) => el.getAttribute(PEER)))
180
+ }
181
+
182
+ // Mark "me" and this person's other elements, then report the seats that
183
+ // joined or left since the last paint.
184
+ function paint(entry) {
185
+ const root = entry.root
186
+ // The seat pgbus's element minted (undefined on a pgbus without seats).
187
+ const mine = root.ownerDocument.getElementById(SOURCE + root.id)?.pgbusSeat
188
+ const marked = root.querySelectorAll(`[${PEER}],[${MEMBER}]`)
189
+ const seats = seatsOf(root)
190
+ let member
191
+ for (const el of marked) {
192
+ if (el.getAttribute(PEER) === mine) member = el.getAttribute(MEMBER)
193
+ }
194
+ for (const el of marked) {
195
+ const me = el.getAttribute(PEER) === mine
196
+ el.toggleAttribute("data-reactive-presence-me", me)
197
+ el.toggleAttribute("data-reactive-presence-same-member", !me && member != null && el.getAttribute(MEMBER) === member)
198
+ }
199
+ const before = entry.seats
200
+ entry.seats = seats
201
+ if (!before) return
202
+ const joined = [...seats].filter((s) => !before.has(s))
203
+ const left = [...before].filter((s) => !seats.has(s))
204
+ if (joined.length || left.length) entry.core.emit("reactive:presence", { joined, left, me: mine ?? null })
205
+ }
@@ -0,0 +1,4 @@
1
+ var c=new Map,m=new WeakSet;function h(t,r){let e=t.element,n=c.get(e.id);if(!n)c.set(e.id,n={state:null,seats:null});n.root=e,n.core=r,n.removing=!1,b(e);let o=(i)=>i.target===e&&queueMicrotask(()=>p(n)),a=(i)=>i.target===e&&i.detail?.action==="presence_set"&&console.error(`[phlex-reactive] #${e.id}: its presence_set request failed`,i.detail);e.addEventListener("turbo:morph-element",o),e.addEventListener("reactive:error",a),n.off=()=>{e.removeEventListener("turbo:morph-element",o),e.removeEventListener("reactive:error",a)},p(n)}function R(t){let r=t.element,e=c.get(r.id);if(!e||e.root!==r)return;e.off(),e.removing=!0,queueMicrotask(()=>{if(!e.removing)return;c.delete(r.id),r.ownerDocument.getElementById("reactive-presence-src-"+r.id)?.remove()})}function S(t,r){if(!t?.hasAttribute?.("data-reactive-presence")){console.warn("[phlex-reactive] js.presence ran outside a reactive_presence root — ignored");return}let e=c.get(t.id);if(!e)c.set(t.id,e={root:t,state:null,seats:null});e.state={...e.state,...r},g(e)}function b(t){let r="reactive-presence-src-"+t.id,e=document.getElementById(r);if(e&&m.has(e))return;if(e?.remove(),!f())setTimeout(()=>{if(!f())console.error("[phlex-reactive] reactive_presence needs the pgbus client (<pgbus-stream-source>) on this page — "+`#${t.id} is not subscribed to its presence stream`)},5000);let n=document.createElement("pgbus-stream-source");n.id=r,n.setAttribute("src",t.getAttribute("data-reactive-presence")),n.setAttribute("presence",""),n.hidden=!0,n.addEventListener("pgbus:connected",()=>{let o=c.get(t.id);if(!o||o.removing)return;g(o),v(o)}),m.add(n),document.body.appendChild(n)}var f=()=>!!globalThis.customElements?.get?.("pgbus-stream-source");function v(t){clearTimeout(t.timer),t.timer=setTimeout(()=>{let r=t.root;if(t.removing||!r.isConnected)return;let e=r.ownerDocument.getElementById("reactive-presence-src-"+r.id)?.pgbusSeat;if(e&&E(r).has(e))return;console.warn(`[phlex-reactive] #${r.id} never appeared on its presence roster: config.streams_authorize returned no identity for this stream, or its presence_set request failed (logged above)`)},2500)}function g(t){let r=t.root;if(!t.core||!r.ownerDocument.getElementById("reactive-presence-src-"+r.id)?.getAttribute("connection-id"))return;t.core.proceed(r,"presence_set",JSON.stringify(t.state??{}))}function E(t){let r=t.getAttribute("data-reactive-presence-roster");if(r!=null)return new Set(r.split(" ").filter(Boolean));return new Set([...t.querySelectorAll("[data-reactive-presence-peer]")].map((e)=>e.getAttribute("data-reactive-presence-peer")))}function p(t){let r=t.root,e=r.ownerDocument.getElementById("reactive-presence-src-"+r.id)?.pgbusSeat,n=r.querySelectorAll("[data-reactive-presence-peer],[data-reactive-presence-member]"),o=E(r),a;for(let s of n)if(s.getAttribute("data-reactive-presence-peer")===e)a=s.getAttribute("data-reactive-presence-member");for(let s of n){let l=s.getAttribute("data-reactive-presence-peer")===e;s.toggleAttribute("data-reactive-presence-me",l),s.toggleAttribute("data-reactive-presence-same-member",!l&&a!=null&&s.getAttribute("data-reactive-presence-member")===a)}let i=t.seats;if(t.seats=o,!i)return;let u=[...o].filter((s)=>!i.has(s)),d=[...i].filter((s)=>!o.has(s));if(u.length||d.length)t.core.emit("reactive:presence",{joined:u,left:d,me:e??null})}export{h as connect,R as disconnect,S as set};
2
+
3
+ //# debugId=A98AC1EA085C2A8E64756E2164756E21
4
+ //# sourceMappingURL=presence.min.js.map
@@ -0,0 +1,10 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["features/presence.js"],
4
+ "sourcesContent": [
5
+ "// phlex/reactive/features/presence — who is viewing a stream, one seat per\n// tab (issue #358). One of the feature modules the core imports on demand\n// (issue #275); in the default client it is part of the one file.\n//\n// The markup is the Ruby side's (reactive_presence and its helpers):\n//\n// data-reactive-presence=\"<signed src>\" the roster root; its stream\n// data-reactive-presence-roster=\"<seats>\" on the root: the seats rendered\n// data-reactive-presence-peer=\"<seat>\" a tab's element\n// data-reactive-presence-member=\"<id>\" a person's (or a tab's) element\n//\n// and what this module sets, for the app's CSS, after the first paint and\n// after every morph of the root:\n//\n// data-reactive-presence-me this tab's own peer element\n// data-reactive-presence-same-member this person's other elements\n//\n// The roster is rendered ONCE for every viewer (one broadcast), so the server\n// cannot say which row is \"me\": the module does, from the tab's seat.\n//\n// The subscription. The root carries its presence stream's signed src; the\n// module mints a <pgbus-stream-source presence> for it in <body>, with a\n// deterministic id (reactive-presence-src-<root id>), as the defer push lane\n// mints its one-shot source. pgbus's element mints the tab's seat (one per\n// document), sends it as ?seat= and exposes it as element.pgbusSeat: the\n// transport stores it on the tab's presence row, a reconnect (a new\n// connection id) keeps it, and this module reads it to find \"me\". A plain\n// replace of the root disconnects and\n// reconnects the controller: the source is removed only a microtask after a\n// disconnect, and kept when the same root id comes back.\n//\n// Announcing. On every pgbus:connected (the first one and each reconnect) the\n// module sends presence_set with the tab's last state ({} at first), through\n// the root's own pipeline (core.proceed): the signed token, the typed state,\n// the request queue. That write puts the tab on the roster for everyone (and\n// the actor's own reply shows it), and a reconnect, which is a fresh row with\n// no state, gets its state back. The runtime sends the source's connection id\n// as X-Reactive-Presence, so the write lands on this tab's row.\n//\n// Events. The module keeps the seats it last saw in the root (the root's\n// data-reactive-presence-roster list); when a morph changes them it dispatches\n// reactive:presence on the root with { joined, left, me } — from the DOM, so a\n// replayed frame that changes nothing fires nothing.\n//\n// This module never imports the core: it reaches a controller through the\n// `core` handle (core.proceed, core.emit).\n\nconst SOURCE = \"reactive-presence-src-\"\nconst PEER = \"data-reactive-presence-peer\"\nconst MEMBER = \"data-reactive-presence-member\"\nconst ROSTER = \"data-reactive-presence-roster\"\n\n// root id -> { root, core, state, seats, removing, off }: what a root's\n// connection left behind, kept across a replace of the root.\nconst roots = new Map()\n// The sources this module minted and listens to (a Turbo cache restore brings\n// back a clone without the listener).\nconst minted = new WeakSet()\n\nexport function connect(controller, core) {\n const root = controller.element\n let entry = roots.get(root.id)\n if (!entry) roots.set(root.id, (entry = { state: null, seats: null }))\n entry.root = root\n entry.core = core\n entry.removing = false\n subscribe(root)\n const morphed = (event) => event.target === root && queueMicrotask(() => paint(entry))\n // A failed announce (a network error, a 5xx) leaves the tab's row without\n // its note and state until the next js.presence or reconnect: say so.\n const failed = (event) =>\n event.target === root &&\n event.detail?.action === \"presence_set\" &&\n console.error(`[phlex-reactive] #${root.id}: its presence_set request failed`, event.detail)\n root.addEventListener(\"turbo:morph-element\", morphed)\n root.addEventListener(\"reactive:error\", failed)\n entry.off = () => {\n root.removeEventListener(\"turbo:morph-element\", morphed)\n root.removeEventListener(\"reactive:error\", failed)\n }\n paint(entry)\n}\n\nexport function disconnect(controller) {\n const root = controller.element\n const entry = roots.get(root.id)\n if (!entry || entry.root !== root) return\n entry.off()\n entry.removing = true\n queueMicrotask(() => {\n if (!entry.removing) return\n roots.delete(root.id)\n root.ownerDocument.getElementById(SOURCE + root.id)?.remove()\n })\n}\n\n// js.presence(**state): merge into the tab's state and send it. Before the\n// tab has a connection the state waits for the announce on connect.\nexport function set(root, state) {\n if (!root?.hasAttribute?.(\"data-reactive-presence\")) {\n console.warn(\"[phlex-reactive] js.presence ran outside a reactive_presence root — ignored\")\n return\n }\n let entry = roots.get(root.id)\n if (!entry) roots.set(root.id, (entry = { root, state: null, seats: null }))\n entry.state = { ...entry.state, ...state }\n send(entry)\n}\n\nfunction subscribe(root) {\n const id = SOURCE + root.id\n const found = document.getElementById(id)\n if (found && minted.has(found)) return\n found?.remove()\n // pgbus's client is an async module script, so this controller can connect\n // before <pgbus-stream-source> is defined: mint the element anyway (it\n // upgrades when pgbus defines it), and call a still-missing client a\n // misconfiguration only after a grace period.\n if (!defined()) {\n setTimeout(() => {\n if (!defined()) {\n console.error(\n \"[phlex-reactive] reactive_presence needs the pgbus client (<pgbus-stream-source>) on this page — \" +\n `#${root.id} is not subscribed to its presence stream`,\n )\n }\n }, 5000)\n }\n const source = document.createElement(\"pgbus-stream-source\")\n source.id = id\n source.setAttribute(\"src\", root.getAttribute(\"data-reactive-presence\"))\n source.setAttribute(\"presence\", \"\")\n source.hidden = true\n source.addEventListener(\"pgbus:connected\", () => {\n const entry = roots.get(root.id)\n if (!entry || entry.removing) return\n send(entry)\n check(entry)\n })\n minted.add(source)\n document.body.appendChild(source)\n}\n\nconst defined = () => !!globalThis.customElements?.get?.(\"pgbus-stream-source\")\n\n// pgbus fires pgbus:connected only after the tab's seat has joined, so the\n// announce lands on its row. A tab still off its roster a moment later has no\n// identity (pgbus joins an anonymous connection to nothing), or its announce\n// failed: say so once. No retry (an accepted risk).\nfunction check(entry) {\n clearTimeout(entry.timer)\n entry.timer = setTimeout(() => {\n const root = entry.root\n if (entry.removing || !root.isConnected) return\n const mine = root.ownerDocument.getElementById(SOURCE + root.id)?.pgbusSeat\n if (mine && seatsOf(root).has(mine)) return\n console.warn(\n `[phlex-reactive] #${root.id} never appeared on its presence roster: config.streams_authorize ` +\n \"returned no identity for this stream, or its presence_set request failed (logged above)\",\n )\n }, 2500)\n}\n\n// Send the tab's state through presence_set — only once the source has a\n// connection (the row the write lands on). Until then the announce on\n// pgbus:connected sends it.\nfunction send(entry) {\n const root = entry.root\n if (!entry.core || !root.ownerDocument.getElementById(SOURCE + root.id)?.getAttribute(\"connection-id\")) return\n entry.core.proceed(root, \"presence_set\", JSON.stringify(entry.state ?? {}))\n}\n\n// The seats the root's render saw: the list the server names on the root\n// (data-reactive-presence-roster: a template need not render an element per\n// tab), or, without it, the tabs' own elements.\nfunction seatsOf(root) {\n const named = root.getAttribute(ROSTER)\n if (named != null) return new Set(named.split(\" \").filter(Boolean))\n return new Set([...root.querySelectorAll(`[${PEER}]`)].map((el) => el.getAttribute(PEER)))\n}\n\n// Mark \"me\" and this person's other elements, then report the seats that\n// joined or left since the last paint.\nfunction paint(entry) {\n const root = entry.root\n // The seat pgbus's element minted (undefined on a pgbus without seats).\n const mine = root.ownerDocument.getElementById(SOURCE + root.id)?.pgbusSeat\n const marked = root.querySelectorAll(`[${PEER}],[${MEMBER}]`)\n const seats = seatsOf(root)\n let member\n for (const el of marked) {\n if (el.getAttribute(PEER) === mine) member = el.getAttribute(MEMBER)\n }\n for (const el of marked) {\n const me = el.getAttribute(PEER) === mine\n el.toggleAttribute(\"data-reactive-presence-me\", me)\n el.toggleAttribute(\"data-reactive-presence-same-member\", !me && member != null && el.getAttribute(MEMBER) === member)\n }\n const before = entry.seats\n entry.seats = seats\n if (!before) return\n const joined = [...seats].filter((s) => !before.has(s))\n const left = [...before].filter((s) => !seats.has(s))\n if (joined.length || left.length) entry.core.emit(\"reactive:presence\", { joined, left, me: mine ?? null })\n}\n"
6
+ ],
7
+ "mappings": "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",
8
+ "debugId": "A98AC1EA085C2A8E64756E2164756E21",
9
+ "names": []
10
+ }
@@ -150,6 +150,7 @@ function ownedTriggers(root, allRoots) {
150
150
  confirm: el.getAttribute("data-reactive-confirm-param"),
151
151
  optimistic: el.getAttribute("data-reactive-optimistic-param"),
152
152
  loading: el.getAttribute("data-reactive-loading-param"),
153
+ shortcut: el.getAttribute("data-reactive-shortcut-param"),
153
154
  }))
154
155
  }
155
156
 
@@ -175,7 +176,10 @@ function ownedClientOps(root, allRoots) {
175
176
  if (Array.isArray(legacy)) return [{ event, ops: legacy }]
176
177
  const records = (raw ?? "").split(" ").map(parseJson).filter((r) => r && typeof r === "object" && !Array.isArray(r))
177
178
  if (records.length === 0) return [{ event, ops: raw }]
178
- return records.map((r) => ({ event: r.on ? `${r.on}${r.window ? "@window" : ""}` : event, ops: r.ops }))
179
+ // A shortcut record (issue #357) reads as "keydown@window shortcut=mod+z".
180
+ const shortcutOf = (s) => (typeof s === "object" ? `${s.keys}${s.inEditable ? " editable" : ""}` : s)
181
+ const summary = (r) => `${r.on}${r.window ? "@window" : ""}${r.shortcut == null ? "" : ` shortcut=${shortcutOf(r.shortcut)}`}`
182
+ return records.map((r) => ({ event: r.on ? summary(r) : event, ops: r.ops }))
179
183
  })
180
184
  }
181
185
 
@@ -1,4 +1,4 @@
1
- function p(t=document){let e=Array.from(t.querySelectorAll('[data-controller~="reactive"]'));return e.map((r)=>f(r,e))}function S(t=document){let e=p(t),r=globalThis.console;if(r&&typeof r.log==="function")r.log(`[phlex-reactive] ${e.length} reactive root(s)`);if(r&&typeof r.table==="function")r.table(e.map((a)=>({id:a.id,component:a.component??(a.opaque?"(opaque token)":null),actions:a.triggers.map((n)=>n.action).join(", "),fields:a.fields.join(", "),computes:a.computes.join(", ")})));return e}function f(t,e){let r=t.getAttribute("data-reactive-token-value"),a=m(r);return{id:t.id||null,component:a.opaque?null:a.c??null,gid:a.gid??null,stateKeys:a.s?Object.keys(a.s):[],tokenVersion:a.v??null,opaque:!!a.opaque,status:b(t),triggers:A(t,e),clientOps:y(t,e),computes:w(t,e),fields:h(t,e),show:q(t,e),text:O(t,e),filter:x(t)}}function m(t){if(!t)return{opaque:!0};try{let e=t.split("--")[0],r=g(e),a=JSON.parse(r),n=a&&a._rails&&a._rails.data?a._rails.data:a;if(!n||typeof n!=="object")return{opaque:!0};return n}catch{return{opaque:!0}}}function g(t){return(globalThis.atob||((r)=>Buffer.from(r,"base64").toString("binary")))(t)}function b(t){return{error:t.getAttribute("data-reactive-error"),busy:t.getAttribute("aria-busy")==="true"||t.hasAttribute("data-reactive-busy"),dirty:t.hasAttribute("data-reactive-dirty")}}var l='[data-controller~="reactive"]';function v(t,e){return t.closest(l)===e}function o(t,e,r){return Array.from(t.querySelectorAll(e)).filter((a)=>v(a,t))}function A(t,e){return o(t,"[data-reactive-action-param]",e).map((r)=>({action:r.getAttribute("data-reactive-action-param"),event:d(r.getAttribute("data-action")),params:r.getAttribute("data-reactive-params-param"),debounce:r.getAttribute("data-reactive-debounce-param"),throttle:r.getAttribute("data-reactive-throttle-param"),confirm:r.getAttribute("data-reactive-confirm-param"),optimistic:r.getAttribute("data-reactive-optimistic-param"),loading:r.getAttribute("data-reactive-loading-param")}))}function d(t){if(!t)return null;let e=t.indexOf("->");return e===-1?null:t.slice(0,e)}function y(t,e){return o(t,"[data-reactive-ops-param]",e).flatMap((r)=>{let a=r.getAttribute("data-reactive-ops-param"),n=d(r.getAttribute("data-action")),u=s(a);if(Array.isArray(u))return[{event:n,ops:u}];let c=(a??"").split(" ").map(s).filter((i)=>i&&typeof i==="object"&&!Array.isArray(i));if(c.length===0)return[{event:n,ops:a}];return c.map((i)=>({event:i.on?`${i.on}${i.window?"@window":""}`:n,ops:i.ops}))})}function s(t){try{return JSON.parse(t)}catch{return}}function w(t,e){return o(t,"[data-reactive-compute-reducer-param]",e).map((r)=>r.getAttribute("data-reactive-compute-reducer-param"))}function h(t,e){return o(t,"input[name], select[name], textarea[name]",e).map((r)=>r.getAttribute("name"))}function q(t,e){return o(t,"[data-reactive-show-field]",e).map((r)=>({field:r.getAttribute("data-reactive-show-field"),equals:r.getAttribute("data-reactive-show-equals"),not:r.getAttribute("data-reactive-show-not"),in:r.getAttribute("data-reactive-show-in")}))}function O(t,e){return o(t,"[data-reactive-text]",e).map((r)=>r.getAttribute("data-reactive-text"))}function x(t){let e=t.getAttribute("data-reactive-filter-input");if(!e)return null;return{input:e,option:t.getAttribute("data-reactive-filter-option"),group:t.getAttribute("data-reactive-filter-group"),empty:t.getAttribute("data-reactive-filter-empty")}}export{S as report,p as scan};
1
+ function m(t=document){let r=Array.from(t.querySelectorAll('[data-controller~="reactive"]'));return r.map((e)=>b(e,r))}function T(t=document){let r=m(t),e=globalThis.console;if(e&&typeof e.log==="function")e.log(`[phlex-reactive] ${r.length} reactive root(s)`);if(e&&typeof e.table==="function")e.table(r.map((a)=>({id:a.id,component:a.component??(a.opaque?"(opaque token)":null),actions:a.triggers.map((o)=>o.action).join(", "),fields:a.fields.join(", "),computes:a.computes.join(", ")})));return r}function b(t,r){let e=t.getAttribute("data-reactive-token-value"),a=g(e);return{id:t.id||null,component:a.opaque?null:a.c??null,gid:a.gid??null,stateKeys:a.s?Object.keys(a.s):[],tokenVersion:a.v??null,opaque:!!a.opaque,status:v(t),triggers:h(t,r),clientOps:w(t,r),computes:q(t,r),fields:O(t,r),show:x(t,r),text:j(t,r),filter:S(t)}}function g(t){if(!t)return{opaque:!0};try{let r=t.split("--")[0],e=l(r),a=JSON.parse(e),o=a&&a._rails&&a._rails.data?a._rails.data:a;if(!o||typeof o!=="object")return{opaque:!0};return o}catch{return{opaque:!0}}}function l(t){return(globalThis.atob||((e)=>Buffer.from(e,"base64").toString("binary")))(t)}function v(t){return{error:t.getAttribute("data-reactive-error"),busy:t.getAttribute("aria-busy")==="true"||t.hasAttribute("data-reactive-busy"),dirty:t.hasAttribute("data-reactive-dirty")}}var A='[data-controller~="reactive"]';function y(t,r){return t.closest(A)===r}function i(t,r,e){return Array.from(t.querySelectorAll(r)).filter((a)=>y(a,t))}function h(t,r){return i(t,"[data-reactive-action-param]",r).map((e)=>({action:e.getAttribute("data-reactive-action-param"),event:d(e.getAttribute("data-action")),params:e.getAttribute("data-reactive-params-param"),debounce:e.getAttribute("data-reactive-debounce-param"),throttle:e.getAttribute("data-reactive-throttle-param"),confirm:e.getAttribute("data-reactive-confirm-param"),optimistic:e.getAttribute("data-reactive-optimistic-param"),loading:e.getAttribute("data-reactive-loading-param"),shortcut:e.getAttribute("data-reactive-shortcut-param")}))}function d(t){if(!t)return null;let r=t.indexOf("->");return r===-1?null:t.slice(0,r)}function w(t,r){return i(t,"[data-reactive-ops-param]",r).flatMap((e)=>{let a=e.getAttribute("data-reactive-ops-param"),o=d(e.getAttribute("data-action")),u=s(a);if(Array.isArray(u))return[{event:o,ops:u}];let c=(a??"").split(" ").map(s).filter((n)=>n&&typeof n==="object"&&!Array.isArray(n));if(c.length===0)return[{event:o,ops:a}];let p=(n)=>typeof n==="object"?`${n.keys}${n.inEditable?" editable":""}`:n,f=(n)=>`${n.on}${n.window?"@window":""}${n.shortcut==null?"":` shortcut=${p(n.shortcut)}`}`;return c.map((n)=>({event:n.on?f(n):o,ops:n.ops}))})}function s(t){try{return JSON.parse(t)}catch{return}}function q(t,r){return i(t,"[data-reactive-compute-reducer-param]",r).map((e)=>e.getAttribute("data-reactive-compute-reducer-param"))}function O(t,r){return i(t,"input[name], select[name], textarea[name]",r).map((e)=>e.getAttribute("name"))}function x(t,r){return i(t,"[data-reactive-show-field]",r).map((e)=>({field:e.getAttribute("data-reactive-show-field"),equals:e.getAttribute("data-reactive-show-equals"),not:e.getAttribute("data-reactive-show-not"),in:e.getAttribute("data-reactive-show-in")}))}function j(t,r){return i(t,"[data-reactive-text]",r).map((e)=>e.getAttribute("data-reactive-text"))}function S(t){let r=t.getAttribute("data-reactive-filter-input");if(!r)return null;return{input:r,option:t.getAttribute("data-reactive-filter-option"),group:t.getAttribute("data-reactive-filter-group"),empty:t.getAttribute("data-reactive-filter-empty")}}export{T as report,m as scan};
2
2
 
3
- //# debugId=041E72D906D0F77464756E2164756E21
3
+ //# debugId=B4494A595CB8588264756E2164756E21
4
4
  //# sourceMappingURL=inspect.min.js.map