phlex-reactive 0.15.0 → 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.
@@ -200,7 +200,9 @@ 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)
205
+ streamModals(event)
204
206
  // The opt-in entry sees every stream (it may hold one for a module the
205
207
  // incoming content needs); with no entry hook, a loaded effects module
206
208
  // wraps the stream directly — in the default entry, always.
@@ -245,6 +247,178 @@ function streamIntoRoots(event) {
245
247
  }
246
248
  }
247
249
 
250
+ // --- Modal dialogs (issue #365) ---
251
+ // show_modal / close_dialog reach the browser's own modal dialog: the top
252
+ // layer, the focus trap, ::backdrop, Escape and focus restored to the opener.
253
+ // A dialog the SERVER renders open with data-reactive-modal becomes modal as
254
+ // soon as the client sees it: at a root's connect (a full page, a replaced
255
+ // root), after a morph of a root or of the page, and after a stream whose
256
+ // content holds a dialog. Duck-typed on tagName (the submitFormFor precedent:
257
+ // fake-node and test friendly).
258
+ function isDialog(el) {
259
+ return el?.tagName === "DIALOG" && typeof el.showModal === "function"
260
+ }
261
+
262
+ // `:modal` is the only way to tell a modal dialog from one opened with the
263
+ // `open` attribute. An engine that does not know the pseudo-class throws a
264
+ // SyntaxError; it then counts as not modal (show_modal reopens it modally).
265
+ function isModal(dialog) {
266
+ try {
267
+ return dialog.matches(":modal")
268
+ } catch {
269
+ return false
270
+ }
271
+ }
272
+
273
+ function showModal(el) {
274
+ if (!isDialog(el)) {
275
+ // Said where a zero-target op is (issue #237): under verbose_errors or debug.
276
+ if (el?.closest?.('[data-reactive-verbose="true"], [data-reactive-debug="true"]')) {
277
+ console.warn(`[phlex-reactive] show_modal targets a <dialog>, not <${el.localName}> — skipped`)
278
+ }
279
+ return
280
+ }
281
+ if (!el.isConnected || isModal(el)) return
282
+ // showModal() throws on a dialog already open non-modally: close it first,
283
+ // in the same task, so nothing paints in between.
284
+ if (el.open) el.close()
285
+ el.showModal()
286
+ }
287
+
288
+ function closeDialog(el) {
289
+ if (isDialog(el) && el.open) el.close()
290
+ }
291
+
292
+ // Make every open dialog[data-reactive-modal] in `scope` modal — `scope` itself
293
+ // too, for a root that is the dialog.
294
+ function upgradeModals(scope) {
295
+ const sel = "dialog[data-reactive-modal]"
296
+ const dialogs = [...(scope?.querySelectorAll?.(sel) ?? [])]
297
+ if (scope?.matches?.(sel)) dialogs.push(scope)
298
+ for (const dialog of dialogs) {
299
+ if (dialog.open && !isModal(dialog)) showModal(dialog)
300
+ }
301
+ }
302
+
303
+ // After a stream whose content holds a dialog has rendered, upgrade the page's
304
+ // marked dialogs. Document-wide: a replace's target is gone by then.
305
+ function streamModals(event) {
306
+ const detail = event.detail
307
+ const render = detail?.render
308
+ const stream = detail?.newStream ?? event.target
309
+ if (typeof render !== "function" || !stream?.querySelector?.("template")?.content?.querySelector?.("dialog")) return
310
+ detail.render = async (el) => {
311
+ await render(el)
312
+ upgradeModals(document)
313
+ }
314
+ }
315
+
316
+ let modalsRegistered = false
317
+
318
+ export function registerReactiveModals() {
319
+ if (modalsRegistered) return
320
+ if (typeof document === "undefined" || typeof document.addEventListener !== "function") return
321
+ modalsRegistered = true
322
+ // A morph of a root (a reply or broadcast morph, a page refresh) may have
323
+ // inserted an open marked dialog or opened one; a page morph covers dialogs
324
+ // outside any root.
325
+ document.addEventListener("turbo:morph-element", (event) => {
326
+ if (event.target?.matches?.('[data-controller~="reactive"]')) upgradeModals(event.target)
327
+ })
328
+ document.addEventListener("turbo:morph", () => upgradeModals(document))
329
+ // A morph that removes `open` from a modal dialog (the server now renders it
330
+ // closed): close() it instead, which leaves the top layer, ends the page's
331
+ // inertness and restores focus. Removing the attribute alone left all that
332
+ // in place under the older spec text, and close() is a no-op once `open` is
333
+ // gone — so the removal is cancelled and close() removes it. Idiomorph asks
334
+ // "remove" for EVERY attribute of the old element, kept or not, and that ask
335
+ // carries no new element: whether `open` is really leaving is read from the
336
+ // element morph's new render, which comes first.
337
+ const closing = new WeakSet()
338
+ document.addEventListener("turbo:before-morph-element", (event) => {
339
+ const el = event.target
340
+ const next = event.detail?.newElement
341
+ if (!isDialog(el) || !next) return
342
+ if (next.hasAttribute?.("open")) closing.delete(el)
343
+ else closing.add(el)
344
+ })
345
+ document.addEventListener("turbo:before-morph-attribute", (event) => {
346
+ const el = event.target
347
+ const detail = event.detail
348
+ if (detail?.attributeName !== "open" || detail.mutationType !== "remove") return
349
+ if (!closing.has(el) || !el.open || !isModal(el)) return
350
+ closing.delete(el)
351
+ event.preventDefault()
352
+ el.close()
353
+ })
354
+ }
355
+
356
+ export function __resetReactiveModalsForTest() {
357
+ modalsRegistered = false
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
+
248
422
  // For the opt-in entry: what to do with a stream before its render — hold it
249
423
  // for a module the incoming content needs. `handle(event, effects)` gets the
250
424
  // loaded effects module (or undefined) and wraps the stream with it itself.
@@ -359,18 +533,21 @@ export function registerReactiveMorphFocus() {
359
533
  const el = event.target
360
534
  const next = event.detail?.newElement // absent when Turbo asks before a removal
361
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)
362
539
  if (el.hasAttribute?.("data-reactive-editable")) {
363
540
  serverTexts.set(el, editableValue(next))
364
- if (!holdsTypedValue(el)) return
541
+ if (!holds) return
365
542
  for (const { name } of [...el.attributes]) {
366
- if (!next.hasAttribute(name) && name !== "contenteditable") el.removeAttribute(name)
543
+ if (!next.hasAttribute(name) && name !== "contenteditable" && name !== HELD) el.removeAttribute(name)
367
544
  }
368
545
  for (const { name, value } of next.attributes) {
369
546
  if (name !== "contenteditable" && el.getAttribute(name) !== value) el.setAttribute(name, value)
370
547
  }
371
548
  return event.preventDefault()
372
549
  }
373
- if (el.localName !== "input" || !holdsTypedValue(el)) return
550
+ if (el.localName !== "input" || !holds) return
374
551
  const value = next.getAttribute("value")
375
552
  if (value == null) el.removeAttribute("value")
376
553
  else if (el.getAttribute("value") !== value) el.setAttribute("value", value)
@@ -379,10 +556,110 @@ export function registerReactiveMorphFocus() {
379
556
  // text when first focused — before anything can be typed into it.
380
557
  document.addEventListener("focusin", (event) => seedServerText(event.target))
381
558
  document.addEventListener("turbo:before-morph-attribute", (event) => {
382
- if (event.detail?.attributeName === "value" && holdsTypedValue(event.target)) event.preventDefault()
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)
383
566
  })
384
567
  }
