phlex-reactive 0.15.2 → 0.15.4

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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +269 -2
  3. data/README.md +92 -13
  4. data/app/javascript/phlex/reactive/core.js +1 -0
  5. data/app/javascript/phlex/reactive/core.min.js +6 -6
  6. data/app/javascript/phlex/reactive/core.min.js.map +4 -4
  7. data/app/javascript/phlex/reactive/features/bindings.js +153 -21
  8. data/app/javascript/phlex/reactive/features/bindings.min.js +2 -2
  9. data/app/javascript/phlex/reactive/features/bindings.min.js.map +3 -3
  10. data/app/javascript/phlex/reactive/features/countdown.js +140 -0
  11. data/app/javascript/phlex/reactive/features/countdown.min.js +4 -0
  12. data/app/javascript/phlex/reactive/features/countdown.min.js.map +10 -0
  13. data/app/javascript/phlex/reactive/features/form.js +54 -22
  14. data/app/javascript/phlex/reactive/features/form.min.js +2 -2
  15. data/app/javascript/phlex/reactive/features/form.min.js.map +3 -3
  16. data/app/javascript/phlex/reactive/features/persist.js +41 -8
  17. data/app/javascript/phlex/reactive/features/persist.min.js +2 -2
  18. data/app/javascript/phlex/reactive/features/persist.min.js.map +3 -3
  19. data/app/javascript/phlex/reactive/reactive_controller.js +2 -0
  20. data/app/javascript/phlex/reactive/reactive_controller.min.js +4 -4
  21. data/app/javascript/phlex/reactive/reactive_controller.min.js.map +9 -8
  22. data/app/javascript/phlex/reactive/runtime.js +313 -32
  23. data/lib/phlex/reactive/component/countdown_binding.rb +80 -0
  24. data/lib/phlex/reactive/component/field_references.rb +130 -0
  25. data/lib/phlex/reactive/component/helpers.rb +81 -19
  26. data/lib/phlex/reactive/component/identity.rb +34 -1
  27. data/lib/phlex/reactive/component/image_fallback_binding.rb +67 -0
  28. data/lib/phlex/reactive/component/media_binding.rb +30 -4
  29. data/lib/phlex/reactive/component/media_pattern.rb +79 -0
  30. data/lib/phlex/reactive/component/selection.rb +24 -2
  31. data/lib/phlex/reactive/component/show_options.rb +91 -0
  32. data/lib/phlex/reactive/doctor.rb +20 -8
  33. data/lib/phlex/reactive/engine.rb +2 -2
  34. data/lib/phlex/reactive/js.rb +58 -5
  35. data/lib/phlex/reactive/show_conditions/compare_terms.rb +142 -0
  36. data/lib/phlex/reactive/show_conditions.rb +39 -6
  37. data/lib/phlex/reactive/streamable.rb +6 -3
  38. data/lib/phlex/reactive/version.rb +1 -1
  39. metadata +10 -1
