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.
@@ -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
- // unheard: read it off the element once. naturalWidth 0 is also a loaded
587
- // SVG with only a viewBox, so decode() decides (it rejects a broken image).
588
- for (const img of document.querySelectorAll?.(`img[${IMAGE_FALLBACK}]`) ?? []) {
589
- if (!img.complete || !hasImageSrc(img)) continue
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
- if (input.closest?.('[data-reactive-verbose="true"], [data-reactive-debug="true"]')) {
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
- if (!reads) return args
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 = detail ? { ...args.detail } : {}
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)
@@ -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
- return op.to_s if arg(args, "detail_from") || TARGET_KEYS.any? { arg(args, it) == TRIGGER_SENTINEL }
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 String, never
676
- # :root or its "@root" sentinel (the root is a container, not the img —
677
- # and a name: on it would replace the whole component with a file name).
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
- return selector if selector.is_a?(String) && !selector.strip.empty? && selector != ROOT_SENTINEL
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.is_a?(Hash) ? detail.keys.map(&:to_s) : []
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)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Phlex
4
4
  module Reactive
5
- VERSION = "0.16.9"
5
+ VERSION = "0.16.11"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: phlex-reactive
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.16.9
4
+ version: 0.16.11
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson