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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +114 -0
- data/README.md +91 -11
- 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/early.js +12 -4
- data/app/javascript/phlex/reactive/early.min.js +2 -2
- data/app/javascript/phlex/reactive/early.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 +479 -20
- data/lib/phlex/reactive/component/helpers.rb +30 -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 +162 -29
- data/lib/phlex/reactive/shortcut.rb +18 -5
- data/lib/phlex/reactive/streamable.rb +31 -2
- data/lib/phlex/reactive/test_helpers.rb +11 -2
- data/lib/phlex/reactive/version.rb +1 -1
- metadata +3 -1
|
@@ -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 (!
|
|
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" || !
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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)
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|