phlex-reactive 0.16.9 → 0.16.11
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 +65 -1
- data/README.md +33 -6
- data/app/javascript/phlex/reactive/core.min.js +4 -4
- data/app/javascript/phlex/reactive/core.min.js.map +3 -3
- data/app/javascript/phlex/reactive/reactive_controller.min.js +4 -4
- data/app/javascript/phlex/reactive/reactive_controller.min.js.map +3 -3
- data/app/javascript/phlex/reactive/runtime.js +111 -11
- data/lib/phlex/reactive/component/helpers.rb +15 -0
- data/lib/phlex/reactive/js.rb +48 -9
- data/lib/phlex/reactive/version.rb +1 -1
- metadata +1 -1
|
@@ -449,7 +449,9 @@ export function __resetReactiveKeepForTest() {
|
|
|
449
449
|
// a morph that changes the src lets the browser try again. A morph that keeps
|
|
450
450
|
// the src puts the state back. An error with no src is not an error (a
|
|
451
451
|
// removed or emptied src). One document listener, capture phase (neither
|
|
452
|
-
// event bubbles); nothing per root at connect.
|
|
452
|
+
// event bubbles); nothing per root at connect. An img that finished before it
|
|
453
|
+
// was in the document (before registration, or while Turbo held it imported
|
|
454
|
+
// but not yet inserted, issue #452) is read off the element instead.
|
|
453
455
|
const IMAGE_FALLBACK = "data-reactive-image-fallback"
|
|
454
456
|
const IMAGE_STATE = "data-reactive-image-state"
|
|
455
457
|
// Marks the `hidden` the fallback set, so a reset removes only that one. On
|
|
@@ -580,13 +582,36 @@ export function registerReactiveImageFallback() {
|
|
|
580
582
|
if (wire) setImageState(img, wire, value)
|
|
581
583
|
})
|
|
582
584
|
},
|
|
585
|
+
// Content Turbo inserts was imported into the document first, a few awaits
|
|
586
|
+
// earlier (issue #452): an img loads from then on, so one whose request
|
|
587
|
+
// ended in between fired its event detached, unheard. Read it off the
|
|
588
|
+
// element once the content is in: the frame, the page, a stream's render.
|
|
589
|
+
"turbo:frame-render": (event) => scanImageFallbacks(event.target),
|
|
590
|
+
"turbo:render": () => scanImageFallbacks(document),
|
|
591
|
+
"turbo:before-stream-render": (event) => {
|
|
592
|
+
const detail = event.detail
|
|
593
|
+
const render = detail?.render
|
|
594
|
+
const stream = detail?.newStream ?? event.target
|
|
595
|
+
if (typeof render !== "function" || !stream?.querySelector?.("template")?.content?.querySelector?.(`img[${IMAGE_FALLBACK}]`)) return
|
|
596
|
+
// The whole document: a replace's target is gone by then.
|
|
597
|
+
detail.render = async (el) => {
|
|
598
|
+
await render(el)
|
|
599
|
+
scanImageFallbacks(document)
|
|
600
|
+
}
|
|
601
|
+
},
|
|
583
602
|
}
|
|
584
603
|
for (const [name, fn] of Object.entries(imageFallbackListeners)) document.addEventListener(name, fn, true)
|
|
585
|
-
// An image that finished before this ran (a cached 404) fired its event
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
604
|
+
// An image that finished before this ran (a cached 404) fired its event unheard.
|
|
605
|
+
scanImageFallbacks(document)
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// Read the result of every marked img in `root` that has no state yet and has
|
|
609
|
+
// finished; one still loading fires its own event, connected. naturalWidth 0
|
|
610
|
+
// is also a loaded SVG with only a viewBox, so decode() decides (it rejects a
|
|
611
|
+
// broken image, and fetches nothing).
|
|
612
|
+
function scanImageFallbacks(root) {
|
|
613
|
+
for (const img of root?.querySelectorAll?.(`img[${IMAGE_FALLBACK}]`) ?? []) {
|
|
614
|
+
if (img.getAttribute(IMAGE_STATE) != null || !img.complete || !hasImageSrc(img)) continue
|
|
590
615
|
// A decode() that settles after the src changed decoded the OLD one: ignore it.
|
|
591
616
|
const was = `${img.getAttribute("src")} ${img.getAttribute("srcset")}`
|
|
592
617
|
const mark = (value) => `${img.getAttribute("src")} ${img.getAttribute("srcset")}` === was && setImageState(img, imageFallback(img) ?? {}, value)
|
|
@@ -1060,6 +1085,7 @@ export function registerReactiveActions() {
|
|
|
1060
1085
|
registerReactiveMorphFocus()
|
|
1061
1086
|
registerReactiveModals()
|
|
1062
1087
|
registerReactiveKeep()
|
|
1088
|
+
registerReactiveRejectedFiles()
|
|
1063
1089
|
registerReactiveImageFallback()
|
|
1064
1090
|
registerReactivePlaintext()
|
|
1065
1091
|
registerReactiveOffline()
|
|
@@ -1555,17 +1581,80 @@ function previewFile(input, args, resolveTargets) {
|
|
|
1555
1581
|
if (args.name != null) {
|
|
1556
1582
|
for (const node of resolveTargets({ ...args, to: args.name })) node.textContent = file ? file.name : ""
|
|
1557
1583
|
}
|
|
1584
|
+
// Written before any rejection is dispatched: a handler sees the list filled.
|
|
1585
|
+
if (args.rejected != null) listRejected(input, resolveTargets({ ...args, to: args.rejected }), rejected, args.rejected)
|
|
1558
1586
|
for (const detail of rejected) input.dispatchEvent(new CustomEvent("reactive:file-rejected", { bubbles: true, detail }))
|
|
1559
1587
|
}
|
|
1560
1588
|
|
|
1589
|
+
// --- Name each refused file (issue #445) ---
|
|
1590
|
+
// rejected: is a container holding an app-authored <template>. Per refused
|
|
1591
|
+
// file, in pick order: a pure deep clone of it, the file's name written as
|
|
1592
|
+
// textContent into every [data-reactive-file="name"] (a file name is user
|
|
1593
|
+
// input: never HTML, never an attribute), and only the
|
|
1594
|
+
// [data-reactive-file-reason] parts for that file's reason kept. Each
|
|
1595
|
+
// top-level element of a clone is marked and appended (top-level text is
|
|
1596
|
+
// dropped). Every run removes the marked entries first, and only those, so
|
|
1597
|
+
// a heading and the <template> stay. The entries are always the container's
|
|
1598
|
+
// last children, which is what the morph listener below checks.
|
|
1599
|
+
const REJECTED_ENTRY = "data-reactive-file-rejected"
|
|
1600
|
+
|
|
1601
|
+
function listRejected(input, lists, rejected, selector) {
|
|
1602
|
+
if (lists.length === 0) return warnPreviewVerbose(input, `rejected: ${JSON.stringify(selector)} is not on the page`)
|
|
1603
|
+
for (const list of lists) {
|
|
1604
|
+
for (const entry of [...list.children]) if (entry.hasAttribute(REJECTED_ENTRY)) entry.remove()
|
|
1605
|
+
const template = [...list.children].find((child) => child.localName === "template")
|
|
1606
|
+
if (!template) {
|
|
1607
|
+
warnPreviewVerbose(input, `rejected: ${JSON.stringify(selector)} has no <template> child`)
|
|
1608
|
+
continue
|
|
1609
|
+
}
|
|
1610
|
+
for (const { reason, file } of rejected) {
|
|
1611
|
+
const entry = template.content.cloneNode(true)
|
|
1612
|
+
for (const part of entry.querySelectorAll("[data-reactive-file-reason]")) {
|
|
1613
|
+
if (part.getAttribute("data-reactive-file-reason") !== reason) part.remove()
|
|
1614
|
+
}
|
|
1615
|
+
for (const slot of entry.querySelectorAll('[data-reactive-file="name"]')) slot.textContent = file.name
|
|
1616
|
+
for (const node of [...entry.childNodes]) {
|
|
1617
|
+
if (node.nodeType !== 1) continue
|
|
1618
|
+
node.setAttribute(REJECTED_ENTRY, "")
|
|
1619
|
+
list.append(node)
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
// A morph keeps the list (issue #445). The action that follows the pick on
|
|
1626
|
+
// the same change replies with a morph whose render of the container has no
|
|
1627
|
+
// entries, and Idiomorph would remove them. Before it syncs the container's
|
|
1628
|
+
// children, copies of the entries go into the new render, so the sync keeps
|
|
1629
|
+
// them; the next pick still replaces them. A plain replace is a new element,
|
|
1630
|
+
// and the server's render of it stands. The check reads the DOM (the last
|
|
1631
|
+
// child is an entry), not a record of what the op listed, so a container
|
|
1632
|
+
// Turbo restored from its cache (new nodes, same entries) keeps them too: one
|
|
1633
|
+
// lastElementChild per morphed element.
|
|
1634
|
+
let rejectedFilesRegistered = false
|
|
1635
|
+
|
|
1636
|
+
export function registerReactiveRejectedFiles() {
|
|
1637
|
+
if (rejectedFilesRegistered) return
|
|
1638
|
+
if (typeof document === "undefined" || typeof document.addEventListener !== "function") return
|
|
1639
|
+
rejectedFilesRegistered = true
|
|
1640
|
+
document.addEventListener("turbo:before-morph-element", (event) => {
|
|
1641
|
+
const list = event.target
|
|
1642
|
+
const next = event.detail?.newElement // absent when Turbo asks before a removal
|
|
1643
|
+
if (!next || event.defaultPrevented || !list.lastElementChild?.hasAttribute(REJECTED_ENTRY)) return
|
|
1644
|
+
for (const entry of list.children) if (entry.hasAttribute(REJECTED_ENTRY)) next.append(entry.cloneNode(true))
|
|
1645
|
+
})
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
export function __resetReactiveRejectedFilesForTest() {
|
|
1649
|
+
rejectedFilesRegistered = false
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1561
1652
|
// Rebuild input.files from the kept files (issue #439); true once it did. A
|
|
1562
1653
|
// DataTransfer is the one way to make a FileList; without one the refused
|
|
1563
1654
|
// files stay in the input (the rejections still fire), and a verbose root is told.
|
|
1564
1655
|
function keepFiles(input, kept) {
|
|
1565
1656
|
if (typeof DataTransfer !== "function") {
|
|
1566
|
-
|
|
1567
|
-
warnPreview("per_file: has no DataTransfer to drop the refused files from the input")
|
|
1568
|
-
}
|
|
1657
|
+
warnPreviewVerbose(input, "per_file: has no DataTransfer to drop the refused files from the input")
|
|
1569
1658
|
return false
|
|
1570
1659
|
}
|
|
1571
1660
|
const transfer = new DataTransfer()
|
|
@@ -1601,6 +1690,11 @@ function warnPreview(why) {
|
|
|
1601
1690
|
console.warn(`[phlex-reactive] preview_file ${why} — skipped`)
|
|
1602
1691
|
}
|
|
1603
1692
|
|
|
1693
|
+
// Only a verbose (or debug) root is told: the op still did all it could.
|
|
1694
|
+
function warnPreviewVerbose(input, why) {
|
|
1695
|
+
if (input.closest?.('[data-reactive-verbose="true"], [data-reactive-debug="true"]')) warnPreview(why)
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1604
1698
|
// Revoke the URL preview_file made for `img`, if it made one.
|
|
1605
1699
|
function forgetPreview(img) {
|
|
1606
1700
|
const url = previews.get(img)
|
|
@@ -2002,7 +2096,13 @@ const READ_OPS = new Set(["set_value", "text", "set_attr"])
|
|
|
2002
2096
|
function readArgs(name, args, resolveTargets, onZeroTargets) {
|
|
2003
2097
|
const detail = name === "dispatch"
|
|
2004
2098
|
const reads = detail ? args.detail_from : READ_OPS.has(name) && args.from != null && { value: args.attr }
|
|
2005
|
-
|
|
2099
|
+
// An empty detail_from reads nothing (the builder never writes one).
|
|
2100
|
+
if (!reads || (detail && !Object.keys(reads).length)) return args
|
|
2101
|
+
// The reads merge over detail as an object: a String or an Array would be
|
|
2102
|
+
// spread key by key (the builder refuses one; a hand-built one is warned).
|
|
2103
|
+
const base = detail ? (args.detail ?? {}) : {}
|
|
2104
|
+
if (typeof base != "object" || Array.isArray(base))
|
|
2105
|
+
return console.warn(`[phlex-reactive] refused client read over detail ${JSON.stringify(base)} — skipped`), null
|
|
2006
2106
|
const from = detail ? "@trigger" : args.from
|
|
2007
2107
|
let source
|
|
2008
2108
|
// A from that does not parse is a miss like one that matches nothing (the
|
|
@@ -2011,7 +2111,7 @@ function readArgs(name, args, resolveTargets, onZeroTargets) {
|
|
|
2011
2111
|
source = resolveTargets({ ...args, to: from })[0]
|
|
2012
2112
|
} catch {}
|
|
2013
2113
|
if (!source) return onZeroTargets?.(name, { ...args, to: from }), null
|
|
2014
|
-
const values =
|
|
2114
|
+
const values = { ...base }
|
|
2015
2115
|
for (const [key, attr] of Object.entries(reads)) {
|
|
2016
2116
|
const value = readValue(source, attr)
|
|
2017
2117
|
if (value == null) return null
|
|
@@ -1656,6 +1656,12 @@ module Phlex
|
|
|
1656
1656
|
"#{label} within: delegates from the bound element's own subtree — it cannot be " \
|
|
1657
1657
|
"window-bound; drop #{name}"
|
|
1658
1658
|
end
|
|
1659
|
+
if CLIENT_WITHIN_NON_BUBBLING.include?(event)
|
|
1660
|
+
raise ArgumentError,
|
|
1661
|
+
"#{label} within: #{event} does not bubble, so the bound element never hears it from a " \
|
|
1662
|
+
"row — bind it on the rows, or use a bubbling twin where there is one (focusin/focusout, " \
|
|
1663
|
+
"mouseover/mouseout, pointerover/pointerout)"
|
|
1664
|
+
end
|
|
1659
1665
|
return unless once
|
|
1660
1666
|
|
|
1661
1667
|
raise ArgumentError,
|
|
@@ -1663,6 +1669,15 @@ module Phlex
|
|
|
1663
1669
|
"first event, so a first event outside every match would leave the binding dead"
|
|
1664
1670
|
end
|
|
1665
1671
|
|
|
1672
|
+
# Issue #442: the common events that do not bubble — a within: binding
|
|
1673
|
+
# on the container would never fire for them. Not exhaustive (media
|
|
1674
|
+
# has more); the ones a list's rows plausibly fire.
|
|
1675
|
+
CLIENT_WITHIN_NON_BUBBLING = %w[
|
|
1676
|
+
focus blur mouseenter mouseleave pointerenter pointerleave load error scroll toggle beforetoggle
|
|
1677
|
+
invalid close cancel play pause ended
|
|
1678
|
+
].freeze
|
|
1679
|
+
private_constant :CLIENT_WITHIN_NON_BUBBLING
|
|
1680
|
+
|
|
1666
1681
|
# Issue #393: the one message on() and on_client() raise when given both
|
|
1667
1682
|
# waits. Built only on that error path (on() is a hot path).
|
|
1668
1683
|
def both_waits_message(label)
|
data/lib/phlex/reactive/js.rb
CHANGED
|
@@ -62,6 +62,9 @@ module Phlex
|
|
|
62
62
|
# an element (where a server-pushed op has no @trigger to put).
|
|
63
63
|
READ_OPS = %w[set_value text set_attr].freeze
|
|
64
64
|
TARGET_KEYS = %w[to from expanded submitter].freeze
|
|
65
|
+
# preview_file's into:/name:/wrap: name elements too (its name is no
|
|
66
|
+
# attribute or event name there).
|
|
67
|
+
PREVIEW_TARGET_KEYS = (TARGET_KEYS + %w[into name wrap]).freeze
|
|
65
68
|
|
|
66
69
|
# The field-writing ops write a field the author names: never the root
|
|
67
70
|
# (a container) nor the trigger (the element clicked).
|
|
@@ -159,11 +162,17 @@ module Phlex
|
|
|
159
162
|
end
|
|
160
163
|
|
|
161
164
|
# The name of the first op that names @trigger, or nil. A raw list, or a
|
|
162
|
-
# hand-built JS.new([...]), may carry Symbol keys or no args Hash.
|
|
165
|
+
# hand-built JS.new([...]), may carry Symbol keys or no args Hash. An
|
|
166
|
+
# empty detail_from reads nothing (the client skips it too).
|
|
163
167
|
def self.trigger_op(list)
|
|
164
168
|
Array(list).each do |op, args|
|
|
165
169
|
next unless args.is_a?(::Hash)
|
|
166
|
-
|
|
170
|
+
|
|
171
|
+
reads = arg(args, "detail_from")
|
|
172
|
+
return op.to_s if reads.respond_to?(:empty?) ? !reads.empty? : reads
|
|
173
|
+
|
|
174
|
+
keys = op.to_s == "preview_file" ? PREVIEW_TARGET_KEYS : TARGET_KEYS
|
|
175
|
+
return op.to_s if keys.any? { arg(args, it) == TRIGGER_SENTINEL }
|
|
167
176
|
end
|
|
168
177
|
nil
|
|
169
178
|
end
|
|
@@ -442,9 +451,30 @@ module Phlex
|
|
|
442
451
|
# input(type: "file", id: "pick", name: "file", multiple: true, accept: "image/png",
|
|
443
452
|
# **mix(on_client(:change, js.preview_file("#pick", into: "#shot", max_bytes: 1_000_000,
|
|
444
453
|
# accept: true, per_file: true)), on(:upload, event: "change")))
|
|
454
|
+
#
|
|
455
|
+
# Name each refused file (issue #445): `rejected:` is a CSS selector (like
|
|
456
|
+
# `into:`) for a container whose first <template> child the client clones
|
|
457
|
+
# once per refused file, in pick order, before any reactive:file-rejected
|
|
458
|
+
# fires. In each clone every [data-reactive-file="name"] gets file.name as
|
|
459
|
+
# textContent (never HTML), and every [data-reactive-file-reason] part
|
|
460
|
+
# whose value is not that file's reason is removed (a reason with no part
|
|
461
|
+
# keeps only the unmarked parts; a reason part at the top level lists only
|
|
462
|
+
# the reasons it names). Each top-level element
|
|
463
|
+
# of a clone is marked data-reactive-file-rejected and appended; the next
|
|
464
|
+
# run of the op removes the marked ones first (a pick with no refusal
|
|
465
|
+
# empties the list), and a morph of the container keeps them.
|
|
466
|
+
# ul(id: "refused") do
|
|
467
|
+
# template do
|
|
468
|
+
# li do
|
|
469
|
+
# span(data: { reactive_file_reason: "size" }) { b(data: { reactive_file: "name" }); plain " is too large." }
|
|
470
|
+
# span(data: { reactive_file_reason: "type" }) { b(data: { reactive_file: "name" }); plain " is not a PNG." }
|
|
471
|
+
# end
|
|
472
|
+
# end
|
|
473
|
+
# end
|
|
474
|
+
# js.preview_file("#pick", into: "#shot", max_bytes: 1_000_000, accept: true, per_file: true, rejected: "#refused")
|
|
445
475
|
|
|
446
476
|
def preview_file(to, into:, name: nil, global: false, valid: false, wrap: nil, max_bytes: nil, accept: nil,
|
|
447
|
-
per_file: false)
|
|
477
|
+
per_file: false, rejected: nil)
|
|
448
478
|
refuse_field_target!(to, "preview_file reads a file input, not %s — pass the input's CSS selector " \
|
|
449
479
|
"(e.g. js.preview_file(\"#photo-file\", into: \"#photo\"))")
|
|
450
480
|
args = { "to" => normalize_target(to), "into" => preview_selector(:into, into) }
|
|
@@ -455,6 +485,7 @@ module Phlex
|
|
|
455
485
|
args["max_bytes"] = preview_max_bytes(max_bytes) unless max_bytes.nil?
|
|
456
486
|
args["accept"] = preview_accept(accept) unless accept.nil? || accept == false
|
|
457
487
|
args["per_file"] = true if preview_per_file(per_file)
|
|
488
|
+
args["rejected"] = preview_selector(:rejected, rejected) unless rejected.nil?
|
|
458
489
|
append("preview_file", args.freeze)
|
|
459
490
|
end
|
|
460
491
|
|
|
@@ -672,11 +703,15 @@ module Phlex
|
|
|
672
703
|
args.freeze
|
|
673
704
|
end
|
|
674
705
|
|
|
675
|
-
# preview_file's into:/name: — a non-empty CSS selector
|
|
676
|
-
# :root or its "@root" sentinel (the root is a container,
|
|
677
|
-
# and a name: on it would replace the whole component with a
|
|
706
|
+
# preview_file's into:/name:/wrap:/rejected: — a non-empty CSS selector
|
|
707
|
+
# String, never :root or its "@root" sentinel (the root is a container,
|
|
708
|
+
# not the img — and a name: on it would replace the whole component with a
|
|
709
|
+
# file name), nor "@trigger" (issue #442; the Symbol :trigger is no
|
|
710
|
+
# String): the preview goes into elements the author names.
|
|
678
711
|
def preview_selector(key, selector)
|
|
679
|
-
|
|
712
|
+
if selector.is_a?(String) && !selector.strip.empty? && ![ROOT_SENTINEL, TRIGGER_SENTINEL].include?(selector)
|
|
713
|
+
return selector
|
|
714
|
+
end
|
|
680
715
|
|
|
681
716
|
raise ArgumentError,
|
|
682
717
|
"#{self.class}: preview_file #{key}: must be a CSS selector string, got #{selector.inspect}"
|
|
@@ -758,15 +793,19 @@ module Phlex
|
|
|
758
793
|
end
|
|
759
794
|
|
|
760
795
|
# dispatch's detail_from: — detail key → read attr, the keys Strings.
|
|
761
|
-
# A key also in detail: would silently override it, so it raises
|
|
796
|
+
# A key also in detail: would silently override it, so it raises; so does
|
|
797
|
+
# a detail: that is no Hash (the browser merges the reads over it).
|
|
762
798
|
def detail_reads(detail, reads)
|
|
763
799
|
unless reads.is_a?(Hash)
|
|
764
800
|
raise ArgumentError,
|
|
765
801
|
"#{self.class}: dispatch detail_from: takes a Hash of detail keys to data-* attributes " \
|
|
766
802
|
"(or :text), got #{reads.inspect}"
|
|
767
803
|
end
|
|
804
|
+
unless detail.nil? || detail.is_a?(Hash)
|
|
805
|
+
raise ArgumentError, "#{self.class}: dispatch detail_from: merges over a Hash detail:, got #{detail.inspect}"
|
|
806
|
+
end
|
|
768
807
|
|
|
769
|
-
literal = detail.
|
|
808
|
+
literal = detail.nil? ? [] : detail.keys.map(&:to_s)
|
|
770
809
|
normalized = reads.to_h do |key, attr|
|
|
771
810
|
key = key.to_s
|
|
772
811
|
if literal.include?(key)
|