@@ -0,0 +1,140 @@
1
+ // phlex/reactive/features/countdown — a deadline counted down on the client
2
+ // (issue #400). One of the feature modules the core imports on demand (issue
3
+ // #275); in the default client it is part of the one file.
4
+ //
5
+ // The markup is the Ruby side's (reactive_countdown):
6
+ //
7
+ // data-reactive-countdown='{"until":"<ISO8601>","format":"ms","waiting":"#wait","done":"#done"}'
8
+ //
9
+ // Every tick reads Date.now against the deadline, so a late tick (a throttled
10
+ // background tab, a slept laptop) paints the real remaining time, never a
11
+ // count of missed ticks; a tab that becomes visible again re-reads at once.
12
+ // The text is unpadded minutes, a colon and zero-padded seconds ("1:30"),
13
+ // written as textContent only. The next tick is set for the moment the shown
14
+ // second changes.
15
+ //
16
+ // At zero (or with the deadline already past on connect, which starts no
17
+ // timer) the text is "0:00", data-reactive-countdown-state is "done", the
18
+ // waiting: node hides, the done: node shows, and the element dispatches
19
+ // reactive:countdown-done (bubbling) once. "Once" is per root id (a root
20
+ // without one is its own element), countdown (its id, else its place in the
21
+ // root) and deadline, kept for the life of the document: a morph or a replace
22
+ // that keeps the deadline, or brings back one that already fired, paints the
23
+ // done state again without a second event; a new deadline counts down afresh.
24
+ // The countdown's content is its text: waiting: and done: are nodes beside it,
25
+ // so a root that is itself the countdown has none.
26
+ // No request, no frame reload: the page decides what the event does.
27
+ //
28
+ // This module never imports the core: it reaches a controller through the
29
+ // `core` handle (core.owns).
30
+
31
+ const MARK = "data-reactive-countdown"
32
+ const STATE = "data-reactive-countdown-state"
33
+
34
+ // "<root id>|<countdown key>" -> every deadline it already dispatched for.
35
+ const finished = new Map()
36
+ // A root without an id: a number of its own, so two never share a key.
37
+ const anonymous = new WeakMap()
38
+ let anonymousSeq = 0
39
+ // controller -> the removal of what connect() added.
40
+ const wired = new WeakMap()
41
+
42
+ export function connect(controller, core) {
43
+ const root = controller.element
44
+ if (!root.id && !anonymous.has(root)) anonymous.set(root, `#${++anonymousSeq}`)
45
+ const rootKey = root.id || anonymous.get(root)
46
+ // countdown element -> its pending timer.
47
+ const timers = new Map()
48
+ let queued = false
49
+
50
+ const clear = (el) => {
51
+ clearTimeout(timers.get(el))
52
+ timers.delete(el)
53
+ }
54
+
55
+ const tick = (el, wire, key) => {
56
+ clear(el)
57
+ const left = Date.parse(wire.until) - Date.now()
58
+ if (!(left > 0)) return finish(el, wire, key)
59
+ const seconds = Math.ceil(left / 1000)
60
+ paint(el, `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`, "running")
61
+ show(wire.waiting, true)
62
+ show(wire.done, false)
63
+ timers.set(
64
+ el,
65
+ setTimeout(() => tick(el, wire, key), ((left - 1) % 1000) + 1),
66
+ )
67
+ }
68
+
69
+ const finish = (el, wire, key) => {
70
+ paint(el, "0:00", "done")
71
+ show(wire.waiting, false)
72
+ show(wire.done, true)
73
+ const fired = finished.get(key) ?? new Set()
74
+ if (fired.has(wire.until)) return
75
+ finished.set(key, fired.add(wire.until))
76
+ el.dispatchEvent(new CustomEvent("reactive:countdown-done", { bubbles: true, detail: { until: wire.until } }))
77
+ }
78
+
79
+ // A selector that is not valid CSS is ignored, never a stopped clock.
80
+ const show = (selector, visible) => {
81
+ let node
82
+ try {
83
+ node = selector && root.querySelector(selector)
84
+ } catch {}
85
+ if (node && node.hidden === visible) node.hidden = !visible
86
+ }
87
+
88
+ // Every countdown this root owns, from scratch: a morph may have changed a
89
+ // deadline, written the server's text back, or removed a countdown.
90
+ const sync = () => {
91
+ queued = false
92
+ // A morph that also removed the root: disconnect ran first.
93
+ if (!wired.has(controller)) return
94
+ const seen = new Set()
95
+ const els = [...(root.matches(`[${MARK}]`) ? [root] : []), ...root.querySelectorAll(`[${MARK}]`)]
96
+ els.forEach((el, index) => {
97
+ if (!core.owns(el)) return
98
+ let wire
99
+ try {
100
+ wire = JSON.parse(el.getAttribute(MARK))
101
+ } catch {
102
+ return
103
+ }
104
+ if (Number.isNaN(Date.parse(wire?.until))) return
105
+ seen.add(el)
106
+ tick(el, wire, `${rootKey}|${el.id || index}`)
107
+ })
108
+ for (const el of timers.keys()) if (!seen.has(el)) clear(el)
109
+ }
110
+
111
+ // A morph of the root or of anything in it (turbo:morph-element bubbles,
112
+ // once per morphed element), a stream rendered into it, a tab woken up:
113
+ // one sync for all of them, after the morph is done.
114
+ const later = () => {
115
+ if (queued) return
116
+ queued = true
117
+ queueMicrotask(sync)
118
+ }
119
+ const woke = () => document.visibilityState === "visible" && sync()
120
+ root.addEventListener("turbo:morph-element", later)
121
+ root.addEventListener("reactive:streamed", later)
122
+ document.addEventListener("visibilitychange", woke)
123
+ wired.set(controller, () => {
124
+ root.removeEventListener("turbo:morph-element", later)
125
+ root.removeEventListener("reactive:streamed", later)
126
+ document.removeEventListener("visibilitychange", woke)
127
+ for (const el of [...timers.keys()]) clear(el)
128
+ })
129
+ sync()
130
+ }
131
+
132
+ export function disconnect(controller) {
133
+ wired.get(controller)?.()
134
+ wired.delete(controller)
135
+ }
136
+
137
+ function paint(el, text, state) {
138
+ if (el.textContent !== text) el.textContent = text
139
+ if (el.getAttribute(STATE) !== state) el.setAttribute(STATE, state)
140
+ }
@@ -0,0 +1,4 @@
1
+ var g=new Map,l=new WeakMap,A=0,f=new WeakMap;function M(s,c){let n=s.element;if(!n.id&&!l.has(n))l.set(n,`#${++A}`);let S=n.id||l.get(n),r=new Map,m=!1,h=(t)=>{clearTimeout(r.get(t)),r.delete(t)},p=(t,o,e)=>{h(t);let i=Date.parse(o.until)-Date.now();if(!(i>0))return y(t,o,e);let a=Math.ceil(i/1000);E(t,`${Math.floor(a/60)}:${String(a%60).padStart(2,"0")}`,"running"),u(o.waiting,!0),u(o.done,!1),r.set(t,setTimeout(()=>p(t,o,e),(i-1)%1000+1))},y=(t,o,e)=>{E(t,"0:00","done"),u(o.waiting,!1),u(o.done,!0);let i=g.get(e)??new Set;if(i.has(o.until))return;g.set(e,i.add(o.until)),t.dispatchEvent(new CustomEvent("reactive:countdown-done",{bubbles:!0,detail:{until:o.until}}))},u=(t,o)=>{let e;try{e=t&&n.querySelector(t)}catch{}if(e&&e.hidden===o)e.hidden=!o},v=()=>{if(m=!1,!f.has(s))return;let t=new Set;[...n.matches("[data-reactive-countdown]")?[n]:[],...n.querySelectorAll("[data-reactive-countdown]")].forEach((e,i)=>{if(!c.owns(e))return;let a;try{a=JSON.parse(e.getAttribute("data-reactive-countdown"))}catch{return}if(Number.isNaN(Date.parse(a?.until)))return;t.add(e),p(e,a,`${S}|${e.id||i}`)});for(let e of r.keys())if(!t.has(e))h(e)},d=()=>{if(m)return;m=!0,queueMicrotask(v)},b=()=>document.visibilityState==="visible"&&v();n.addEventListener("turbo:morph-element",d),n.addEventListener("reactive:streamed",d),document.addEventListener("visibilitychange",b),f.set(s,()=>{n.removeEventListener("turbo:morph-element",d),n.removeEventListener("reactive:streamed",d),document.removeEventListener("visibilitychange",b);for(let t of[...r.keys()])h(t)}),v()}function w(s){f.get(s)?.(),f.delete(s)}function E(s,c,n){if(s.textContent!==c)s.textContent=c;if(s.getAttribute("data-reactive-countdown-state")!==n)s.setAttribute("data-reactive-countdown-state",n)}export{M as connect,w as disconnect};
2
+
3
+ //# debugId=BEA93E2782D7855864756E2164756E21
4
+ //# sourceMappingURL=countdown.min.js.map
@@ -0,0 +1,10 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["features/countdown.js"],
4
+ "sourcesContent": [
5
+ "// phlex/reactive/features/countdown — a deadline counted down on the client\n// (issue #400). 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 markup is the Ruby side's (reactive_countdown):\n//\n// data-reactive-countdown='{\"until\":\"<ISO8601>\",\"format\":\"ms\",\"waiting\":\"#wait\",\"done\":\"#done\"}'\n//\n// Every tick reads Date.now against the deadline, so a late tick (a throttled\n// background tab, a slept laptop) paints the real remaining time, never a\n// count of missed ticks; a tab that becomes visible again re-reads at once.\n// The text is unpadded minutes, a colon and zero-padded seconds (\"1:30\"),\n// written as textContent only. The next tick is set for the moment the shown\n// second changes.\n//\n// At zero (or with the deadline already past on connect, which starts no\n// timer) the text is \"0:00\", data-reactive-countdown-state is \"done\", the\n// waiting: node hides, the done: node shows, and the element dispatches\n// reactive:countdown-done (bubbling) once. \"Once\" is per root id (a root\n// without one is its own element), countdown (its id, else its place in the\n// root) and deadline, kept for the life of the document: a morph or a replace\n// that keeps the deadline, or brings back one that already fired, paints the\n// done state again without a second event; a new deadline counts down afresh.\n// The countdown's content is its text: waiting: and done: are nodes beside it,\n// so a root that is itself the countdown has none.\n// No request, no frame reload: the page decides what the event does.\n//\n// This module never imports the core: it reaches a controller through the\n// `core` handle (core.owns).\n\nconst MARK = \"data-reactive-countdown\"\nconst STATE = \"data-reactive-countdown-state\"\n\n// \"<root id>|<countdown key>\" -> every deadline it already dispatched for.\nconst finished = new Map()\n// A root without an id: a number of its own, so two never share a key.\nconst anonymous = new WeakMap()\nlet anonymousSeq = 0\n// controller -> the removal of what connect() added.\nconst wired = new WeakMap()\n\nexport function connect(controller, core) {\n const root = controller.element\n if (!root.id && !anonymous.has(root)) anonymous.set(root, `#${++anonymousSeq}`)\n const rootKey = root.id || anonymous.get(root)\n // countdown element -> its pending timer.\n const timers = new Map()\n let queued = false\n\n const clear = (el) => {\n clearTimeout(timers.get(el))\n timers.delete(el)\n }\n\n const tick = (el, wire, key) => {\n clear(el)\n const left = Date.parse(wire.until) - Date.now()\n if (!(left > 0)) return finish(el, wire, key)\n const seconds = Math.ceil(left / 1000)\n paint(el, `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, \"0\")}`, \"running\")\n show(wire.waiting, true)\n show(wire.done, false)\n timers.set(\n el,\n setTimeout(() => tick(el, wire, key), ((left - 1) % 1000) + 1),\n )\n }\n\n const finish = (el, wire, key) => {\n paint(el, \"0:00\", \"done\")\n show(wire.waiting, false)\n show(wire.done, true)\n const fired = finished.get(key) ?? new Set()\n if (fired.has(wire.until)) return\n finished.set(key, fired.add(wire.until))\n el.dispatchEvent(new CustomEvent(\"reactive:countdown-done\", { bubbles: true, detail: { until: wire.until } }))\n }\n\n // A selector that is not valid CSS is ignored, never a stopped clock.\n const show = (selector, visible) => {\n let node\n try {\n node = selector && root.querySelector(selector)\n } catch {}\n if (node && node.hidden === visible) node.hidden = !visible\n }\n\n // Every countdown this root owns, from scratch: a morph may have changed a\n // deadline, written the server's text back, or removed a countdown.\n const sync = () => {\n queued = false\n // A morph that also removed the root: disconnect ran first.\n if (!wired.has(controller)) return\n const seen = new Set()\n const els = [...(root.matches(`[${MARK}]`) ? [root] : []), ...root.querySelectorAll(`[${MARK}]`)]\n els.forEach((el, index) => {\n if (!core.owns(el)) return\n let wire\n try {\n wire = JSON.parse(el.getAttribute(MARK))\n } catch {\n return\n }\n if (Number.isNaN(Date.parse(wire?.until))) return\n seen.add(el)\n tick(el, wire, `${rootKey}|${el.id || index}`)\n })\n for (const el of timers.keys()) if (!seen.has(el)) clear(el)\n }\n\n // A morph of the root or of anything in it (turbo:morph-element bubbles,\n // once per morphed element), a stream rendered into it, a tab woken up:\n // one sync for all of them, after the morph is done.\n const later = () => {\n if (queued) return\n queued = true\n queueMicrotask(sync)\n }\n const woke = () => document.visibilityState === \"visible\" && sync()\n root.addEventListener(\"turbo:morph-element\", later)\n root.addEventListener(\"reactive:streamed\", later)\n document.addEventListener(\"visibilitychange\", woke)\n wired.set(controller, () => {\n root.removeEventListener(\"turbo:morph-element\", later)\n root.removeEventListener(\"reactive:streamed\", later)\n document.removeEventListener(\"visibilitychange\", woke)\n for (const el of [...timers.keys()]) clear(el)\n })\n sync()\n}\n\nexport function disconnect(controller) {\n wired.get(controller)?.()\n wired.delete(controller)\n}\n\nfunction paint(el, text, state) {\n if (el.textContent !== text) el.textContent = text\n if (el.getAttribute(STATE) !== state) el.setAttribute(STATE, state)\n}\n"
6
+ ],
7
+ "mappings": "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",
8
+ "debugId": "BEA93E2782D7855864756E2164756E21",
9
+ "names": []
10
+ }
@@ -17,6 +17,8 @@ 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).
19
19
  const askedVisits = new WeakSet()
20
+ // window -> its guarded roots and the removal of its one listener pair.
21
+ const guards = new WeakMap()
20
22
 
21
23
  export function connect(controller, core) {
22
24
  const root = controller.element
@@ -55,7 +57,7 @@ export function connect(controller, core) {
55
57
  // beforeunload covers a real browser unload; turbo:before-visit covers a
56
58
  // Turbo in-app navigation (it does NOT fire on restoration visits — the
57
59
  // documented gap). Registered on window only when the marker is present.
58
- if (root.getAttribute?.("data-reactive-warn-unsaved") === "true") armUnsavedGuard(root, listen)
60
+ if (root.getAttribute?.("data-reactive-warn-unsaved") === "true") armUnsavedGuard(root, offs)
59
61
  }
60
62
 
61
63
  // Clipboard-trigger availability gate (issue #228) — ONLY when this root
@@ -193,29 +195,59 @@ function dirtyCount(root) {
193
195
  // Arm the navigate-away guard (warn_unsaved: true, issue #103). beforeunload
194
196
  // blocks a real browser unload; turbo:before-visit blocks a Turbo in-app
195
197
  // navigation (it does NOT fire on restoration visits — the documented gap).
196
- // Both read the LIVE dirty count, so a clean form never blocks. Added through
197
- // connect()'s `listen`, so disconnect() removes exactly them.
198
- function armUnsavedGuard(root, listen) {
198
+ // Both read the LIVE dirty count, so a clean form never blocks.
199
+ //
200
+ // One listener pair per window serves every guarded root on it: a pair per
201
+ // root made connecting and disconnecting N guarded forms quadratic (the
202
+ // browser scans a target's listeners on each add and remove). The pair is
203
+ // added with the first guarded root and removed with the last, so a page
204
+ // without one carries no beforeunload listener. The root leaves the set
205
+ // through `offs`, which disconnect() runs.
206
+ function armUnsavedGuard(root, offs) {
199
207
  if (typeof window === "undefined" || typeof window.addEventListener !== "function") return
200
208
 
201
- listen(window, "beforeunload", (event) => {
202
- if (dirtyCount(root) === 0) return undefined
203
- // The spec dance: preventDefault + a truthy returnValue triggers the native
204
- // "leave site?" prompt. The string is legacy (modern browsers show their own
205
- // copy) but must be non-empty/truthy to arm the dialog.
206
- event.preventDefault()
207
- event.returnValue = "You have unsaved changes."
208
- return event.returnValue
209
- })
210
- // Every warn_unsaved root arms its own turbo:before-visit handler, and one
211
- // visit is one event dispatched to all of them: the first DIRTY root asks,
212
- // and the rest see the event already asked (issue #298). A clean root never
213
- // claims the event, so any dirty root on the page can still veto the visit.
214
- listen(window, "turbo:before-visit", (event) => {
215
- if (dirtyCount(root) === 0 || askedVisits.has(event)) return
216
- askedVisits.add(event)
217
- const ok = typeof window.confirm === "function" ? window.confirm("You have unsaved changes. Leave anyway?") : true
218
- if (!ok) event.preventDefault?.()
209
+ const win = window
210
+ let guard = guards.get(win)
211
+ if (!guard) {
212
+ const roots = new Set()
213
+ const dirtyRoot = () => {
214
+ for (const guarded of roots) if (dirtyCount(guarded) > 0) return guarded
215
+ return null
216
+ }
217
+ const beforeunload = (event) => {
218
+ if (!dirtyRoot()) return undefined
219
+ // The spec dance: preventDefault + a truthy returnValue triggers the native
220
+ // "leave site?" prompt. The string is legacy (modern browsers show their own
221
+ // copy) but must be non-empty/truthy to arm the dialog.
222
+ event.preventDefault()
223
+ event.returnValue = "You have unsaved changes."
224
+ return event.returnValue
225
+ }
226
+ // One visit asks once (issue #298), and only when a guarded root is dirty.
227
+ // askedVisits still matters when two copies of this module (a bundled one
228
+ // beside the pinned one) each hold a pair.
229
+ const beforeVisit = (event) => {
230
+ if (askedVisits.has(event) || !dirtyRoot()) return
231
+ askedVisits.add(event)
232
+ const ok = typeof win.confirm === "function" ? win.confirm("You have unsaved changes. Leave anyway?") : true
233
+ if (!ok) event.preventDefault?.()
234
+ }
235
+ win.addEventListener("beforeunload", beforeunload)
236
+ win.addEventListener("turbo:before-visit", beforeVisit)
237
+ guard = {
238
+ roots,
239
+ remove: () => {
240
+ win.removeEventListener?.("beforeunload", beforeunload)
241
+ win.removeEventListener?.("turbo:before-visit", beforeVisit)
242
+ guards.delete(win)
243
+ },
244
+ }
245
+ guards.set(win, guard)
246
+ }
247
+ guard.roots.add(root)
248
+ offs.push(() => {
249
+ guard.roots.delete(root)
250
+ if (guard.roots.size === 0) guard.remove()
219
251
  })
220
252
  }
221
253
 
@@ -1,4 +1,4 @@
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};
1
+ var d=new WeakMap,p=new WeakSet,f=new WeakMap;function k(e,r){let t=e.element,n=[],o=[],a=(s,l,v)=>{s.addEventListener?.(l,v),n.push(()=>s.removeEventListener?.(l,v))},u,c,i=()=>{if(!u&&(u=b(t,r))){let s=()=>y(e,r);if(a(t,"turbo:morph-element",s),o.push(s),s(),t.getAttribute?.("data-reactive-warn-unsaved")==="true")w(t,n)}if(!c&&(c=h(t,r))){let s=()=>A(t,r);a(t,"turbo:morph-element",s),o.push(s),s()}};d.set(e,[n,()=>{for(let s of o)s();i()}]),i()}function E(e){for(let r of d.get(e)?.[0]??[])r();d.delete(e)}function S(e){d.get(e)?.[1]()}function b(e,r){if((e.getAttribute?.("data-action")??"").includes("reactive#trackDirty"))return!0;let t=e.querySelectorAll?.('[data-action*="reactive#trackDirty"]')??[];for(let n of t)if(r.owns(n))return!0;return!1}function y(e,r){let t=e.element;if(typeof t?.querySelectorAll!=="function")return;let n=0,o=(a,u)=>{if(u)a.setAttribute("data-reactive-dirty","true"),n++;else a.removeAttribute("data-reactive-dirty")};if(t.querySelectorAll("input[name], select[name], textarea[name]").forEach((a)=>{if(!r.owns(a))return;if(a.type==="file")return;o(a,m(a))}),t.querySelectorAll("[data-reactive-editable][name]").forEach((a)=>{if(r.owns(a))o(a,r.text(a)!==r.served(a))}),n>0)t.setAttribute("data-reactive-dirty",String(n));else t.removeAttribute("data-reactive-dirty")}function m(e){if(e.type==="checkbox"||e.type==="radio")return e.checked!==e.defaultChecked;if(e.tag==="select"||e.options){let r=Array.from(e.options??[]),t=!e.multiple&&!(e.size>1),n=r.filter((o)=>o.defaultSelected).pop()??r.find((o)=>!o.closest?.("optgroup")?.disabled&&!o.disabled);return r.some((o)=>o.selected!==(t?o===n:o.defaultSelected))}return e.value!==e.defaultValue}function g(e){let r=e.getAttribute?.("data-reactive-dirty"),t=Number(r);return Number.isFinite(t)&&t>0?t:0}function w(e,r){if(typeof window>"u"||typeof window.addEventListener!=="function")return;let t=window,n=f.get(t);if(!n){let o=new Set,a=()=>{for(let i of o)if(g(i)>0)return i;return null},u=(i)=>{if(!a())return;return i.preventDefault(),i.returnValue="You have unsaved changes.",i.returnValue},c=(i)=>{if(p.has(i)||!a())return;if(p.add(i),!(typeof t.confirm==="function"?t.confirm("You have unsaved changes. Leave anyway?"):!0))i.preventDefault?.()};t.addEventListener("beforeunload",u),t.addEventListener("turbo:before-visit",c),n={roots:o,remove:()=>{t.removeEventListener?.("beforeunload",u),t.removeEventListener?.("turbo:before-visit",c),f.delete(t)}},f.set(t,n)}n.roots.add(e),r.push(()=>{if(n.roots.delete(e),n.roots.size===0)n.remove()})}function h(e,r){if(e.getAttribute?.("data-reactive-clipboard"))return!0;let t=e.querySelectorAll?.("[data-reactive-clipboard]")??[];for(let n of t)if(r.owns(n))return!0;return!1}function A(e,r){let t=typeof globalThis.navigator?.clipboard?.readText==="function";if(e.getAttribute?.("data-reactive-clipboard"))e.hidden=!t;for(let n of e.querySelectorAll?.("[data-reactive-clipboard]")??[])if(r.owns(n))n.hidden=!t}export{k as connect,E as disconnect,S as reseed,y as scan};
2
2
 
3
- //# debugId=E49EE6DCC948366F64756E2164756E21
3
+ //# debugId=2DE40E9CEBCE1CEE64756E2164756E21
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, 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"
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// window -> its guarded roots and the removal of its one listener pair.\nconst guards = new WeakMap()\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, offs)\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.\n//\n// One listener pair per window serves every guarded root on it: a pair per\n// root made connecting and disconnecting N guarded forms quadratic (the\n// browser scans a target's listeners on each add and remove). The pair is\n// added with the first guarded root and removed with the last, so a page\n// without one carries no beforeunload listener. The root leaves the set\n// through `offs`, which disconnect() runs.\nfunction armUnsavedGuard(root, offs) {\n if (typeof window === \"undefined\" || typeof window.addEventListener !== \"function\") return\n\n const win = window\n let guard = guards.get(win)\n if (!guard) {\n const roots = new Set()\n const dirtyRoot = () => {\n for (const guarded of roots) if (dirtyCount(guarded) > 0) return guarded\n return null\n }\n const beforeunload = (event) => {\n if (!dirtyRoot()) 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 // One visit asks once (issue #298), and only when a guarded root is dirty.\n // askedVisits still matters when two copies of this module (a bundled one\n // beside the pinned one) each hold a pair.\n const beforeVisit = (event) => {\n if (askedVisits.has(event) || !dirtyRoot()) return\n askedVisits.add(event)\n const ok = typeof win.confirm === \"function\" ? win.confirm(\"You have unsaved changes. Leave anyway?\") : true\n if (!ok) event.preventDefault?.()\n }\n win.addEventListener(\"beforeunload\", beforeunload)\n win.addEventListener(\"turbo:before-visit\", beforeVisit)\n guard = {\n roots,\n remove: () => {\n win.removeEventListener?.(\"beforeunload\", beforeunload)\n win.removeEventListener?.(\"turbo:before-visit\", beforeVisit)\n guards.delete(win)\n },\n }\n guards.set(win, guard)\n }\n guard.roots.add(root)\n offs.push(() => {\n guard.roots.delete(root)\n if (guard.roots.size === 0) guard.remove()\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": "E49EE6DCC948366F64756E2164756E21",
7
+ "mappings": "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",
8
+ "debugId": "2DE40E9CEBCE1CEE64756E2164756E21",
9
9
  "names": []
10
10
  }
@@ -4,8 +4,8 @@
4
4
  // persist_clear client op runs.
5
5
  //
6
6
  // A root that declares
7
- // data-reactive-persist='{"key","ttl","debounce"[,"fields"][,"restore"]}'
8
- // keeps a localStorage draft of every persistable OWNED control (write on
7
+ // data-reactive-persist='{"key","ttl","debounce"[,"fields"][,"restore"][,"storage"]}'
8
+ // keeps a localStorage (or, with "storage":"session", sessionStorage) draft of every persistable OWNED control (write on
9
9
  // input/change, restore on connect, clear on a successful submit / TTL / the
10
10
  // persist_clear op). The storage layer is MODULE-LEVEL and keyed on the root
11
11
  // element — the per-root connect/write path and the persist_state /
@@ -105,8 +105,12 @@ function persistPayload(root) {
105
105
  return null
106
106
  }
107
107
 
108
- function persistStorage() {
108
+ // The store the root selected: sessionStorage for {"storage":"session"}
109
+ // (issue #397 — this tab only), else localStorage. Never the other one: a
110
+ // missing sessionStorage is "no draft", not a fallback that outlives the tab.
111
+ function persistStorage(payload) {
109
112
  try {
113
+ if (payload.storage === "session") return typeof sessionStorage === "undefined" ? null : sessionStorage
110
114
  return typeof localStorage === "undefined" ? null : localStorage
111
115
  } catch {
112
116
  return null // the accessor itself can throw (blocked site data)
@@ -129,7 +133,7 @@ function persistKeyFor(payload) {
129
133
  // version, or expired (an expired draft is REMOVED on read). Returns
130
134
  // { fields, state } with state null when the draft carries none.
131
135
  function persistRead(root, payload) {
132
- const store = persistStorage()
136
+ const store = persistStorage(payload)
133
137
  if (!store) return null
134
138
  let raw
135
139
  try {
@@ -157,7 +161,7 @@ function persistRead(root, payload) {
157
161
  }
158
162
 
159
163
  function persistWrite(root, payload, { fields, state }) {
160
- const store = persistStorage()
164
+ const store = persistStorage(payload)
161
165
  if (!store) return false
162
166
  const draft = { v: PERSIST_VERSION, savedAt: Date.now(), fields }
163
167
  if (state) draft.state = state
@@ -172,7 +176,7 @@ function persistWrite(root, payload, { fields, state }) {
172
176
 
173
177
  function persistRemove(root, payload) {
174
178
  root?.removeAttribute?.(PERSIST_STATE_ATTR)
175
- const store = persistStorage()
179
+ const store = persistStorage(payload)
176
180
  if (!store) return
177
181
  try {
178
182
  store.removeItem(persistKeyFor(payload))
@@ -508,6 +512,35 @@ function persistClearRoot(root) {
508
512
  // record's fields: the minifier renames a local, never a property (#310).
509
513
  const wired = new WeakMap()
510
514
 
515
+ // document -> the submit-end handlers of its persisted roots. One document
516
+ // listener serves them all: a listener per root made connecting and
517
+ // disconnecting N persisted roots quadratic (the browser scans a target's
518
+ // listeners on each add and remove). Added with the first root, removed with
519
+ // the last. Returns the handler's removal.
520
+ const submitEnds = new WeakMap()
521
+
522
+ function onSubmitEndOf(doc, handler) {
523
+ if (typeof doc?.addEventListener !== "function") return () => {}
524
+ let shared = submitEnds.get(doc)
525
+ if (!shared) {
526
+ const handlers = new Set()
527
+ const listener = (event) => {
528
+ for (const run of handlers) run(event)
529
+ }
530
+ doc.addEventListener("turbo:submit-end", listener)
531
+ shared = [handlers, listener]
532
+ submitEnds.set(doc, shared)
533
+ }
534
+ const [handlers, listener] = shared
535
+ handlers.add(handler)
536
+ return () => {
537
+ handlers.delete(handler)
538
+ if (handlers.size > 0) return
539
+ doc.removeEventListener?.("turbo:submit-end", listener)
540
+ submitEnds.delete(doc)
541
+ }
542
+ }
543
+
511
544
  // Restore the draft into the owned controls, expose the state bag, announce,
512
545
  // then arm the write listeners. The restore reads ONCE and never writes back:
513
546
  // nothing is wired until it has completed, so no listener can clobber the
@@ -578,12 +611,12 @@ export function connect(controller, core, _morphed, pending) {
578
611
  const inputs = ["input", ...PERSIST_EDITOR_CHANGE_EVENTS]
579
612
  for (const event of inputs) root.addEventListener?.(event, onInput)
580
613
  root.addEventListener?.("change", writeNow)
581
- document.addEventListener?.("turbo:submit-end", onSubmitEnd)
614
+ const offSubmitEnd = onSubmitEndOf(globalThis.document, onSubmitEnd)
582
615
  wired.set(controller, () => {
583
616
  if (timer !== null) writeNow()
584
617
  for (const event of inputs) root.removeEventListener?.(event, onInput)
585
618
  root.removeEventListener?.("change", writeNow)
586
- document.removeEventListener?.("turbo:submit-end", onSubmitEnd)
619
+ offSubmitEnd()
587
620
  })
588
621
 
589
622
  // An edit made while we were on our way had no listener to draft it: draft
@@ -1,4 +1,4 @@
1
- var m;function te(e){m=e}var C=1,F="phlex-reactive:persist:",V=new Set(["hidden","file","password","submit","button","reset","image"]),A=Symbol("persist-no-value"),w="data-reactive-persist-state",z=":is(lexxy-editor, trix-editor, [contenteditable=''], [contenteditable=true], [contenteditable=plaintext-only])",G=new Set(["lexxy-editor","trix-editor"]),q="lexxy-editor, trix-editor, trix-toolbar",H=["lexxy:change","trix-change"],B=new Set(["","<p></p>","<p><br></p>","<div><br></div>"]),O=new WeakSet,I=new WeakSet,P=new WeakSet;function S(e){let i=e?.getAttribute?.("data-reactive-persist");if(!i||i==="off")return null;try{let r=JSON.parse(i);if(r&&typeof r==="object"&&typeof r.key==="string"&&r.key!=="")return r}catch{}if(!O.has(e))O.add(e),console.warn(`[phlex-reactive] malformed reactive_persist payload ${JSON.stringify(i)} — persistence disabled`);return null}function k(){try{return typeof localStorage>"u"?null:localStorage}catch{return null}}function T(e,i){if(e?.getAttribute?.("data-reactive-debug")!=="true"||I.has(e))return;I.add(e),console.info(`[phlex-reactive] reactive_persist: storage unavailable — draft skipped (${i?.name??i})`)}function N(e){return F+e.key}function b(e,i){let r=k();if(!r)return null;let n;try{n=r.getItem(N(i))}catch(u){return T(e,u),null}if(!n)return null;let t;try{t=JSON.parse(n)}catch{return null}if(!t||typeof t!=="object"||t.v!==C)return null;let s=Number(i.ttl)*1000;if(!(Number(t.savedAt)+s>Date.now()))return h(e,i),null;let c=t.fields&&typeof t.fields==="object"?t.fields:{},o=t.state&&typeof t.state==="object"?t.state:null;return{fields:c,state:o}}function R(e,i,{fields:r,state:n}){let t=k();if(!t)return!1;let s={v:C,savedAt:Date.now(),fields:r};if(n)s.state=n;try{return t.setItem(N(i),JSON.stringify(s)),!0}catch(c){return T(e,c),!1}}function h(e,i){e?.removeAttribute?.(w);let r=k();if(!r)return;try{r.removeItem(N(i))}catch(n){T(e,n)}}function v(e,i){let r=Array.isArray(i.fields)?new Set(i.fields):null,n=[],t=(s)=>s.closest('[data-controller~="reactive"]')===e&&s.getAttribute("data-reactive-persist")!=="off";for(let s of e.querySelectorAll("input[name], select[name], textarea[name]")){if(!t(s)||V.has(s.type)||s.closest(q))continue;if(r&&!r.has(s.name))continue;n.push({el:s,name:s.name,kind:"native"})}for(let s of e.querySelectorAll(z)){if(!t(s))continue;let c=U(s);if(!c||r&&!r.has(c))continue;n.push({el:s,name:c,kind:G.has(s.localName)?"editor":"contenteditable"})}return n}function D(e){return e.hasAttribute("data-reactive-editable")?m.text(e):e.textContent??""}function U(e){return e.getAttribute("name")||typeof e.name==="string"&&e.name||null}function g(e){return typeof e.value==="string"}function L(e){if(typeof e.isEmpty==="boolean")return e.isEmpty;let i=e.editor?.getDocument?.();if(typeof i?.isEmpty==="function")return i.isEmpty();return B.has(e.value.trim())}function X(e,i,r){if(e?.getAttribute?.("data-reactive-debug")!=="true"||P.has(e))return;P.add(e),console.info(`[phlex-reactive] reactive_persist: could not restore editor ${JSON.stringify(i)} — ${r?.message??r}`)}function y(e){return e.tagName==="SELECT"&&e.multiple}function W(e){let i=new Map;for(let{el:r,name:n}of e){if(r.type==="radio"||!String(n).endsWith("[]"))continue;i.set(n,(i.get(n)??0)+1)}return i}function j(e,i,r){if(!Array.isArray(e))return e;if(r.get(i)!==1||e.length!==1)return A;return e[0]}function _(e,i){let r={},n=(t)=>{let s=r[t];return Array.isArray(s)?s:r[t]=[]};for(let{el:t,name:s,kind:c}of v(e,i)){let o=String(s).endsWith("[]");if(c==="editor"){if(g(t))if(o)n(s).push(t.value);else r[s]=t.value}else if(c==="contenteditable"){let u=D(t);if(o)n(s).push(u);else r[s]=u}else if(t.type==="radio"){if(t.checked)r[s]=t.value;else if(!Object.hasOwn(r,s))r[s]=null}else if(t.type==="checkbox")if(o){let u=n(s);if(t.checked)u.push(t.value)}else r[s]=t.checked;else if(y(t)){let u=[...t.options].filter((l)=>l.selected).map((l)=>l.value);if(o)n(s).push(...u);else r[s]=u}else if(o)n(s).push(t.value);else r[s]=t.value}return r}function Y(e,i,r){let n=i.restore==="always",t=v(e,i),s=W(t),c=new Set;for(let o of t)if(o.kind==="native"&&o.el.type==="checkbox"&&o.el.checked)c.add(o.name);for(let{el:o,name:u,kind:l}of t){if(!Object.hasOwn(r,u))continue;let a=r[u];if(a===null||a===void 0)continue;if(Array.isArray(a)&&y(o)&&(s.get(u)??0)>1)continue;if(Array.isArray(a)&&!(o.type==="checkbox"||y(o))){if(a=j(a,u,s),a===A)continue}if((o.type==="checkbox"||y(o))&&String(u).endsWith("[]")&&!Array.isArray(a))continue;if(l==="editor")M(e,o,u,a,n);else if(l==="contenteditable"){if(!n&&D(o).trim()!=="")continue;o.textContent=String(a)}else if(o.type==="radio"){if(!n&&t.some((p)=>p.kind==="native"&&p.el.type==="radio"&&p.name===u&&p.el.checked))continue;o.checked=o.value===String(a)}else if(o.type==="checkbox"&&Array.isArray(a)){if(!n&&c.has(u))continue;o.checked=a.map(String).includes(o.value)}else if(o.type==="checkbox"){if(!n&&o.checked)continue;o.checked=Boolean(a)}else if(y(o)){if(!n&&[...o.options].some((d)=>d.selected))continue;let p=new Set((Array.isArray(a)?a:[a]).map(String));for(let d of o.options)d.selected=p.has(d.value)}else{if(!n&&o.value!=="")continue;o.value=String(a)}}K(e,i,r)}function M(e,i,r,n,t){if(Array.isArray(n))return;if(!g(i))return;if(!t&&!L(i))return;try{i.value=String(n)}catch(s){X(e,r,s)}}function K(e,i,r){let n=globalThis.customElements;if(typeof n?.whenDefined!=="function")return;let t=new Set;for(let c of e.querySelectorAll("lexxy-editor, trix-editor"))if(!g(c)&&!n.get?.(c.localName))t.add(c.localName);let s=i.restore==="always";for(let c of t)n.whenDefined(c).then(()=>{if(!e.isConnected)return;let o=v(e,i),u=W(o);for(let{el:l,name:a,kind:p}of o){if(p!=="editor"||l.localName!==c||!Object.hasOwn(r,a))continue;let d=j(r[a],a,u);if(d===null||d===void 0||d===A)continue;M(e,l,a,d,s)}})}function Q(e,i,r=!0){let n=S(e);if(!n){console.warn("[phlex-reactive] persist_state on a root without reactive_persist — skipped");return}if(!i||typeof i!=="object")return;let t=b(e,n),s={...t?.state??{},...i},c=r?_(e,n):t?.fields??{};if(R(e,n,{fields:c,state:s}))e.setAttribute?.(w,JSON.stringify(s))}function Z(e){let i=S(e);if(i)h(e,i)}var x=new WeakMap;function ne(e,i,r,n){let t=e.element,s=S(t);if(!s)return;if(n?.submitted)h(t,s);let c=b(t,s);if(c){if(Y(t,s,c.fields),c.state)t.setAttribute?.(w,JSON.stringify(c.state));i.emit("reactive:persist-restored",{key:s.key,fields:c.fields,state:c.state??{}}),i.reseed()}let o=null,u=()=>{if(o!==null)clearTimeout(o),o=null},l=()=>{u();let f=b(t,s);R(t,s,{fields:_(t,s),state:f?.state??null})},a=()=>{let f=Number(s.debounce)||0;if(f<=0)return l();if(o!==null)clearTimeout(o);o=setTimeout(()=>{o=null,l()},f)},p=(f)=>{if(!f?.detail?.success)return;let E=f.target;if(E?.tagName!=="FORM"||typeof E.contains!=="function")return;if(!E.contains(t))return;u(),h(t,s)},d=["input",...H];for(let f of d)t.addEventListener?.(f,a);if(t.addEventListener?.("change",l),document.addEventListener?.("turbo:submit-end",p),x.set(e,()=>{if(o!==null)l();for(let f of d)t.removeEventListener?.(f,a);t.removeEventListener?.("change",l),document.removeEventListener?.("turbo:submit-end",p)}),n?.edited&&!n.submitted)l();for(let f of n?.ops??[])f(!0)}function ie(e,i){if(!i)return;let r=S(e);if(!r)return;if(i.submitted)return h(e,r);if(i.edited){let n=b(e,r),t={...n?.fields},s=_(e,r);for(let c of ee(e,r))if(Object.hasOwn(s,c))t[c]=s[c];R(e,r,{fields:t,state:n?.state??null})}for(let n of i.ops??[])n(!1)}function ee(e,i){let r=new Set;for(let{el:n,name:t,kind:s}of v(e,i))if(s==="editor"?g(n)&&!L(n):s==="contenteditable"?n.hasAttribute("data-reactive-editable")?m.text(n)!==m.served(n):(n.textContent??"").trim()!=="":n.type==="checkbox"||n.type==="radio"?n.checked!==n.defaultChecked:n.tagName==="SELECT"?[...n.options].some((o)=>o.selected!==o.hasAttribute("selected")):n.value!==n.defaultValue)r.add(t);return r}function re(e){let i=x.get(e);x.delete(e),i?.()}function J(e,i){let r=m?.waiting.get(e)?.persist;if(!r||!S(e))return i(!0);(r.ops??=[]).push(i)}function se(e,i){J(e,(r)=>Q(e,i,r))}function oe(e){J(e,()=>Z(e))}export{z as PERSIST_EDITOR_SELECTOR,G as PERSIST_EDITOR_TAGS,ie as abandon,oe as clearRoot,ne as connect,re as disconnect,te as install,se as writeState};
1
+ var m;function se(e){m=e}var L=1,V="phlex-reactive:persist:",G=new Set(["hidden","file","password","submit","button","reset","image"]),A=Symbol("persist-no-value"),k="data-reactive-persist-state",q=":is(lexxy-editor, trix-editor, [contenteditable=''], [contenteditable=true], [contenteditable=plaintext-only])",H=new Set(["lexxy-editor","trix-editor"]),B="lexxy-editor, trix-editor, trix-toolbar",U=["lexxy:change","trix-change"],X=new Set(["","<p></p>","<p><br></p>","<div><br></div>"]),I=new WeakSet,P=new WeakSet,C=new WeakSet;function S(e){let t=e?.getAttribute?.("data-reactive-persist");if(!t||t==="off")return null;try{let s=JSON.parse(t);if(s&&typeof s==="object"&&typeof s.key==="string"&&s.key!=="")return s}catch{}if(!I.has(e))I.add(e),console.warn(`[phlex-reactive] malformed reactive_persist payload ${JSON.stringify(t)} — persistence disabled`);return null}function T(e){try{if(e.storage==="session")return typeof sessionStorage>"u"?null:sessionStorage;return typeof localStorage>"u"?null:localStorage}catch{return null}}function N(e,t){if(e?.getAttribute?.("data-reactive-debug")!=="true"||P.has(e))return;P.add(e),console.info(`[phlex-reactive] reactive_persist: storage unavailable — draft skipped (${t?.name??t})`)}function R(e){return V+e.key}function b(e,t){let s=T(t);if(!s)return null;let n;try{n=s.getItem(R(t))}catch(u){return N(e,u),null}if(!n)return null;let i;try{i=JSON.parse(n)}catch{return null}if(!i||typeof i!=="object"||i.v!==L)return null;let r=Number(t.ttl)*1000;if(!(Number(i.savedAt)+r>Date.now()))return h(e,t),null;let c=i.fields&&typeof i.fields==="object"?i.fields:{},o=i.state&&typeof i.state==="object"?i.state:null;return{fields:c,state:o}}function O(e,t,{fields:s,state:n}){let i=T(t);if(!i)return!1;let r={v:L,savedAt:Date.now(),fields:s};if(n)r.state=n;try{return i.setItem(R(t),JSON.stringify(r)),!0}catch(c){return N(e,c),!1}}function h(e,t){e?.removeAttribute?.(k);let s=T(t);if(!s)return;try{s.removeItem(R(t))}catch(n){N(e,n)}}function g(e,t){let s=Array.isArray(t.fields)?new Set(t.fields):null,n=[],i=(r)=>r.closest('[data-controller~="reactive"]')===e&&r.getAttribute("data-reactive-persist")!=="off";for(let r of e.querySelectorAll("input[name], select[name], textarea[name]")){if(!i(r)||G.has(r.type)||r.closest(B))continue;if(s&&!s.has(r.name))continue;n.push({el:r,name:r.name,kind:"native"})}for(let r of e.querySelectorAll(q)){if(!i(r))continue;let c=Y(r);if(!c||s&&!s.has(c))continue;n.push({el:r,name:c,kind:H.has(r.localName)?"editor":"contenteditable"})}return n}function D(e){return e.hasAttribute("data-reactive-editable")?m.text(e):e.textContent??""}function Y(e){return e.getAttribute("name")||typeof e.name==="string"&&e.name||null}function v(e){return typeof e.value==="string"}function W(e){if(typeof e.isEmpty==="boolean")return e.isEmpty;let t=e.editor?.getDocument?.();if(typeof t?.isEmpty==="function")return t.isEmpty();return X.has(e.value.trim())}function K(e,t,s){if(e?.getAttribute?.("data-reactive-debug")!=="true"||C.has(e))return;C.add(e),console.info(`[phlex-reactive] reactive_persist: could not restore editor ${JSON.stringify(t)} — ${s?.message??s}`)}function y(e){return e.tagName==="SELECT"&&e.multiple}function j(e){let t=new Map;for(let{el:s,name:n}of e){if(s.type==="radio"||!String(n).endsWith("[]"))continue;t.set(n,(t.get(n)??0)+1)}return t}function M(e,t,s){if(!Array.isArray(e))return e;if(s.get(t)!==1||e.length!==1)return A;return e[0]}function _(e,t){let s={},n=(i)=>{let r=s[i];return Array.isArray(r)?r:s[i]=[]};for(let{el:i,name:r,kind:c}of g(e,t)){let o=String(r).endsWith("[]");if(c==="editor"){if(v(i))if(o)n(r).push(i.value);else s[r]=i.value}else if(c==="contenteditable"){let u=D(i);if(o)n(r).push(u);else s[r]=u}else if(i.type==="radio"){if(i.checked)s[r]=i.value;else if(!Object.hasOwn(s,r))s[r]=null}else if(i.type==="checkbox")if(o){let u=n(r);if(i.checked)u.push(i.value)}else s[r]=i.checked;else if(y(i)){let u=[...i.options].filter((f)=>f.selected).map((f)=>f.value);if(o)n(r).push(...u);else s[r]=u}else if(o)n(r).push(i.value);else s[r]=i.value}return s}function Q(e,t,s){let n=t.restore==="always",i=g(e,t),r=j(i),c=new Set;for(let o of i)if(o.kind==="native"&&o.el.type==="checkbox"&&o.el.checked)c.add(o.name);for(let{el:o,name:u,kind:f}of i){if(!Object.hasOwn(s,u))continue;let a=s[u];if(a===null||a===void 0)continue;if(Array.isArray(a)&&y(o)&&(r.get(u)??0)>1)continue;if(Array.isArray(a)&&!(o.type==="checkbox"||y(o))){if(a=M(a,u,r),a===A)continue}if((o.type==="checkbox"||y(o))&&String(u).endsWith("[]")&&!Array.isArray(a))continue;if(f==="editor")J(e,o,u,a,n);else if(f==="contenteditable"){if(!n&&D(o).trim()!=="")continue;o.textContent=String(a)}else if(o.type==="radio"){if(!n&&i.some((p)=>p.kind==="native"&&p.el.type==="radio"&&p.name===u&&p.el.checked))continue;o.checked=o.value===String(a)}else if(o.type==="checkbox"&&Array.isArray(a)){if(!n&&c.has(u))continue;o.checked=a.map(String).includes(o.value)}else if(o.type==="checkbox"){if(!n&&o.checked)continue;o.checked=Boolean(a)}else if(y(o)){if(!n&&[...o.options].some((d)=>d.selected))continue;let p=new Set((Array.isArray(a)?a:[a]).map(String));for(let d of o.options)d.selected=p.has(d.value)}else{if(!n&&o.value!=="")continue;o.value=String(a)}}Z(e,t,s)}function J(e,t,s,n,i){if(Array.isArray(n))return;if(!v(t))return;if(!i&&!W(t))return;try{t.value=String(n)}catch(r){K(e,s,r)}}function Z(e,t,s){let n=globalThis.customElements;if(typeof n?.whenDefined!=="function")return;let i=new Set;for(let c of e.querySelectorAll("lexxy-editor, trix-editor"))if(!v(c)&&!n.get?.(c.localName))i.add(c.localName);let r=t.restore==="always";for(let c of i)n.whenDefined(c).then(()=>{if(!e.isConnected)return;let o=g(e,t),u=j(o);for(let{el:f,name:a,kind:p}of o){if(p!=="editor"||f.localName!==c||!Object.hasOwn(s,a))continue;let d=M(s[a],a,u);if(d===null||d===void 0||d===A)continue;J(e,f,a,d,r)}})}function ee(e,t,s=!0){let n=S(e);if(!n){console.warn("[phlex-reactive] persist_state on a root without reactive_persist — skipped");return}if(!t||typeof t!=="object")return;let i=b(e,n),r={...i?.state??{},...t},c=s?_(e,n):i?.fields??{};if(O(e,n,{fields:c,state:r}))e.setAttribute?.(k,JSON.stringify(r))}function te(e){let t=S(e);if(t)h(e,t)}var w=new WeakMap,x=new WeakMap;function ne(e,t){if(typeof e?.addEventListener!=="function")return()=>{};let s=x.get(e);if(!s){let r=new Set,c=(o)=>{for(let u of r)u(o)};e.addEventListener("turbo:submit-end",c),s=[r,c],x.set(e,s)}let[n,i]=s;return n.add(t),()=>{if(n.delete(t),n.size>0)return;e.removeEventListener?.("turbo:submit-end",i),x.delete(e)}}function re(e,t,s,n){let i=e.element,r=S(i);if(!r)return;if(n?.submitted)h(i,r);let c=b(i,r);if(c){if(Q(i,r,c.fields),c.state)i.setAttribute?.(k,JSON.stringify(c.state));t.emit("reactive:persist-restored",{key:r.key,fields:c.fields,state:c.state??{}}),t.reseed()}let o=null,u=()=>{if(o!==null)clearTimeout(o),o=null},f=()=>{u();let l=b(i,r);O(i,r,{fields:_(i,r),state:l?.state??null})},a=()=>{let l=Number(r.debounce)||0;if(l<=0)return f();if(o!==null)clearTimeout(o);o=setTimeout(()=>{o=null,f()},l)},p=(l)=>{if(!l?.detail?.success)return;let E=l.target;if(E?.tagName!=="FORM"||typeof E.contains!=="function")return;if(!E.contains(i))return;u(),h(i,r)},d=["input",...U];for(let l of d)i.addEventListener?.(l,a);i.addEventListener?.("change",f);let z=ne(globalThis.document,p);if(w.set(e,()=>{if(o!==null)f();for(let l of d)i.removeEventListener?.(l,a);i.removeEventListener?.("change",f),z()}),n?.edited&&!n.submitted)f();for(let l of n?.ops??[])l(!0)}function oe(e,t){if(!t)return;let s=S(e);if(!s)return;if(t.submitted)return h(e,s);if(t.edited){let n=b(e,s),i={...n?.fields},r=_(e,s);for(let c of ie(e,s))if(Object.hasOwn(r,c))i[c]=r[c];O(e,s,{fields:i,state:n?.state??null})}for(let n of t.ops??[])n(!1)}function ie(e,t){let s=new Set;for(let{el:n,name:i,kind:r}of g(e,t))if(r==="editor"?v(n)&&!W(n):r==="contenteditable"?n.hasAttribute("data-reactive-editable")?m.text(n)!==m.served(n):(n.textContent??"").trim()!=="":n.type==="checkbox"||n.type==="radio"?n.checked!==n.defaultChecked:n.tagName==="SELECT"?[...n.options].some((o)=>o.selected!==o.hasAttribute("selected")):n.value!==n.defaultValue)s.add(i);return s}function ce(e){let t=w.get(e);w.delete(e),t?.()}function F(e,t){let s=m?.waiting.get(e)?.persist;if(!s||!S(e))return t(!0);(s.ops??=[]).push(t)}function ae(e,t){F(e,(s)=>ee(e,t,s))}function ue(e){F(e,()=>te(e))}export{q as PERSIST_EDITOR_SELECTOR,H as PERSIST_EDITOR_TAGS,oe as abandon,ue as clearRoot,re as connect,ce as disconnect,se as install,ae as writeState};
2
2
 
3
- //# debugId=C200972FABEC142364756E2164756E21
3
+ //# debugId=A902A72D95342CD264756E2164756E21
4
4
  //# sourceMappingURL=persist.min.js.map