385
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
+
386
663
  export function __resetReactiveMorphFocusForTest() {
387
664
  morphFocusRegistered = false
388
665
  }
@@ -581,6 +858,8 @@ export function registerReactiveActions() {
581
858
  registerReactiveDefer()
582
859
  registerReactiveStreamRender()
583
860
  registerReactiveMorphFocus()
861
+ registerReactiveModals()
862
+ registerReactiveKeep()
584
863
  registerReactivePlaintext()
585
864
  registerReactiveOffline()
586
865
  registerReactiveDev()
@@ -655,7 +934,8 @@ if (typeof window !== "undefined" && typeof document !== "undefined") {
655
934
  // time; this guards a hand-built / forged ops attr from bypassing it. Refused:
656
935
  // event handlers (on*, XSS), URL-bearing names (a javascript: navigation
657
936
  // surface), and style (CSS injection). Case-insensitive, mirroring js.rb.
658
- const REFUSED_ATTR_URL = new Set(["href", "src", "srcdoc", "action", "formaction", "xlink:href", "style"])
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"])
659
939
  function attrRefused(name) {
660
940
  const lower = String(name).toLowerCase()
661
941
  return lower.startsWith("on") || REFUSED_ATTR_URL.has(lower)
@@ -838,6 +1118,11 @@ const CLIENT_OPS = Object.freeze({
838
1118
  focus: (el) => el.focus?.(),
839
1119
  focus_first: (el) => firstFocusable(el)?.focus?.(),
840
1120
 
1121
+ // Modal dialog ops (issue #365): HTMLDialogElement#showModal / #close.
1122
+ // ACTOR-ONLY like focus (BROADCAST_REFUSED_OPS server-side).
1123
+ show_modal: (el) => showModal(el),
1124
+ close_dialog: (el) => closeDialog(el),
1125
+
841
1126
  // Text op (issue #159): set textContent — XSS-safe by construction (never
842
1127
  // innerHTML), strictly less powerful than set_attr. Change-guarded like
843
1128
  // #mirrorText. With global: true it is the cross-root text escape: paint a
@@ -847,6 +1132,19 @@ const CLIENT_OPS = Object.freeze({
847
1132
  if (el.textContent !== text) el.textContent = text
848
1133
  },
849
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
+
850
1148
  // Tick or untick a whole checkbox group (issue #342) — "clear selection".
851
1149
  // The bindings feature flips the root's OWNED boxes the way its select-all
852
1150
  // header does (every box set, then input + change on each flipped one) and
@@ -916,6 +1214,17 @@ const CLIENT_OPS = Object.freeze({
916
1214
  // carries this op.
917
1215
  paste_into: (el) => pasteClipboardInto(el),
918
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
+
919
1228
  // Client-only drafts (issue #239): merge a flat state bag into the root's
920
1229
  // reactive_persist draft / forget the draft. ACTOR-ONLY like focus/submit
921
1230
  // (BROADCAST_REFUSED_OPS server-side) — rewriting or wiping every
@@ -957,7 +1266,7 @@ function pasteClipboardInto(field) {
957
1266
  .then((text) => {
958
1267
  if (!text) return
959
1268
  field.value = text
960
- if (typeof field.dispatchEvent === "function") field.dispatchEvent(new Event("input", { bubbles: true }))
1269
+ dispatchInput(field)
961
1270
  field.focus?.()
962
1271
  })
963
1272
  .catch(() => {
@@ -966,6 +1275,104 @@ function pasteClipboardInto(field) {
966
1275
  })
967
1276
  }
968
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
+
969
1376
  // Apply a hidden-flag change, optionally animated by a [during, from, to]
970
1377
  // transition (issue #96). Split out so show/hide/toggle share it.
971
1378
  function setHidden(el, hidden, args) {
@@ -993,6 +1400,31 @@ function guardAttr(name) {
993
1400
  return false
994
1401
  }
995
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
+
996
1428
  // The first focusable descendant of `el`, in document order — the natural
997
1429
  // keyboard target inside an opened menu/dialog. Covers the standard focusable
998
1430
  // set; :not([tabindex="-1"]) drops explicitly-removed nodes. Returns null when
@@ -1025,9 +1457,13 @@ function parseOps(raw) {
1025
1457
  // record(s) whose descriptor matches the firing event: event.type, the key
1026
1458
  // filter, and window-boundness.
1027
1459
 
1028
- // Stimulus's default keyMappings (letters and digits map to themselves). A
1460
+ // Stimulus's default keyMappings (letters and digits map to themselves), plus
1461
+ // delete and backspace (issue #366): Stimulus lacks those two, so a shortcut
1462
+ // naming one binds a bare keydown and only shortcutApplies matches it. A
1029
1463
  // custom Stimulus schema is not mirrored — an unknown filter never matches.
1030
1464
  const KEY_FILTER_MAP = Object.freeze({
1465
+ backspace: "Backspace",
1466
+ delete: "Delete",
1031
1467
  enter: "Enter",
1032
1468
  tab: "Tab",
1033
1469
  esc: "Escape",
@@ -1070,7 +1506,9 @@ function keyFilterMatches(filter, event, keyMappings) {
1070
1506
  // keydown whose `mod` is meta on Apple platforms and ctrl elsewhere. Ruby
1071
1507
  // renders a meta twin and a ctrl twin; Stimulus filters the key, and this
1072
1508
  // decides whether the twin that fired is this platform's and whether the
1073
- // target is one the user is typing in. Either miss is a complete no-op.
1509
+ // target is one the user is typing in. Either miss is a complete no-op. A
1510
+ // delete/backspace chord (issue #366) has no twins and no Stimulus filter:
1511
+ // its bare keydown hears every key, and this check alone picks the chord.
1074
1512
  // (Stimulus typecasts a digit shortcut, "1", to a number: hence String().)
1075
1513
  const shortcutKeys = (spec) => (spec && typeof spec === "object" ? spec.keys : String(spec))
1076
1514
  // A shortcut record keeps a bare `on: "keydown"`, so bindingMatches lets every
@@ -1229,7 +1667,10 @@ function applyOps(list, resolveTargets, onZeroTargets) {
1229
1667
  }
1230
1668
  const targets = resolveTargets(args)
1231
1669
  if (targets.length === 0 && onZeroTargets) onZeroTargets(name, args)
1232
- for (const el of targets) CLIENT_OPS[name](el, args, resolveTargets)
1670
+ for (const el of targets) {
1671
+ CLIENT_OPS[name](el, args, resolveTargets)
1672
+ if (args?.keep === true) keepResult(el, name, args, resolveTargets)
1673
+ }
1233
1674
  }
1234
1675
  }
1235
1676
 
@@ -1374,8 +1815,8 @@ function formNeeded(root) {
1374
1815
  // Show bindings and cross-root show targets, completion bindings, option
1375
1816
  // filtering, the bulk-selection group bindings (enable, select-all, count —
1376
1817
  // issue #319) and their cross-root targets (#343), the tag-chip input, draft
1377
- // nested rows and editable text's keys and paste (#351): a form's client-only
1378
- // 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
1379
1820
  // owns an element that does. (The probes the connect-time gates ran before
1380
1821
  // the split, unchanged.)
1381
1822
  function bindingsNeeded(root) {
@@ -1385,7 +1826,7 @@ function bindingsNeeded(root) {
1385
1826
  ) ||
1386
1827
  (root.querySelectorAll?.("[data-reactive-show-field], [data-reactive-show]") ?? []).length > 0 ||
1387
1828
  root.querySelector?.(
1388
- "[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]",
1389
1830
  ) != null
1390
1831
  )
1391
1832
  }
@@ -1965,6 +2406,9 @@ export default class extends Controller {
1965
2406
  if (__SPLIT__) this.#loadFeatures()
1966
2407
  else this.#connectFeaturesNow()
1967
2408
 
2409
+ // A dialog the server rendered open with data-reactive-modal (issue #365).
2410
+ upgradeModals(this.element)
2411
+
1968
2412
  // LAST, after every feature above is wired: a replayed trigger must find
1969
2413
  // the controller exactly as a live event after connect would.
1970
2414
  this.#announceConnected()
@@ -2216,6 +2660,8 @@ export default class extends Controller {
2216
2660
  plain: plaintextSupported,
2217
2661
  text: editableValue,
2218
2662
  served: serverText,
2663
+ // Copy one <img> onto another (issue #373): the reactive_media binding.
2664
+ copyMedia,
2219
2665
  // The confirm gate, as dispatch() runs it: the resolver inside the chain
2220
2666
  // (a synchronous throw is a cancel), never a rejection out.
2221
2667
  confirm: (message, context) =>
@@ -2432,19 +2878,22 @@ export default class extends Controller {
2432
2878
  // a per-controller promise makes each dispatch wait for the previous one, so
2433
2879
  // it always uses the freshest token.
2434
2880
  dispatch(event) {
2881
+ // A shortcut (issue #357) that is the other platform's twin, or pressed
2882
+ // in a field the user is typing in, is a complete no-op: the native
2883
+ // default (text undo) proceeds and nothing is announced. Checked FIRST: a
2884
+ // delete/backspace shortcut (issue #366) binds a bare window keydown, so
2885
+ // every key the user presses lands here.
2886
+ const shortcut = event.params?.shortcut
2887
+ if (shortcut != null && !shortcutApplies(shortcut, event, this.application?.schema?.keyMappings)) return
2435
2888
  // A :once trigger already replayed on connect (issue #273) is spent.
2436
2889
  if (earlyOnceSwallows(event, "dispatch", this.application?.schema?.keyMappings, this.element)) return
2437
2890
  // `window` (renamed: never shadow the global) and `outside` are the event-
2438
2891
  // modifier params (issue #80). The client decides preventDefault behavior
2439
2892
  // from event.params — set by the Ruby on() — never by sniffing the
2440
2893
  // Stimulus descriptor.
2441
- const { action, params, debounce, throttle, confirm, confirmWhen, outside, window: windowBound, optimistic, shortcut } =
2894
+ const { action, params, debounce, throttle, confirm, confirmWhen, outside, window: windowBound, optimistic } =
2442
2895
  event.params
2443
2896
  if (!action) return
2444
- // A shortcut (issue #357) that is the other platform's twin, or pressed
2445
- // in a field the user is typing in, is a complete no-op: the native
2446
- // default (text undo) proceeds and nothing is announced.
2447
- if (shortcut != null && !shortcutApplies(shortcut, event, this.application?.schema?.keyMappings)) return
2448
2897
 
2449
2898
  // The pending-state hint (issue #181): data-reactive-busy-param. During a
2450
2899
  // deploy overlap a page rendered by the PREVIOUS gem still emits the old
@@ -2600,6 +3049,12 @@ export default class extends Controller {
2600
3049
  // the component resets whatever they toggled (by design — a signed action
2601
3050
  // owns state that must survive re-renders).
2602
3051
  runOps(event) {
3052
+ // A lone shortcut record (Stimulus typecasts it to an object) whose chord
3053
+ // did not fire bails before any bookkeeping: a delete/backspace shortcut
3054
+ // (issue #366) binds a bare window keydown that hears every key. Several
3055
+ // records are checked one by one below.
3056
+ const lone = event.params?.ops
3057
+ if (lone?.shortcut != null && !shortcutApplies(lone.shortcut, event, this.application?.schema?.keyMappings)) return
2603
3058
  if (earlyOnceSwallows(event, "runOps", this.application?.schema?.keyMappings, this.element)) return
2604
3059
  if (bindingsAlreadyRan(event, this)) return
2605
3060
  const params = event.params ?? {}
@@ -3205,6 +3660,9 @@ export default class extends Controller {
3205
3660
  presenceNeeded(this.element) &&
3206
3661
  document.getElementById(`reactive-presence-src-${this.element.id}`)?.getAttribute("connection-id")
3207
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
3208
3666
 
3209
3667
  // ONLY `fetch` itself is the network boundary — offline, DNS, a reset
3210
3668
  // connection. Everything below this inner try (reading the body,
@@ -3286,7 +3744,7 @@ export default class extends Controller {
3286
3744
  if (__SPLIT__) this.#devtools((devtools) => devtools.recordBody(debug, errorBody, fresh))
3287
3745
  else devtoolsFeature.recordBody(debug, errorBody, fresh)
3288
3746
  }
3289
- window.Turbo.renderStreamMessage(errorBody)
3747
+ renderOwnReply(this.element, errorBody, false)
3290
3748
  }
3291
3749
  this.#markError("http")
3292
3750
  this.#emitError(action, params, allParams, { kind: "http", status: response.status, body: errorBody })
@@ -3317,8 +3775,9 @@ export default class extends Controller {
3317
3775
  // Turbo applies the <turbo-stream> ops by id. A plain replace is an
3318
3776
  // outerHTML swap (focus on the replaced subtree is lost); a method="morph"
3319
3777
  // replace (Response.morph) or an update morphs in place, preserving the
3320
- // focused input + caret on unchanged nodes — see issue #28.
3321
- window.Turbo.renderStreamMessage(html)
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)
3322
3781
  // Debug-only (issue #181): the morph may apply a microtask later, so check
3323
3782
  // the resurrected-hide case AFTER it lands. Off the debug path, resurrect is
3324
3783
  // null — zero cost.