phlex-reactive 0.15.1 → 0.15.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +89 -0
- data/README.md +72 -9
- data/app/controllers/phlex/reactive/actions_controller.rb +4 -1
- data/app/javascript/phlex/reactive/compute.js +14 -1
- data/app/javascript/phlex/reactive/compute.min.js +2 -2
- data/app/javascript/phlex/reactive/compute.min.js.map +3 -3
- data/app/javascript/phlex/reactive/core.min.js +6 -5
- data/app/javascript/phlex/reactive/core.min.js.map +3 -3
- data/app/javascript/phlex/reactive/features/bindings.js +47 -0
- data/app/javascript/phlex/reactive/features/bindings.min.js +2 -2
- data/app/javascript/phlex/reactive/features/bindings.min.js.map +3 -3
- data/app/javascript/phlex/reactive/reactive_controller.min.js +4 -3
- data/app/javascript/phlex/reactive/reactive_controller.min.js.map +4 -4
- data/app/javascript/phlex/reactive/runtime.js +337 -13
- data/lib/phlex/reactive/component/helpers.rb +15 -0
- data/lib/phlex/reactive/component/media_binding.rb +85 -0
- data/lib/phlex/reactive/held_fields.rb +87 -0
- data/lib/phlex/reactive/js.rb +142 -29
- data/lib/phlex/reactive/streamable.rb +26 -2
- data/lib/phlex/reactive/test_helpers.rb +11 -2
- data/lib/phlex/reactive/version.rb +1 -1
- metadata +3 -1
|
@@ -200,6 +200,7 @@ export function registerReactiveStreamRender() {
|
|
|
200
200
|
if (typeof document === "undefined" || typeof document.addEventListener !== "function") return
|
|
201
201
|
streamRenderRegistered = true
|
|
202
202
|
document.addEventListener("turbo:before-stream-render", (event) => {
|
|
203
|
+
streamOwnReply(event)
|
|
203
204
|
streamIntoRoots(event)
|
|
204
205
|
streamModals(event)
|
|
205
206
|
// The opt-in entry sees every stream (it may hold one for a module the
|
|
@@ -356,6 +357,68 @@ export function __resetReactiveModalsForTest() {
|
|
|
356
357
|
modalsRegistered = false
|
|
357
358
|
}
|
|
358
359
|
|
|
360
|
+
// --- keep: a morph re-applies what a kept client op did (issue #369) ---------
|
|
361
|
+
// A morph rewrites `class`, `hidden` and every attribute from the server
|
|
362
|
+
// render, so a selection made with on_client ops was lost to the next reply or
|
|
363
|
+
// broadcast morph. An op with keep: records its RESULT on each target — each
|
|
364
|
+
// class on or off, `hidden`, an attribute's value (null: absent) — and the one
|
|
365
|
+
// document listener below puts that back after Turbo morphs the element in
|
|
366
|
+
// place. The result, not the op: a toggle must not flip again on every morph.
|
|
367
|
+
// The latest kept op on an element and a class or attribute wins, so "clear
|
|
368
|
+
// all, then select one" leaves one. Classes and attributes no kept op touched
|
|
369
|
+
// follow the render. Weak: an element a stream replaces is a new element.
|
|
370
|
+
//
|
|
371
|
+
// One Map per element: ".name" → whether that class is on; an attribute name →
|
|
372
|
+
// its value, null when absent. show/hide/toggle keep the `hidden` attribute,
|
|
373
|
+
// so the morph puts back the visibility without running a transition again.
|
|
374
|
+
// (No attribute name starts with ".", so the two kinds of key never meet.)
|
|
375
|
+
// An entry is re-inserted on every kept op, so the Map replays in op order:
|
|
376
|
+
// a kept add_class after a kept set_attr("class", …) still wins.
|
|
377
|
+
const kept = new WeakMap()
|
|
378
|
+
|
|
379
|
+
function keepResult(el, name, args, resolveTargets) {
|
|
380
|
+
const state = kept.get(el) ?? new Map()
|
|
381
|
+
kept.set(el, state)
|
|
382
|
+
const put = (key, value) => {
|
|
383
|
+
state.delete(key)
|
|
384
|
+
state.set(key, value)
|
|
385
|
+
}
|
|
386
|
+
if (name.endsWith("_class")) for (const c of args.classes ?? []) put(`.${c}`, el.classList.contains(c))
|
|
387
|
+
else if (name.endsWith("_attr")) attrRefused(args.name) || put(args.name, el.getAttribute(args.name))
|
|
388
|
+
else if (name === "show" || name === "hide" || name === "toggle") {
|
|
389
|
+
put("hidden", el.getAttribute("hidden"))
|
|
390
|
+
// The expanded: trigger's aria-expanded is kept on the trigger: a document
|
|
391
|
+
// listener has no op scope to resolve it from later.
|
|
392
|
+
if (args.expanded != null) {
|
|
393
|
+
for (const trigger of resolveTargets({ ...args, to: args.expanded })) {
|
|
394
|
+
keepResult(trigger, "set_attr", { name: "aria-expanded" })
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
let keepRegistered = false
|
|
401
|
+
|
|
402
|
+
export function registerReactiveKeep() {
|
|
403
|
+
if (keepRegistered) return
|
|
404
|
+
if (typeof document === "undefined" || typeof document.addEventListener !== "function") return
|
|
405
|
+
keepRegistered = true
|
|
406
|
+
// turbo:morph-element fires for every element Idiomorph morphed in place,
|
|
407
|
+
// after its attributes were synced from the new render.
|
|
408
|
+
document.addEventListener("turbo:morph-element", (event) => {
|
|
409
|
+
const el = event.target
|
|
410
|
+
for (const [key, value] of kept.get(el) ?? []) {
|
|
411
|
+
if (key[0] === ".") el.classList.toggle(key.slice(1), value)
|
|
412
|
+
else if (value == null) el.removeAttribute(key)
|
|
413
|
+
else if (el.getAttribute(key) !== value) el.setAttribute(key, value)
|
|
414
|
+
}
|
|
415
|
+
})
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
export function __resetReactiveKeepForTest() {
|
|
419
|
+
keepRegistered = false
|
|
420
|
+
}
|
|
421
|
+
|
|
359
422
|
// For the opt-in entry: what to do with a stream before its render — hold it
|
|
360
423
|
// for a module the incoming content needs. `handle(event, effects)` gets the
|
|
361
424
|
// loaded effects module (or undefined) and wraps the stream with it itself.
|
|
@@ -470,18 +533,21 @@ export function registerReactiveMorphFocus() {
|
|
|
470
533
|
const el = event.target
|
|
471
534
|
const next = event.detail?.newElement // absent when Turbo asks before a removal
|
|
472
535
|
if (!next || event.defaultPrevented) return
|
|
536
|
+
const holds = holdsTypedValue(el)
|
|
537
|
+
// Before the default below is overwritten (issue #370).
|
|
538
|
+
if (holds) noteHeld(el, next)
|
|
473
539
|
if (el.hasAttribute?.("data-reactive-editable")) {
|
|
474
540
|
serverTexts.set(el, editableValue(next))
|
|
475
|
-
if (!
|
|
541
|
+
if (!holds) return
|
|
476
542
|
for (const { name } of [...el.attributes]) {
|
|
477
|
-
if (!next.hasAttribute(name) && name !== "contenteditable") el.removeAttribute(name)
|
|
543
|
+
if (!next.hasAttribute(name) && name !== "contenteditable" && name !== HELD) el.removeAttribute(name)
|
|
478
544
|
}
|
|
479
545
|
for (const { name, value } of next.attributes) {
|
|
480
546
|
if (name !== "contenteditable" && el.getAttribute(name) !== value) el.setAttribute(name, value)
|
|
481
547
|
}
|
|
482
548
|
return event.preventDefault()
|
|
483
549
|
}
|
|
484
|
-
if (el.localName !== "input" || !
|
|
550
|
+
if (el.localName !== "input" || !holds) return
|
|
485
551
|
const value = next.getAttribute("value")
|
|
486
552
|
if (value == null) el.removeAttribute("value")
|
|
487
553
|
else if (el.getAttribute("value") !== value) el.setAttribute("value", value)
|
|
@@ -490,10 +556,110 @@ export function registerReactiveMorphFocus() {
|
|
|
490
556
|
// text when first focused — before anything can be typed into it.
|
|
491
557
|
document.addEventListener("focusin", (event) => seedServerText(event.target))
|
|
492
558
|
document.addEventListener("turbo:before-morph-attribute", (event) => {
|
|
493
|
-
|
|
559
|
+
const name = event.detail?.attributeName
|
|
560
|
+
if ((name === "value" || name === HELD) && holdsTypedValue(event.target)) event.preventDefault()
|
|
561
|
+
})
|
|
562
|
+
// The user took theirs: the field reads its default again.
|
|
563
|
+
document.addEventListener("input", (event) => {
|
|
564
|
+
const el = event.target
|
|
565
|
+
if (el?.hasAttribute?.(HELD) && typedValue(el) === serverValue(el)) el.removeAttribute(HELD)
|
|
494
566
|
})
|
|
495
567
|
}
|
|
496
568
|
|
|
569
|
+
// --- Knowing what was held (issue #370) ---
|
|
570
|
+
// A morph that holds a focused field (above) over a render whose value differs
|
|
571
|
+
// from the field's default — its last server value — means someone else changed
|
|
572
|
+
// the field while the user typed. The field is marked data-reactive-held (kept
|
|
573
|
+
// through later morphs while focused, like `value`), a bubbling reactive:held
|
|
574
|
+
// { name, theirs } is dispatched, and the root's next action sends the names as
|
|
575
|
+
// X-Reactive-Held. The root's own reply never marks (it answers exactly those
|
|
576
|
+
// values), and once applied the root holds nothing. A morph of the field while
|
|
577
|
+
// unfocused writes the server's value and drops the mark with it.
|
|
578
|
+
const HELD = "data-reactive-held"
|
|
579
|
+
const ROOT = '[data-controller~="reactive"]'
|
|
580
|
+
let replyRoot = null
|
|
581
|
+
const replying = new Map()
|
|
582
|
+
|
|
583
|
+
function typedValue(el) {
|
|
584
|
+
return el.hasAttribute("data-reactive-editable") ? editableValue(el) : el.value
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function serverValue(el) {
|
|
588
|
+
return el.hasAttribute("data-reactive-editable") ? serverText(el) : el.defaultValue
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function renderedValue(next) {
|
|
592
|
+
if (next.hasAttribute("data-reactive-editable")) return editableValue(next)
|
|
593
|
+
return next.localName === "textarea" ? next.textContent : (next.getAttribute("value") ?? "")
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function noteHeld(el, next) {
|
|
597
|
+
const name = el.getAttribute("name")
|
|
598
|
+
// A checkbox's or radio's value is the option, not what the user typed.
|
|
599
|
+
if (!name || /^(checkbox|radio)$/.test(el.type) || replying.has(el.closest(ROOT))) return
|
|
600
|
+
const theirs = renderedValue(next)
|
|
601
|
+
if (theirs === typedValue(el)) return el.removeAttribute(HELD)
|
|
602
|
+
if (theirs === serverValue(el)) return
|
|
603
|
+
el.setAttribute(HELD, "")
|
|
604
|
+
el.dispatchEvent(new CustomEvent("reactive:held", { bubbles: true, detail: { name, theirs } }))
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// The names a root's next request reports: its own marked fields (not a nested
|
|
608
|
+
// root's), still different from their default. As a header value: JSON with
|
|
609
|
+
// every non-ASCII character escaped (fetch refuses a code point above 0xFF).
|
|
610
|
+
function heldHeader(root) {
|
|
611
|
+
const names = []
|
|
612
|
+
for (const el of root.querySelectorAll(`[${HELD}]`)) {
|
|
613
|
+
if (el.closest(ROOT) !== root) continue
|
|
614
|
+
const name = el.getAttribute("name")
|
|
615
|
+
if (typedValue(el) === serverValue(el)) el.removeAttribute(HELD)
|
|
616
|
+
else if (name && !names.includes(name)) names.push(name)
|
|
617
|
+
}
|
|
618
|
+
if (names.length === 0) return null
|
|
619
|
+
return JSON.stringify(names).replace(/[\u007f-]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`)
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function releaseHeld(root) {
|
|
623
|
+
for (const el of root.querySelectorAll(`[${HELD}]`)) if (el.closest(ROOT) === root) el.removeAttribute(HELD)
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// Hand a root's own reply to Turbo: while its streams render nothing in the
|
|
627
|
+
// root is marked, and once they have the root holds nothing. Turbo announces
|
|
628
|
+
// each stream synchronously inside renderStreamMessage (streamOwnReply wraps
|
|
629
|
+
// its render, innermost); a reply none of whose streams rendered releases now.
|
|
630
|
+
// An error reply (release false) never marks either, but releases nothing: the
|
|
631
|
+
// server refused those values rather than answering them.
|
|
632
|
+
function renderOwnReply(root, html, release = true) {
|
|
633
|
+
const own = { root, streams: 0, release }
|
|
634
|
+
const previous = replyRoot
|
|
635
|
+
replyRoot = own
|
|
636
|
+
try {
|
|
637
|
+
window.Turbo.renderStreamMessage(html)
|
|
638
|
+
} finally {
|
|
639
|
+
replyRoot = previous
|
|
640
|
+
}
|
|
641
|
+
if (release && own.streams === 0) releaseHeld(root)
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function streamOwnReply(event) {
|
|
645
|
+
const own = replyRoot
|
|
646
|
+
const detail = event.detail
|
|
647
|
+
const render = detail?.render
|
|
648
|
+
if (!own || typeof render !== "function") return
|
|
649
|
+
own.streams++
|
|
650
|
+
detail.render = async (el) => {
|
|
651
|
+
replying.set(own.root, (replying.get(own.root) || 0) + 1)
|
|
652
|
+
try {
|
|
653
|
+
return await render(el)
|
|
654
|
+
} finally {
|
|
655
|
+
const left = replying.get(own.root) - 1
|
|
656
|
+
if (left) replying.set(own.root, left)
|
|
657
|
+
else replying.delete(own.root)
|
|
658
|
+
if (own.release) releaseHeld(own.root)
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
497
663
|
export function __resetReactiveMorphFocusForTest() {
|
|
498
664
|
morphFocusRegistered = false
|
|
499
665
|
}
|
|
@@ -693,6 +859,7 @@ export function registerReactiveActions() {
|
|
|
693
859
|
registerReactiveStreamRender()
|
|
694
860
|
registerReactiveMorphFocus()
|
|
695
861
|
registerReactiveModals()
|
|
862
|
+
registerReactiveKeep()
|
|
696
863
|
registerReactivePlaintext()
|
|
697
864
|
registerReactiveOffline()
|
|
698
865
|
registerReactiveDev()
|
|
@@ -767,7 +934,8 @@ if (typeof window !== "undefined" && typeof document !== "undefined") {
|
|
|
767
934
|
// time; this guards a hand-built / forged ops attr from bypassing it. Refused:
|
|
768
935
|
// event handlers (on*, XSS), URL-bearing names (a javascript: navigation
|
|
769
936
|
// surface), and style (CSS injection). Case-insensitive, mirroring js.rb.
|
|
770
|
-
|
|
937
|
+
// srcset (issue #373) too: a URL list that picks what an <img> loads.
|
|
938
|
+
const REFUSED_ATTR_URL = new Set(["href", "src", "srcset", "srcdoc", "action", "formaction", "xlink:href", "style"])
|
|
771
939
|
function attrRefused(name) {
|
|
772
940
|
const lower = String(name).toLowerCase()
|
|
773
941
|
return lower.startsWith("on") || REFUSED_ATTR_URL.has(lower)
|
|
@@ -964,6 +1132,19 @@ const CLIENT_OPS = Object.freeze({
|
|
|
964
1132
|
if (el.textContent !== text) el.textContent = text
|
|
965
1133
|
},
|
|
966
1134
|
|
|
1135
|
+
// Copy a server-rendered image (issue #373): src, srcset and alt from the
|
|
1136
|
+
// `from` <img>, resolved with the op's own scoping (the `submitter:`
|
|
1137
|
+
// precedent). The URL is read off that element, never from an argument —
|
|
1138
|
+
// the src refusal above stays. A selector that does not parse (a URL
|
|
1139
|
+
// string) is a miss, like one that matches nothing.
|
|
1140
|
+
copy_media: (el, args, resolveTargets) => {
|
|
1141
|
+
let source = null
|
|
1142
|
+
try {
|
|
1143
|
+
source = resolveTargets({ ...args, to: args.from })[0] ?? null
|
|
1144
|
+
} catch {}
|
|
1145
|
+
copyMedia(el, source)
|
|
1146
|
+
},
|
|
1147
|
+
|
|
967
1148
|
// Tick or untick a whole checkbox group (issue #342) — "clear selection".
|
|
968
1149
|
// The bindings feature flips the root's OWNED boxes the way its select-all
|
|
969
1150
|
// header does (every box set, then input + change on each flipped one) and
|
|
@@ -1033,6 +1214,17 @@ const CLIENT_OPS = Object.freeze({
|
|
|
1033
1214
|
// carries this op.
|
|
1034
1215
|
paste_into: (el) => pasteClipboardInto(el),
|
|
1035
1216
|
|
|
1217
|
+
// Preview a chosen file (issue #375): paint the input's first image file
|
|
1218
|
+
// onto the `into` <img> through an object URL made HERE from the input's
|
|
1219
|
+
// own File. The op carries no URL and `args.url` is never read, so the src
|
|
1220
|
+
// refusal of set_attr (issue #96) stays whole. ACTOR-ONLY like paste_into
|
|
1221
|
+
// (BROADCAST_REFUSED_OPS server-side).
|
|
1222
|
+
preview_file: (el, args, resolveTargets) => previewFile(el, args, resolveTargets),
|
|
1223
|
+
|
|
1224
|
+
// A field's live value (issue #374): the paste_into write without the
|
|
1225
|
+
// clipboard or the focus. ACTOR-ONLY (BROADCAST_REFUSED_OPS server-side).
|
|
1226
|
+
set_value: (el, args) => setFieldValue(el, String(args.value ?? "")),
|
|
1227
|
+
|
|
1036
1228
|
// Client-only drafts (issue #239): merge a flat state bag into the root's
|
|
1037
1229
|
// reactive_persist draft / forget the draft. ACTOR-ONLY like focus/submit
|
|
1038
1230
|
// (BROADCAST_REFUSED_OPS server-side) — rewriting or wiping every
|
|
@@ -1074,7 +1266,7 @@ function pasteClipboardInto(field) {
|
|
|
1074
1266
|
.then((text) => {
|
|
1075
1267
|
if (!text) return
|
|
1076
1268
|
field.value = text
|
|
1077
|
-
|
|
1269
|
+
dispatchInput(field)
|
|
1078
1270
|
field.focus?.()
|
|
1079
1271
|
})
|
|
1080
1272
|
.catch(() => {
|
|
@@ -1083,6 +1275,104 @@ function pasteClipboardInto(field) {
|
|
|
1083
1275
|
})
|
|
1084
1276
|
}
|
|
1085
1277
|
|
|
1278
|
+
// --- Preview a chosen file (issue #375) ---
|
|
1279
|
+
// img -> the object URL preview_file last made for it. The runtime owns it:
|
|
1280
|
+
// never an op argument, never an attribute a caller sets. An entry lives
|
|
1281
|
+
// while the img is on the page AND still shows that URL; a re-pick, a clear,
|
|
1282
|
+
// a non-image file, the img leaving the page or a morph that strips its src
|
|
1283
|
+
// in place revokes it. One MutationObserver notices the last two, and only
|
|
1284
|
+
// while some preview is live: it then wakes on every DOM mutation of the page
|
|
1285
|
+
// (a live feed, a drag), and each wake is a walk over the few live previews.
|
|
1286
|
+
const previews = new Map()
|
|
1287
|
+
let previewObserver = null
|
|
1288
|
+
|
|
1289
|
+
function previewFile(input, args, resolveTargets) {
|
|
1290
|
+
if (input?.tagName !== "INPUT" || input.type !== "file") {
|
|
1291
|
+
return warnPreview(`reads an input[type=file], not <${input?.localName}>`)
|
|
1292
|
+
}
|
|
1293
|
+
const img = typeof resolveTargets === "function" ? resolveTargets({ ...args, to: args.into })[0] : null
|
|
1294
|
+
// Duck-typed on tagName (the isDialog precedent): only an <img> ever gets a
|
|
1295
|
+
// src here — never an iframe, a script, a frame or anything else.
|
|
1296
|
+
if (img?.tagName !== "IMG") {
|
|
1297
|
+
return warnPreview(`paints an <img>, and ${JSON.stringify(args.into)} is ${img ? `a <${img.localName}>` : "not on the page"}`)
|
|
1298
|
+
}
|
|
1299
|
+
const file = [...(input.files ?? [])].find((f) => typeof f?.type === "string" && f.type.startsWith("image/"))
|
|
1300
|
+
forgetPreview(img)
|
|
1301
|
+
if (file) {
|
|
1302
|
+
const url = URL.createObjectURL(file)
|
|
1303
|
+
previews.set(img, url)
|
|
1304
|
+
img.setAttribute("src", url)
|
|
1305
|
+
watchPreviews()
|
|
1306
|
+
} else img.removeAttribute("src")
|
|
1307
|
+
if (args.name == null) return
|
|
1308
|
+
for (const node of resolveTargets({ ...args, to: args.name })) node.textContent = file ? file.name : ""
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
function warnPreview(why) {
|
|
1312
|
+
console.warn(`[phlex-reactive] preview_file ${why} — skipped`)
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
// Revoke the URL preview_file made for `img`, if it made one.
|
|
1316
|
+
function forgetPreview(img) {
|
|
1317
|
+
const url = previews.get(img)
|
|
1318
|
+
if (url === undefined) return
|
|
1319
|
+
previews.delete(img)
|
|
1320
|
+
URL.revokeObjectURL(url)
|
|
1321
|
+
if (previews.size === 0) {
|
|
1322
|
+
previewObserver?.disconnect()
|
|
1323
|
+
previewObserver = null
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
function watchPreviews() {
|
|
1328
|
+
if (previewObserver || typeof MutationObserver !== "function" || !document?.documentElement) return
|
|
1329
|
+
previewObserver = new MutationObserver(() => {
|
|
1330
|
+
for (const [img, url] of previews) if (!img.isConnected || img.getAttribute("src") !== url) forgetPreview(img)
|
|
1331
|
+
})
|
|
1332
|
+
previewObserver.observe(document.documentElement, {
|
|
1333
|
+
childList: true,
|
|
1334
|
+
subtree: true,
|
|
1335
|
+
attributes: true,
|
|
1336
|
+
attributeFilter: ["src"],
|
|
1337
|
+
})
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
export function __resetReactivePreviewsForTest() {
|
|
1341
|
+
previewObserver?.disconnect()
|
|
1342
|
+
previewObserver = null
|
|
1343
|
+
previews.clear()
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
// The set-value contract's dispatch (issue #183): a bubbling `input`, so
|
|
1347
|
+
// compute reducers, show bindings and on_complete run as if the user typed.
|
|
1348
|
+
function dispatchInput(field) {
|
|
1349
|
+
if (typeof field.dispatchEvent === "function") field.dispatchEvent(new Event("input", { bubbles: true }))
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
// The body of the set_value op (issue #374). Writes the IDL .value — never the
|
|
1353
|
+
// attribute, never HTML — and dispatches `input` only when the field's value
|
|
1354
|
+
// actually moved (compared after the write: a textarea normalizes CRLF, a
|
|
1355
|
+
// select ends at "" for a value no option has). No `change`, no focus. Only an
|
|
1356
|
+
// input, textarea or select is a target, so a contenteditable (reactive_editable's
|
|
1357
|
+
// to write) warns and skips like any other node. A file input takes "" alone —
|
|
1358
|
+
// the browser throws on anything else.
|
|
1359
|
+
function setFieldValue(field, value) {
|
|
1360
|
+
if (!/^(input|textarea|select)$/.test(field.localName) || (field.type === "file" && value !== "")) {
|
|
1361
|
+
console.warn("[phlex-reactive] set_value skipped: not a field it can write", field)
|
|
1362
|
+
return
|
|
1363
|
+
}
|
|
1364
|
+
const before = fieldState(field)
|
|
1365
|
+
field.value = value
|
|
1366
|
+
if (fieldState(field) !== before) dispatchInput(field)
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
// What set_value compares: .value, or for a <select multiple> its whole
|
|
1370
|
+
// selection — assigning .value deselects every other option, which .value
|
|
1371
|
+
// (the first selected) alone would not show.
|
|
1372
|
+
function fieldState(field) {
|
|
1373
|
+
return field.type === "select-multiple" ? [...field.selectedOptions].map((o) => o.value).join("\n") : field.value
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1086
1376
|
// Apply a hidden-flag change, optionally animated by a [during, from, to]
|
|
1087
1377
|
// transition (issue #96). Split out so show/hide/toggle share it.
|
|
1088
1378
|
function setHidden(el, hidden, args) {
|
|
@@ -1110,6 +1400,31 @@ function guardAttr(name) {
|
|
|
1110
1400
|
return false
|
|
1111
1401
|
}
|
|
1112
1402
|
|
|
1403
|
+
// Copy src, srcset and alt from one <img> onto another (issue #373) — the
|
|
1404
|
+
// copy_media op and the reactive_media binding (the bindings feature reaches it
|
|
1405
|
+
// through the core handle). Only an <img> target is written, so an iframe,
|
|
1406
|
+
// script, object, embed or frame never receives a src from here. A source that
|
|
1407
|
+
// is missing, not an <img> or has no src clears the target. Each attribute is
|
|
1408
|
+
// written only when it changes, so the same URL twice does not reload. Returns
|
|
1409
|
+
// the alt copied, false when it cleared, or null when the target was skipped.
|
|
1410
|
+
function copyMedia(target, source) {
|
|
1411
|
+
if (target?.localName !== "img") {
|
|
1412
|
+
console.warn(`[phlex-reactive] copy_media targets an <img>, not <${target?.localName}> — skipped`)
|
|
1413
|
+
return null
|
|
1414
|
+
}
|
|
1415
|
+
const src = source?.localName === "img" ? source.getAttribute("src") : null
|
|
1416
|
+
const pick = (name) => (src ? source.getAttribute(name) : null)
|
|
1417
|
+
for (const name of ["srcset", "src"]) {
|
|
1418
|
+
const value = pick(name)
|
|
1419
|
+
if (value == null) {
|
|
1420
|
+
if (target.hasAttribute(name)) target.removeAttribute(name)
|
|
1421
|
+
} else if (target.getAttribute(name) !== value) target.setAttribute(name, value)
|
|
1422
|
+
}
|
|
1423
|
+
const alt = pick("alt") ?? ""
|
|
1424
|
+
if (target.getAttribute("alt") !== alt) target.setAttribute("alt", alt)
|
|
1425
|
+
return src ? alt : false
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1113
1428
|
// The first focusable descendant of `el`, in document order — the natural
|
|
1114
1429
|
// keyboard target inside an opened menu/dialog. Covers the standard focusable
|
|
1115
1430
|
// set; :not([tabindex="-1"]) drops explicitly-removed nodes. Returns null when
|
|
@@ -1352,7 +1667,10 @@ function applyOps(list, resolveTargets, onZeroTargets) {
|
|
|
1352
1667
|
}
|
|
1353
1668
|
const targets = resolveTargets(args)
|
|
1354
1669
|
if (targets.length === 0 && onZeroTargets) onZeroTargets(name, args)
|
|
1355
|
-
for (const el of targets)
|
|
1670
|
+
for (const el of targets) {
|
|
1671
|
+
CLIENT_OPS[name](el, args, resolveTargets)
|
|
1672
|
+
if (args?.keep === true) keepResult(el, name, args, resolveTargets)
|
|
1673
|
+
}
|
|
1356
1674
|
}
|
|
1357
1675
|
}
|
|
1358
1676
|
|
|
@@ -1497,8 +1815,8 @@ function formNeeded(root) {
|
|
|
1497
1815
|
// Show bindings and cross-root show targets, completion bindings, option
|
|
1498
1816
|
// filtering, the bulk-selection group bindings (enable, select-all, count —
|
|
1499
1817
|
// issue #319) and their cross-root targets (#343), the tag-chip input, draft
|
|
1500
|
-
// nested rows
|
|
1501
|
-
// bindings. The root declares one, or
|
|
1818
|
+
// nested rows, editable text's keys and paste (#351) and media previews
|
|
1819
|
+
// (#373): a form's client-only bindings. The root declares one, or
|
|
1502
1820
|
// owns an element that does. (The probes the connect-time gates ran before
|
|
1503
1821
|
// the split, unchanged.)
|
|
1504
1822
|
function bindingsNeeded(root) {
|
|
@@ -1508,7 +1826,7 @@ function bindingsNeeded(root) {
|
|
|
1508
1826
|
) ||
|
|
1509
1827
|
(root.querySelectorAll?.("[data-reactive-show-field], [data-reactive-show]") ?? []).length > 0 ||
|
|
1510
1828
|
root.querySelector?.(
|
|
1511
|
-
"[data-reactive-nested-json], [data-reactive-nested-list], [data-reactive-confirm-when-param], [data-reactive-enable], [data-reactive-select-all], [data-reactive-count], [data-reactive-editable]",
|
|
1829
|
+
"[data-reactive-nested-json], [data-reactive-nested-list], [data-reactive-confirm-when-param], [data-reactive-enable], [data-reactive-select-all], [data-reactive-count], [data-reactive-editable], [data-reactive-media]",
|
|
1512
1830
|
) != null
|
|
1513
1831
|
)
|
|
1514
1832
|
}
|
|
@@ -2342,6 +2660,8 @@ export default class extends Controller {
|
|
|
2342
2660
|
plain: plaintextSupported,
|
|
2343
2661
|
text: editableValue,
|
|
2344
2662
|
served: serverText,
|
|
2663
|
+
// Copy one <img> onto another (issue #373): the reactive_media binding.
|
|
2664
|
+
copyMedia,
|
|
2345
2665
|
// The confirm gate, as dispatch() runs it: the resolver inside the chain
|
|
2346
2666
|
// (a synchronous throw is a cancel), never a rejection out.
|
|
2347
2667
|
confirm: (message, context) =>
|
|
@@ -3340,6 +3660,9 @@ export default class extends Controller {
|
|
|
3340
3660
|
presenceNeeded(this.element) &&
|
|
3341
3661
|
document.getElementById(`reactive-presence-src-${this.element.id}`)?.getAttribute("connection-id")
|
|
3342
3662
|
if (presence) headers["X-Reactive-Presence"] = presence
|
|
3663
|
+
// The fields a morph held over a changed server value (issue #370).
|
|
3664
|
+
const held = heldHeader(this.element)
|
|
3665
|
+
if (held) headers["X-Reactive-Held"] = held
|
|
3343
3666
|
|
|
3344
3667
|
// ONLY `fetch` itself is the network boundary — offline, DNS, a reset
|
|
3345
3668
|
// connection. Everything below this inner try (reading the body,
|
|
@@ -3421,7 +3744,7 @@ export default class extends Controller {
|
|
|
3421
3744
|
if (__SPLIT__) this.#devtools((devtools) => devtools.recordBody(debug, errorBody, fresh))
|
|
3422
3745
|
else devtoolsFeature.recordBody(debug, errorBody, fresh)
|
|
3423
3746
|
}
|
|
3424
|
-
|
|
3747
|
+
renderOwnReply(this.element, errorBody, false)
|
|
3425
3748
|
}
|
|
3426
3749
|
this.#markError("http")
|
|
3427
3750
|
this.#emitError(action, params, allParams, { kind: "http", status: response.status, body: errorBody })
|
|
@@ -3452,8 +3775,9 @@ export default class extends Controller {
|
|
|
3452
3775
|
// Turbo applies the <turbo-stream> ops by id. A plain replace is an
|
|
3453
3776
|
// outerHTML swap (focus on the replaced subtree is lost); a method="morph"
|
|
3454
3777
|
// replace (Response.morph) or an update morphs in place, preserving the
|
|
3455
|
-
// focused input + caret on unchanged nodes — see issue #28.
|
|
3456
|
-
|
|
3778
|
+
// focused input + caret on unchanged nodes — see issue #28. As the root's
|
|
3779
|
+
// own reply, it clears what the root held (issue #370).
|
|
3780
|
+
renderOwnReply(this.element, html)
|
|
3457
3781
|
// Debug-only (issue #181): the morph may apply a microtask later, so check
|
|
3458
3782
|
// the resurrected-hide case AFTER it lands. Off the debug path, resurrect is
|
|
3459
3783
|
// null — zero cost.
|
|
@@ -15,6 +15,7 @@ module Phlex
|
|
|
15
15
|
include Selection
|
|
16
16
|
include Sortable
|
|
17
17
|
include Editable
|
|
18
|
+
include MediaBinding
|
|
18
19
|
|
|
19
20
|
# The acting client's SSE connection id during the current action (nil
|
|
20
21
|
# outside an action, or when the client isn't subscribed to a stream).
|
|
@@ -31,6 +32,20 @@ module Phlex
|
|
|
31
32
|
Phlex::Reactive.current_connection_id
|
|
32
33
|
end
|
|
33
34
|
|
|
35
|
+
# The names of this action's posted fields that a morph held over a
|
|
36
|
+
# changed server value (issue #370): someone else changed the field
|
|
37
|
+
# while this user typed, and this tab kept the user's text. [] when
|
|
38
|
+
# nothing was held, and outside an action. Filtered to the action's
|
|
39
|
+
# declared params; a hint about what the user saw, never authorization.
|
|
40
|
+
#
|
|
41
|
+
# def save(title: nil)
|
|
42
|
+
# return conflict!(title) if reactive_held_fields.include?("title") && title != @note.title
|
|
43
|
+
# @note.update!(title:)
|
|
44
|
+
# end
|
|
45
|
+
def reactive_held_fields
|
|
46
|
+
Phlex::Reactive::HeldFields.current
|
|
47
|
+
end
|
|
48
|
+
|
|
34
49
|
# Manually satisfy the verify_authorized guard (issue #168) for a bespoke
|
|
35
50
|
# authorization check the interceptor can't see — a hand-rolled policy, a
|
|
36
51
|
# feature flag, an ownership comparison that doesn't go through one of
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phlex
|
|
4
|
+
module Reactive
|
|
5
|
+
module Component
|
|
6
|
+
# A preview image that follows a field (issue #373), with no URL in the
|
|
7
|
+
# page's ops or attributes: the server renders every candidate <img>
|
|
8
|
+
# (hidden), and the binding copies the one whose key matches.
|
|
9
|
+
#
|
|
10
|
+
# input(name: "code")
|
|
11
|
+
# img(id: "logo", alt: "", **reactive_media(
|
|
12
|
+
# from: :code, take: 3,
|
|
13
|
+
# pool: { "007" => "#logo-007", "011" => "#logo-011" },
|
|
14
|
+
# label: "#caption", blank_label: "Unknown"
|
|
15
|
+
# ))
|
|
16
|
+
# img(id: "logo-007", src: "/logos/007.svg", alt: "North", hidden: true)
|
|
17
|
+
#
|
|
18
|
+
# The field is read the way reactive_show reads one (owned fields, a
|
|
19
|
+
# radio group's checked value, reactive_scope applied). `take:` keeps the
|
|
20
|
+
# first n characters as the key; omit it to use the whole value. A pool
|
|
21
|
+
# value is a CSS SELECTOR for an <img> inside the root, never a URL: the
|
|
22
|
+
# client copies that element's src, srcset and alt the way
|
|
23
|
+
# js.copy_media does. A key, selector or element that misses clears the
|
|
24
|
+
# preview. `label:` names a node whose textContent becomes the chosen
|
|
25
|
+
# image's alt (`blank_label`, or "", on a miss). Client-only: no token,
|
|
26
|
+
# no round trip.
|
|
27
|
+
#
|
|
28
|
+
# Named MediaBinding, not Media: this module sits in every reactive
|
|
29
|
+
# component's ancestry, and a bare `Media` would shadow a host app's kit
|
|
30
|
+
# component of that name (issue #233).
|
|
31
|
+
module MediaBinding
|
|
32
|
+
def reactive_media(from:, pool: nil, take: nil, label: nil, blank_label: nil, **attrs)
|
|
33
|
+
wire = { "field" => media_field!(from) }
|
|
34
|
+
wire["take"] = media_take!(take) unless take.nil?
|
|
35
|
+
wire["pool"] = media_pool!(pool)
|
|
36
|
+
wire["label"] = media_selector!(:label, label) unless label.nil?
|
|
37
|
+
wire["blank"] = blank_label.to_s unless blank_label.nil?
|
|
38
|
+
mix({ data: { reactive_media: wire.to_json } }, attrs)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def media_field!(from)
|
|
44
|
+
name = from.to_s if from.is_a?(Symbol) || from.is_a?(String)
|
|
45
|
+
return name unless name.nil? || name.strip.empty?
|
|
46
|
+
|
|
47
|
+
raise ArgumentError, "reactive_media from: needs the field name to watch (e.g. from: :code), got #{from.inspect}"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def media_take!(take)
|
|
51
|
+
return take if take.is_a?(Integer) && take.positive?
|
|
52
|
+
|
|
53
|
+
raise ArgumentError, "reactive_media take: must be a positive Integer (or omitted for the whole value), " \
|
|
54
|
+
"got #{take.inspect}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def media_pool!(pool)
|
|
58
|
+
if pool.nil?
|
|
59
|
+
raise ArgumentError, "reactive_media needs pool: — a Hash of key => selector of a server-rendered <img> " \
|
|
60
|
+
"(e.g. pool: { \"007\" => \"#logo-007\" })"
|
|
61
|
+
end
|
|
62
|
+
unless pool.is_a?(Hash) && !pool.empty?
|
|
63
|
+
raise ArgumentError,
|
|
64
|
+
"reactive_media pool: must be a non-empty Hash of key => <img> selector, got #{pool.inspect}"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
pool.to_h { |key, selector| [key.to_s, media_selector!(:pool, selector)] }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# A pool value or label: is a selector, never a URL — the client only
|
|
71
|
+
# ever passes it to querySelector.
|
|
72
|
+
def media_selector!(kind, selector)
|
|
73
|
+
if selector == JS::ROOT_SENTINEL
|
|
74
|
+
raise ArgumentError,
|
|
75
|
+
"reactive_media #{kind}: #{selector} names the whole root — pass the selector of an element inside it"
|
|
76
|
+
end
|
|
77
|
+
return selector if selector.is_a?(String) && !selector.strip.empty?
|
|
78
|
+
|
|
79
|
+
raise ArgumentError,
|
|
80
|
+
"reactive_media #{kind}: values are CSS selectors (e.g. \"#logo-007\"), got #{selector.inspect}"
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|