phlex-reactive 0.15.2 → 0.15.3
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 +101 -2
- data/README.md +14 -4
- data/app/javascript/phlex/reactive/core.min.js +6 -6
- data/app/javascript/phlex/reactive/core.min.js.map +3 -3
- data/app/javascript/phlex/reactive/features/bindings.js +31 -12
- 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/features/form.js +54 -22
- data/app/javascript/phlex/reactive/features/form.min.js +2 -2
- data/app/javascript/phlex/reactive/features/form.min.js.map +3 -3
- data/app/javascript/phlex/reactive/features/persist.js +31 -2
- data/app/javascript/phlex/reactive/features/persist.min.js +2 -2
- data/app/javascript/phlex/reactive/features/persist.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 +6 -6
- data/app/javascript/phlex/reactive/runtime.js +148 -9
- data/lib/phlex/reactive/component/helpers.rb +9 -5
- data/lib/phlex/reactive/component/identity.rb +34 -1
- data/lib/phlex/reactive/component/image_fallback_binding.rb +54 -0
- data/lib/phlex/reactive/component/media_binding.rb +22 -4
- data/lib/phlex/reactive/component/media_pattern.rb +79 -0
- data/lib/phlex/reactive/doctor.rb +20 -8
- data/lib/phlex/reactive/js.rb +16 -5
- data/lib/phlex/reactive/version.rb +1 -1
- metadata +3 -1
|
@@ -419,6 +419,135 @@ export function __resetReactiveKeepForTest() {
|
|
|
419
419
|
keepRegistered = false
|
|
420
420
|
}
|
|
421
421
|
|
|
422
|
+
// --- An image fallback that recovers (issue #383) ---------------------------
|
|
423
|
+
// data-reactive-image-fallback='{"hide":true,"alt":"#name"}' on an <img>. On
|
|
424
|
+
// `error` it gets data-reactive-image-state="error" (plus `hidden` with hide,
|
|
425
|
+
// and its alt shown in the `alt` node of its own reactive root); on `load`,
|
|
426
|
+
// "loaded". A later src clears the error: copy_media and preview_file reset
|
|
427
|
+
// it as they write (resetImage), any src that loads clears it on `load`, and
|
|
428
|
+
// a morph that changes the src lets the browser try again. A morph that keeps
|
|
429
|
+
// the src puts the state back. An error with no src is not an error (a
|
|
430
|
+
// removed or emptied src). One document listener, capture phase (neither
|
|
431
|
+
// event bubbles); nothing per root at connect.
|
|
432
|
+
const IMAGE_FALLBACK = "data-reactive-image-fallback"
|
|
433
|
+
const IMAGE_STATE = "data-reactive-image-state"
|
|
434
|
+
// Marks the `hidden` the fallback set, so a reset removes only that one. On
|
|
435
|
+
// the element, not in a WeakSet: a Turbo snapshot restores a NEW node.
|
|
436
|
+
const IMAGE_HIDDEN = "data-reactive-image-hidden"
|
|
437
|
+
let imageFallbackListeners = null
|
|
438
|
+
|
|
439
|
+
function imageFallback(img) {
|
|
440
|
+
if (img?.localName !== "img") return null
|
|
441
|
+
const raw = img.getAttribute(IMAGE_FALLBACK)
|
|
442
|
+
if (raw == null) return null
|
|
443
|
+
try {
|
|
444
|
+
return JSON.parse(raw) || {}
|
|
445
|
+
} catch {
|
|
446
|
+
return {}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const hasImageSrc = (img) => img.getAttribute("src") || img.getAttribute("srcset")
|
|
451
|
+
|
|
452
|
+
// The node that shows the alt text: a selector resolved inside the img's own
|
|
453
|
+
// root only (a document listener has no op scope).
|
|
454
|
+
function imageAltNode(img, wire) {
|
|
455
|
+
if (typeof wire.alt !== "string" || !wire.alt) return null
|
|
456
|
+
try {
|
|
457
|
+
const node = img.closest('[data-controller~="reactive"]')?.querySelector(wire.alt)
|
|
458
|
+
// Never a node that holds the img: its textContent would wipe the img.
|
|
459
|
+
return node && !node.contains(img) ? node : null
|
|
460
|
+
} catch {
|
|
461
|
+
return null
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function setImageState(img, wire, value) {
|
|
466
|
+
if (value === "error") {
|
|
467
|
+
if (wire.hide && !img.hasAttribute("hidden")) {
|
|
468
|
+
img.setAttribute("hidden", "")
|
|
469
|
+
img.setAttribute(IMAGE_HIDDEN, "")
|
|
470
|
+
}
|
|
471
|
+
const node = imageAltNode(img, wire)
|
|
472
|
+
if (node) {
|
|
473
|
+
node.textContent = img.getAttribute("alt") ?? ""
|
|
474
|
+
node.hidden = false
|
|
475
|
+
}
|
|
476
|
+
} else resetImage(img, wire)
|
|
477
|
+
img.setAttribute(IMAGE_STATE, value)
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// Undo what an error did — only what it did: `hidden` stays when the author
|
|
481
|
+
// set it. Called by copy_media and preview_file when they change the src.
|
|
482
|
+
function resetImage(img, wire = imageFallback(img)) {
|
|
483
|
+
if (!wire || img.getAttribute(IMAGE_STATE) == null) return
|
|
484
|
+
if (img.getAttribute(IMAGE_STATE) === "error") {
|
|
485
|
+
if (img.hasAttribute(IMAGE_HIDDEN)) img.removeAttribute("hidden")
|
|
486
|
+
const node = imageAltNode(img, wire)
|
|
487
|
+
if (node) node.hidden = true
|
|
488
|
+
}
|
|
489
|
+
img.removeAttribute(IMAGE_STATE)
|
|
490
|
+
img.removeAttribute(IMAGE_HIDDEN)
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function onImageEvent(event) {
|
|
494
|
+
const img = event.target
|
|
495
|
+
const wire = imageFallback(img)
|
|
496
|
+
if (!wire) return
|
|
497
|
+
if (event.type === "load") setImageState(img, wire, "loaded")
|
|
498
|
+
else if (hasImageSrc(img)) setImageState(img, wire, "error")
|
|
499
|
+
else resetImage(img, wire)
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
export function registerReactiveImageFallback() {
|
|
503
|
+
if (imageFallbackListeners) return
|
|
504
|
+
if (typeof document === "undefined" || typeof document.addEventListener !== "function") return
|
|
505
|
+
// A morph that keeps the src keeps the state: the render carries none, and
|
|
506
|
+
// the browser loads nothing new to tell us again. Put it back once the
|
|
507
|
+
// whole morph is done, so a later-morphed alt node is shown again too.
|
|
508
|
+
const kept = new WeakMap()
|
|
509
|
+
imageFallbackListeners = {
|
|
510
|
+
error: onImageEvent,
|
|
511
|
+
load: onImageEvent,
|
|
512
|
+
"turbo:before-morph-element": (event) => {
|
|
513
|
+
const img = event.target
|
|
514
|
+
const next = event.detail?.newElement
|
|
515
|
+
const value = img?.getAttribute?.(IMAGE_STATE)
|
|
516
|
+
if (value == null || !next || !imageFallback(img)) return
|
|
517
|
+
const same = (name) => img.getAttribute(name) === next.getAttribute?.(name)
|
|
518
|
+
if (same("src") && same("srcset")) kept.set(img, value)
|
|
519
|
+
},
|
|
520
|
+
"turbo:morph-element": (event) => {
|
|
521
|
+
const img = event.target
|
|
522
|
+
const value = kept.get(img)
|
|
523
|
+
if (value === undefined) return
|
|
524
|
+
kept.delete(img)
|
|
525
|
+
queueMicrotask(() => {
|
|
526
|
+
const wire = imageFallback(img)
|
|
527
|
+
if (wire) setImageState(img, wire, value)
|
|
528
|
+
})
|
|
529
|
+
},
|
|
530
|
+
}
|
|
531
|
+
for (const [name, fn] of Object.entries(imageFallbackListeners)) document.addEventListener(name, fn, true)
|
|
532
|
+
// An image that finished before this ran (a cached 404) fired its event
|
|
533
|
+
// unheard: read it off the element once. naturalWidth 0 is also a loaded
|
|
534
|
+
// SVG with only a viewBox, so decode() decides (it rejects a broken image).
|
|
535
|
+
for (const img of document.querySelectorAll?.(`img[${IMAGE_FALLBACK}]`) ?? []) {
|
|
536
|
+
if (!img.complete || !hasImageSrc(img)) continue
|
|
537
|
+
// A decode() that settles after the src changed decoded the OLD one: ignore it.
|
|
538
|
+
const was = `${img.getAttribute("src")} ${img.getAttribute("srcset")}`
|
|
539
|
+
const mark = (value) => `${img.getAttribute("src")} ${img.getAttribute("srcset")}` === was && setImageState(img, imageFallback(img) ?? {}, value)
|
|
540
|
+
if (img.naturalWidth > 0) mark("loaded")
|
|
541
|
+
else if (typeof img.decode === "function") img.decode().then(() => mark("loaded"), () => mark("error"))
|
|
542
|
+
else mark("error")
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
export function __resetReactiveImageFallbackForTest() {
|
|
547
|
+
for (const [name, fn] of Object.entries(imageFallbackListeners ?? {})) document.removeEventListener(name, fn, true)
|
|
548
|
+
imageFallbackListeners = null
|
|
549
|
+
}
|
|
550
|
+
|
|
422
551
|
// For the opt-in entry: what to do with a stream before its render — hold it
|
|
423
552
|
// for a module the incoming content needs. `handle(event, effects)` gets the
|
|
424
553
|
// loaded effects module (or undefined) and wraps the stream with it itself.
|
|
@@ -860,6 +989,7 @@ export function registerReactiveActions() {
|
|
|
860
989
|
registerReactiveMorphFocus()
|
|
861
990
|
registerReactiveModals()
|
|
862
991
|
registerReactiveKeep()
|
|
992
|
+
registerReactiveImageFallback()
|
|
863
993
|
registerReactivePlaintext()
|
|
864
994
|
registerReactiveOffline()
|
|
865
995
|
registerReactiveDev()
|
|
@@ -1296,7 +1426,11 @@ function previewFile(input, args, resolveTargets) {
|
|
|
1296
1426
|
if (img?.tagName !== "IMG") {
|
|
1297
1427
|
return warnPreview(`paints an <img>, and ${JSON.stringify(args.into)} is ${img ? `a <${img.localName}>` : "not on the page"}`)
|
|
1298
1428
|
}
|
|
1299
|
-
|
|
1429
|
+
// valid: true (issue #382): an input the app marked invalid (setCustomValidity,
|
|
1430
|
+
// required) previews as empty. validity.valid, not checkValidity(): no
|
|
1431
|
+
// `invalid` event fired into the app's own listeners.
|
|
1432
|
+
const files = args.valid === true && input.validity?.valid === false ? [] : (input.files ?? [])
|
|
1433
|
+
const file = [...files].find((f) => typeof f?.type === "string" && f.type.startsWith("image/"))
|
|
1300
1434
|
forgetPreview(img)
|
|
1301
1435
|
if (file) {
|
|
1302
1436
|
const url = URL.createObjectURL(file)
|
|
@@ -1304,6 +1438,7 @@ function previewFile(input, args, resolveTargets) {
|
|
|
1304
1438
|
img.setAttribute("src", url)
|
|
1305
1439
|
watchPreviews()
|
|
1306
1440
|
} else img.removeAttribute("src")
|
|
1441
|
+
resetImage(img)
|
|
1307
1442
|
if (args.name == null) return
|
|
1308
1443
|
for (const node of resolveTargets({ ...args, to: args.name })) node.textContent = file ? file.name : ""
|
|
1309
1444
|
}
|
|
@@ -1404,7 +1539,9 @@ function guardAttr(name) {
|
|
|
1404
1539
|
// copy_media op and the reactive_media binding (the bindings feature reaches it
|
|
1405
1540
|
// through the core handle). Only an <img> target is written, so an iframe,
|
|
1406
1541
|
// script, object, embed or frame never receives a src from here. A source that
|
|
1407
|
-
// is missing
|
|
1542
|
+
// is missing or not an <img> clears the target. An <img> source without src is
|
|
1543
|
+
// a hit with no image (issue #381): the target loses src and srcset but takes
|
|
1544
|
+
// the alt, so a known entry without a logo keeps its name. Each attribute is
|
|
1408
1545
|
// written only when it changes, so the same URL twice does not reload. Returns
|
|
1409
1546
|
// the alt copied, false when it cleared, or null when the target was skipped.
|
|
1410
1547
|
function copyMedia(target, source) {
|
|
@@ -1412,17 +1549,19 @@ function copyMedia(target, source) {
|
|
|
1412
1549
|
console.warn(`[phlex-reactive] copy_media targets an <img>, not <${target?.localName}> — skipped`)
|
|
1413
1550
|
return null
|
|
1414
1551
|
}
|
|
1415
|
-
const
|
|
1416
|
-
const
|
|
1552
|
+
const img = source?.localName === "img" ? source : null
|
|
1553
|
+
const src = img?.getAttribute("src")
|
|
1554
|
+
let changed = false
|
|
1417
1555
|
for (const name of ["srcset", "src"]) {
|
|
1418
|
-
const value =
|
|
1556
|
+
const value = src ? img.getAttribute(name) : null
|
|
1419
1557
|
if (value == null) {
|
|
1420
|
-
if (target.hasAttribute(name)) target.removeAttribute(name)
|
|
1421
|
-
} else if (target.getAttribute(name) !== value) target.setAttribute(name, value)
|
|
1558
|
+
if (target.hasAttribute(name)) changed = target.removeAttribute(name) ?? true
|
|
1559
|
+
} else if (target.getAttribute(name) !== value) changed = target.setAttribute(name, value) ?? true
|
|
1422
1560
|
}
|
|
1423
|
-
|
|
1561
|
+
if (changed) resetImage(target)
|
|
1562
|
+
const alt = img?.getAttribute("alt") ?? ""
|
|
1424
1563
|
if (target.getAttribute("alt") !== alt) target.setAttribute("alt", alt)
|
|
1425
|
-
return
|
|
1564
|
+
return img ? alt : false
|
|
1426
1565
|
}
|
|
1427
1566
|
|
|
1428
1567
|
// The first focusable descendant of `el`, in document order — the natural
|
|
@@ -16,6 +16,7 @@ module Phlex
|
|
|
16
16
|
include Sortable
|
|
17
17
|
include Editable
|
|
18
18
|
include MediaBinding
|
|
19
|
+
include ImageFallbackBinding
|
|
19
20
|
|
|
20
21
|
# The acting client's SSE connection id during the current action (nil
|
|
21
22
|
# outside an action, or when the client isn't subscribed to a stream).
|
|
@@ -1766,13 +1767,16 @@ module Phlex
|
|
|
1766
1767
|
|
|
1767
1768
|
private_constant :REMOVED_ON_KWARGS, :PENDING_CLASS_OPS
|
|
1768
1769
|
|
|
1770
|
+
# Runs on every on() (a hot path): a key lookup per removed name, no arrays.
|
|
1769
1771
|
def reject_removed_on_kwargs!(action_name, params)
|
|
1770
|
-
|
|
1771
|
-
return if removed.empty?
|
|
1772
|
+
return if params.empty?
|
|
1772
1773
|
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1774
|
+
REMOVED_ON_KWARGS.each_key do
|
|
1775
|
+
next unless params.key?(it)
|
|
1776
|
+
|
|
1777
|
+
raise ArgumentError,
|
|
1778
|
+
"on(#{action_name.inspect}) #{it}: was removed in issue #181 — use #{REMOVED_ON_KWARGS[it]}"
|
|
1779
|
+
end
|
|
1776
1780
|
end
|
|
1777
1781
|
|
|
1778
1782
|
# Issue #357: parse on()'s shortcut: (nil when absent). A shortcut
|
|
@@ -18,6 +18,16 @@ module Phlex
|
|
|
18
18
|
module Identity
|
|
19
19
|
extend ActiveSupport::Concern
|
|
20
20
|
|
|
21
|
+
# Whether the installed GlobalID gives a class its own app
|
|
22
|
+
# (global_id_app, then GlobalID.app — rails/globalid after 1.4).
|
|
23
|
+
# Read once: it is a fact about the loaded gem.
|
|
24
|
+
def self.per_class_gid_app?
|
|
25
|
+
return @per_class_gid_app if defined?(@per_class_gid_app)
|
|
26
|
+
|
|
27
|
+
@per_class_gid_app = ::GlobalID::Identification.const_defined?(:ClassMethods, false) &&
|
|
28
|
+
::GlobalID::Identification::ClassMethods.method_defined?(:global_id_app)
|
|
29
|
+
end
|
|
30
|
+
|
|
21
31
|
class_methods do
|
|
22
32
|
# The record's instance-variable symbol (e.g. :@todo), computed once.
|
|
23
33
|
# reactive_token reads it on every render; interpolating :"@#{key}" each
|
|
@@ -74,7 +84,7 @@ module Phlex
|
|
|
74
84
|
# before to_gid (nil.to_gid would raise).
|
|
75
85
|
if (record_ivar = klass.reactive_record_ivar) &&
|
|
76
86
|
(record = instance_variable_get(record_ivar)) && signable_gid?(record)
|
|
77
|
-
payload["gid"] = record
|
|
87
|
+
payload["gid"] = reactive_gid(record)
|
|
78
88
|
end
|
|
79
89
|
|
|
80
90
|
state_ivars = klass.reactive_state_ivars
|
|
@@ -95,6 +105,29 @@ module Phlex
|
|
|
95
105
|
def signable_gid?(record)
|
|
96
106
|
!(record.respond_to?(:persisted?) && !record.persisted?)
|
|
97
107
|
end
|
|
108
|
+
|
|
109
|
+
# record.to_gid.to_s, without the URI. For a record whose to_gid is
|
|
110
|
+
# GlobalID's own (not overridden on its class or on itself) and whose
|
|
111
|
+
# id is an Integer, GlobalID's string is exactly
|
|
112
|
+
# "gid://<GlobalID.app>/<class name>/<id>": an Integer needs no
|
|
113
|
+
# escaping and there are no params. Building it directly skips a
|
|
114
|
+
# validated URI::GID (~37 objects, about half a record token's cost),
|
|
115
|
+
# once per record-backed row per render. Anything else goes through
|
|
116
|
+
# to_gid unchanged, its errors included.
|
|
117
|
+
def reactive_gid(record)
|
|
118
|
+
if record.is_a?(::GlobalID::Identification) &&
|
|
119
|
+
record.method(:to_gid).owner.equal?(::GlobalID::Identification) &&
|
|
120
|
+
(id = record.id).is_a?(Integer) && (app = reactive_gid_app(record.class)) && (name = record.class.name)
|
|
121
|
+
"gid://#{app}/#{name}/#{id}"
|
|
122
|
+
else
|
|
123
|
+
record.to_gid.to_s
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# The app to_gid would use: the class's own where GlobalID has one.
|
|
128
|
+
def reactive_gid_app(klass)
|
|
129
|
+
(Identity.per_class_gid_app? && klass.global_id_app) || ::GlobalID.app
|
|
130
|
+
end
|
|
98
131
|
end
|
|
99
132
|
end
|
|
100
133
|
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phlex
|
|
4
|
+
module Reactive
|
|
5
|
+
module Component
|
|
6
|
+
# An <img> that falls back when its src fails and recovers when the src
|
|
7
|
+
# changes (issue #383).
|
|
8
|
+
#
|
|
9
|
+
# img(id: "logo", alt: "", **reactive_image_fallback(hide: true))
|
|
10
|
+
# img(id: "logo", alt: "Bank", **reactive_image_fallback(show_alt: "#bank-name"))
|
|
11
|
+
# span(id: "bank-name", hidden: true)
|
|
12
|
+
#
|
|
13
|
+
# On `error` the client sets data-reactive-image-state="error", adds
|
|
14
|
+
# `hidden` (hide: true, the default) and, with show_alt:, writes the
|
|
15
|
+
# img's alt into that node (a CSS selector inside the root) and shows it.
|
|
16
|
+
# On `load` the state is "loaded". A later src — copy_media,
|
|
17
|
+
# reactive_media, preview_file, a morph, or any src that loads — clears
|
|
18
|
+
# the error and shows the img again; an img with no src is never an
|
|
19
|
+
# error. hide: false leaves visibility alone: style the state with CSS.
|
|
20
|
+
# Composes with reactive_media (its **attrs mix the data attribute in):
|
|
21
|
+
#
|
|
22
|
+
# img(**reactive_media(from: :rib, pool: POOL, **reactive_image_fallback))
|
|
23
|
+
#
|
|
24
|
+
# Named ImageFallbackBinding for the reason MediaBinding is (issue #233).
|
|
25
|
+
module ImageFallbackBinding
|
|
26
|
+
def reactive_image_fallback(hide: true, show_alt: nil, **attrs)
|
|
27
|
+
wire = { "hide" => image_fallback_hide!(hide) }
|
|
28
|
+
wire["alt"] = image_fallback_alt!(show_alt) unless show_alt.nil?
|
|
29
|
+
mix({ data: { reactive_image_fallback: wire.to_json } }, attrs)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def image_fallback_hide!(hide)
|
|
35
|
+
return hide if [true, false].include?(hide)
|
|
36
|
+
|
|
37
|
+
raise ArgumentError, "reactive_image_fallback hide: must be true or false, got #{hide.inspect}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# A selector the client passes to querySelector, never a URL.
|
|
41
|
+
def image_fallback_alt!(selector)
|
|
42
|
+
if selector == JS::ROOT_SENTINEL
|
|
43
|
+
raise ArgumentError,
|
|
44
|
+
"reactive_image_fallback show_alt: #{selector} names the whole root — pass the selector of a node inside it"
|
|
45
|
+
end
|
|
46
|
+
return selector if selector.is_a?(String) && !selector.strip.empty?
|
|
47
|
+
|
|
48
|
+
raise ArgumentError,
|
|
49
|
+
"reactive_image_fallback show_alt: must be a CSS selector (e.g. \"#bank-name\"), got #{selector.inspect}"
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -20,18 +20,29 @@ module Phlex
|
|
|
20
20
|
# first n characters as the key; omit it to use the whole value. A pool
|
|
21
21
|
# value is a CSS SELECTOR for an <img> inside the root, never a URL: the
|
|
22
22
|
# client copies that element's src, srcset and alt the way
|
|
23
|
-
# js.copy_media does. A key, selector or element that misses
|
|
24
|
-
# preview.
|
|
25
|
-
# image
|
|
23
|
+
# js.copy_media does. A key, selector or element that misses (or is not
|
|
24
|
+
# an <img>) clears the preview. A pool <img> with an alt and no src is a
|
|
25
|
+
# hit without an image: the preview drops its src and takes the alt
|
|
26
|
+
# (issue #381). `label:` names a node whose textContent becomes the
|
|
27
|
+
# chosen image's alt (`blank_label`, or "", on a miss). Client-only: no token,
|
|
26
28
|
# no round trip.
|
|
27
29
|
#
|
|
30
|
+
# Until the field has `take` characters, or while `match:` (a Regexp,
|
|
31
|
+
# anchored to the whole key) rejects the key, the binding is PENDING
|
|
32
|
+
# (issue #380): never `blank_label`. `pending: :clear` (the default)
|
|
33
|
+
# clears the preview and empties the label; `pending: :keep` leaves both
|
|
34
|
+
# alone. The preview's data-reactive-media-state reads pending, hit or
|
|
35
|
+
# miss, so a row can hide itself with CSS until there is an answer.
|
|
36
|
+
#
|
|
28
37
|
# Named MediaBinding, not Media: this module sits in every reactive
|
|
29
38
|
# component's ancestry, and a bare `Media` would shadow a host app's kit
|
|
30
39
|
# component of that name (issue #233).
|
|
31
40
|
module MediaBinding
|
|
32
|
-
def reactive_media(from:, pool: nil, take: nil, label: nil, blank_label: nil, **attrs)
|
|
41
|
+
def reactive_media(from:, pool: nil, take: nil, match: nil, pending: :clear, label: nil, blank_label: nil, **attrs)
|
|
33
42
|
wire = { "field" => media_field!(from) }
|
|
34
43
|
wire["take"] = media_take!(take) unless take.nil?
|
|
44
|
+
wire["match"] = MediaPattern.to_js(match) unless match.nil?
|
|
45
|
+
wire["pending"] = "keep" if media_pending!(pending) == "keep"
|
|
35
46
|
wire["pool"] = media_pool!(pool)
|
|
36
47
|
wire["label"] = media_selector!(:label, label) unless label.nil?
|
|
37
48
|
wire["blank"] = blank_label.to_s unless blank_label.nil?
|
|
@@ -54,6 +65,13 @@ module Phlex
|
|
|
54
65
|
"got #{take.inspect}"
|
|
55
66
|
end
|
|
56
67
|
|
|
68
|
+
def media_pending!(pending)
|
|
69
|
+
mode = pending.to_s if pending.is_a?(Symbol) || pending.is_a?(String)
|
|
70
|
+
return mode if %w[clear keep].include?(mode)
|
|
71
|
+
|
|
72
|
+
raise ArgumentError, "reactive_media pending: must be :clear or :keep, got #{pending.inspect}"
|
|
73
|
+
end
|
|
74
|
+
|
|
57
75
|
def media_pool!(pool)
|
|
58
76
|
if pool.nil?
|
|
59
77
|
raise ArgumentError, "reactive_media needs pool: — a Hash of key => selector of a server-rendered <img> " \
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phlex
|
|
4
|
+
module Reactive
|
|
5
|
+
module Component
|
|
6
|
+
# reactive_media's match: (issue #380) — a Ruby Regexp as the JavaScript
|
|
7
|
+
# source the client anchors as ^(?:…)$ and compiles without flags. The
|
|
8
|
+
# source is tokenized (an escape is one token, so an escaped backslash is
|
|
9
|
+
# never mistaken for one): a leading \A and a trailing \z/\Z are dropped,
|
|
10
|
+
# and anything JavaScript would read differently raises at render rather
|
|
11
|
+
# than matching silently wrong in the browser.
|
|
12
|
+
module MediaPattern
|
|
13
|
+
# Escapes Ruby reads as an anchor or a class and JS reads as a letter,
|
|
14
|
+
# plus \s/\S, whose JS class also holds Unicode spaces Ruby's does not.
|
|
15
|
+
RUBY_ONLY_ESCAPES = %w[\A \z \Z \G \h \H \R \X \K \p \P \s \S].freeze
|
|
16
|
+
# The group openers both engines read alike: (?: (?= (?! (?<= (?<! (?<name>
|
|
17
|
+
SHARED_GROUPS = [":", "=", "!", "<"].freeze
|
|
18
|
+
OPTIONS = Regexp::IGNORECASE | Regexp::EXTENDED | Regexp::MULTILINE
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
def to_js(match)
|
|
23
|
+
unless match.is_a?(Regexp)
|
|
24
|
+
raise ArgumentError,
|
|
25
|
+
"reactive_media match: must be a Regexp (e.g. /\\A\\d{3}\\z/), got #{match.inspect}"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
unless match.options.nobits?(OPTIONS)
|
|
29
|
+
refuse!(match,
|
|
30
|
+
"has options (i, m, x) the client would read differently — drop them")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
tokens = match.source.scan(/\\.|./m)
|
|
34
|
+
tokens.shift if tokens.first == "\\A"
|
|
35
|
+
final = tokens.pop if %w[\z \Z].include?(tokens.last)
|
|
36
|
+
check!(tokens, match)
|
|
37
|
+
# Ruby's \Z also matches before one final newline.
|
|
38
|
+
final == "\\Z" ? "#{tokens.join}\\n?" : tokens.join
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def check!(tokens, match)
|
|
42
|
+
in_class = false
|
|
43
|
+
tokens.each_with_index do |token, i|
|
|
44
|
+
if RUBY_ONLY_ESCAPES.include?(token)
|
|
45
|
+
refuse!(match,
|
|
46
|
+
"uses #{token}, which is Ruby-only (JavaScript reads it differently)")
|
|
47
|
+
end
|
|
48
|
+
refuse!(match, "has a character above U+FFFF (the client pattern has no u flag)") if token.ord > 0xFFFF
|
|
49
|
+
if token == "\\u" && tokens[i + 1] == "{"
|
|
50
|
+
refuse!(match,
|
|
51
|
+
"uses \\u{…}, which JavaScript without the u flag reads as a repeated u")
|
|
52
|
+
end
|
|
53
|
+
if in_class
|
|
54
|
+
in_class = false if token == "]"
|
|
55
|
+
class_syntax!(tokens, i, match)
|
|
56
|
+
elsif token == "["
|
|
57
|
+
in_class = true
|
|
58
|
+
elsif token == "(" && tokens[i + 1] == "?" && !SHARED_GROUPS.include?(tokens[i + 2])
|
|
59
|
+
refuse!(match, "uses (?#{tokens[i + 2]}…), a group JavaScript does not read the same way")
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# A [ inside a class (a nested class or a POSIX [:digit:]) and && (an
|
|
65
|
+
# intersection) are Ruby-only: JavaScript reads them as literals.
|
|
66
|
+
def class_syntax!(tokens, index, match)
|
|
67
|
+
token = tokens[index]
|
|
68
|
+
return unless token == "[" || (token == "&" && tokens[index + 1] == "&")
|
|
69
|
+
|
|
70
|
+
refuse!(match, "has a nested character class, a POSIX [:class:] or && — JavaScript reads them literally")
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def refuse!(match, why)
|
|
74
|
+
raise ArgumentError, "reactive_media match: #{match.inspect} #{why}"
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -248,7 +248,15 @@ module Phlex
|
|
|
248
248
|
# authorization / mark_authorized! call. It is :unknown, NEVER a hard fail:
|
|
249
249
|
# a helper may authorize indirectly, so this is a hint, not a verdict.
|
|
250
250
|
def authorization_check(components)
|
|
251
|
-
|
|
251
|
+
infos = nil
|
|
252
|
+
suspects = components.flat_map do
|
|
253
|
+
next [] if component_defines_authorization_method?(it)
|
|
254
|
+
|
|
255
|
+
# Read once, and only when needed: Inspector.components Prism-scans
|
|
256
|
+
# every component's actions.
|
|
257
|
+
infos ||= inspector_infos
|
|
258
|
+
unauthorized_action_labels(it, infos)
|
|
259
|
+
end
|
|
252
260
|
return Check.new(:ok, "every mutating action appears to authorize", name: :authorization) if suspects.empty?
|
|
253
261
|
|
|
254
262
|
Check.new(:unknown, "actions with no detected authorization call: #{suspects.join(", ")}",
|
|
@@ -394,13 +402,11 @@ module Phlex
|
|
|
394
402
|
end
|
|
395
403
|
|
|
396
404
|
# "Klass#action" for every declared action on `klass` that (advisory)
|
|
397
|
-
# appears unauthorized
|
|
398
|
-
# ancestry
|
|
399
|
-
# Inspector) finds no authorization / mark_authorized! call in its body.
|
|
400
|
-
def unauthorized_action_labels(klass)
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
info = Phlex::Reactive::Inspector.components.find { it.klass == klass }
|
|
405
|
+
# appears unauthorized (the caller has already ruled out an authorization
|
|
406
|
+
# method in its ancestry): the action isn't skipped, and the Prism scan (via
|
|
407
|
+
# the shared Inspector) finds no authorization / mark_authorized! call in its body.
|
|
408
|
+
def unauthorized_action_labels(klass, infos)
|
|
409
|
+
info = infos[klass]
|
|
404
410
|
return [] unless info
|
|
405
411
|
|
|
406
412
|
info.actions
|
|
@@ -417,6 +423,12 @@ module Phlex
|
|
|
417
423
|
# Phlex::Reactive.authorization_methods and NOT the Inspector's set, which
|
|
418
424
|
# folds in mark_authorized! — every component inherits that instance helper,
|
|
419
425
|
# so including it would silence the check for everything.
|
|
426
|
+
def inspector_infos
|
|
427
|
+
Phlex::Reactive::Inspector.components.to_h { [it.klass, it] }
|
|
428
|
+
rescue StandardError
|
|
429
|
+
{}
|
|
430
|
+
end
|
|
431
|
+
|
|
420
432
|
def component_defines_authorization_method?(klass)
|
|
421
433
|
Phlex::Reactive.authorization_methods.any? do
|
|
422
434
|
klass.method_defined?(it) || klass.private_method_defined?(it)
|
data/lib/phlex/reactive/js.rb
CHANGED
|
@@ -297,8 +297,17 @@ module Phlex
|
|
|
297
297
|
# that read every subscriber's file input would be hostile.
|
|
298
298
|
# input(type: "file", id: "photo-file", accept: "image/*",
|
|
299
299
|
# **on_client(:change, js.preview_file("#photo-file", into: "#photo", name: "#photo-name")))
|
|
300
|
-
|
|
301
|
-
|
|
300
|
+
#
|
|
301
|
+
# Upload validation (issue #382): `valid: true` treats an input whose
|
|
302
|
+
# validity is false (setCustomValidity, required) as empty, so a rejected
|
|
303
|
+
# file never gets a preview. An app that clears the input without a
|
|
304
|
+
# change event dispatches `reactive:file-changed` on it; bind the same op
|
|
305
|
+
# to that event too and the preview clears:
|
|
306
|
+
# op = js.preview_file("#photo-file", into: "#photo", valid: true)
|
|
307
|
+
# input(type: "file", id: "photo-file",
|
|
308
|
+
# **mix(on_client(:change, op), on_client("reactive:file-changed", op)))
|
|
309
|
+
|
|
310
|
+
def preview_file(to, into:, name: nil, global: false, valid: false)
|
|
302
311
|
if [:root, ROOT_SENTINEL].include?(to)
|
|
303
312
|
raise ArgumentError,
|
|
304
313
|
"#{self.class}: preview_file reads a file input, not the component root — " \
|
|
@@ -308,6 +317,7 @@ module Phlex
|
|
|
308
317
|
args = { "to" => normalize_target(to), "into" => preview_selector(:into, into) }
|
|
309
318
|
args["name"] = preview_selector(:name, name) unless name.nil?
|
|
310
319
|
args["global"] = true if global
|
|
320
|
+
args["valid"] = true if valid
|
|
311
321
|
append("preview_file", args.freeze)
|
|
312
322
|
end
|
|
313
323
|
|
|
@@ -331,9 +341,10 @@ module Phlex
|
|
|
331
341
|
# onto the `to` <img>. Both are ELEMENTS (:root or a CSS selector,
|
|
332
342
|
# resolved with the op's own scoping, global: included): the URL is read
|
|
333
343
|
# off the live source element in the browser and is never an argument,
|
|
334
|
-
# so the set_attr refusal of src (issue #96) stays. A missing
|
|
335
|
-
# non-<img> source
|
|
336
|
-
#
|
|
344
|
+
# so the set_attr refusal of src (issue #96) stays. A missing or
|
|
345
|
+
# non-<img> source clears the target's src, srcset and alt; an <img>
|
|
346
|
+
# source without src removes the target's src and srcset and copies its
|
|
347
|
+
# alt (issue #381); a target that is not an <img> is skipped. The same URL twice
|
|
337
348
|
# is not written again (no reload). Broadcast-safe: it only reads
|
|
338
349
|
# elements already in the subscriber's document.
|
|
339
350
|
# button(**on_client(:click, js.copy_media("#logo", from: "#logo-007"))) { "North" }
|
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.15.
|
|
4
|
+
version: 0.15.3
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Mikael Henriksson
|
|
@@ -197,8 +197,10 @@ files:
|
|
|
197
197
|
- lib/phlex/reactive/component/editable.rb
|
|
198
198
|
- lib/phlex/reactive/component/helpers.rb
|
|
199
199
|
- lib/phlex/reactive/component/identity.rb
|
|
200
|
+
- lib/phlex/reactive/component/image_fallback_binding.rb
|
|
200
201
|
- lib/phlex/reactive/component/lazy.rb
|
|
201
202
|
- lib/phlex/reactive/component/media_binding.rb
|
|
203
|
+
- lib/phlex/reactive/component/media_pattern.rb
|
|
202
204
|
- lib/phlex/reactive/component/presence_declaration.rb
|
|
203
205
|
- lib/phlex/reactive/component/registry.rb
|
|
204
206
|
- lib/phlex/reactive/component/selection.rb
|