phlex-reactive 0.16.8 → 0.16.9
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 +47 -1
- data/README.md +60 -5
- 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/early.js +28 -1
- 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/devtools.js +15 -5
- data/app/javascript/phlex/reactive/features/devtools.min.js +2 -2
- data/app/javascript/phlex/reactive/features/devtools.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 +4 -4
- data/app/javascript/phlex/reactive/runtime.js +85 -11
- data/lib/phlex/reactive/component/dsl.rb +2 -0
- data/lib/phlex/reactive/component/helpers.rb +50 -4
- data/lib/phlex/reactive/js.rb +211 -41
- data/lib/phlex/reactive/response.rb +4 -0
- data/lib/phlex/reactive/version.rb +1 -1
- metadata +1 -1
|
@@ -1972,13 +1972,15 @@ function bindingsAlreadyRan(event, controller) {
|
|
|
1972
1972
|
function applyOps(list, resolveTargets, onZeroTargets) {
|
|
1973
1973
|
for (const entry of list) {
|
|
1974
1974
|
if (!Array.isArray(entry)) continue
|
|
1975
|
-
|
|
1975
|
+
let [name, args = {}] = entry
|
|
1976
1976
|
if (!Object.hasOwn(CLIENT_OPS, name)) {
|
|
1977
1977
|
console.warn(`[phlex-reactive] unknown client op ${JSON.stringify(name)} — skipped`)
|
|
1978
1978
|
continue
|
|
1979
1979
|
}
|
|
1980
1980
|
const targets = resolveTargets(args)
|
|
1981
1981
|
if (targets.length === 0 && onZeroTargets) onZeroTargets(name, args)
|
|
1982
|
+
if (targets.length > 0) args = readArgs(name, args, resolveTargets, onZeroTargets)
|
|
1983
|
+
if (!args) continue
|
|
1982
1984
|
for (const el of targets) {
|
|
1983
1985
|
CLIENT_OPS[name](el, args, resolveTargets)
|
|
1984
1986
|
if (args?.keep === true) keepResult(el, name, args, resolveTargets)
|
|
@@ -1986,6 +1988,48 @@ function applyOps(list, resolveTargets, onZeroTargets) {
|
|
|
1986
1988
|
}
|
|
1987
1989
|
}
|
|
1988
1990
|
|
|
1991
|
+
// --- Reads (issue #442) ----------------------------------------------------
|
|
1992
|
+
// set_value, text and set_attr with `from` + `attr` take their value off an
|
|
1993
|
+
// element instead of a literal; dispatch's detail_from builds the detail off
|
|
1994
|
+
// the trigger. The read runs ONCE per op, before any target is written, with
|
|
1995
|
+
// the op's own scoping ("@trigger", "@root", a selector, global:), first
|
|
1996
|
+
// match. Returns the args with the value (or detail) in place, or null to skip
|
|
1997
|
+
// the op: a source that resolves to nothing (the zero-target diagnostic) or a
|
|
1998
|
+
// read attr off the data-*/text rule (warned). copy_media's `from` is an
|
|
1999
|
+
// element it copies, not a read, so it never comes here.
|
|
2000
|
+
const READ_OPS = new Set(["set_value", "text", "set_attr"])
|
|
2001
|
+
|
|
2002
|
+
function readArgs(name, args, resolveTargets, onZeroTargets) {
|
|
2003
|
+
const detail = name === "dispatch"
|
|
2004
|
+
const reads = detail ? args.detail_from : READ_OPS.has(name) && args.from != null && { value: args.attr }
|
|
2005
|
+
if (!reads) return args
|
|
2006
|
+
const from = detail ? "@trigger" : args.from
|
|
2007
|
+
let source
|
|
2008
|
+
// A from that does not parse is a miss like one that matches nothing (the
|
|
2009
|
+
// copy_media precedent): the op is skipped, its siblings still run.
|
|
2010
|
+
try {
|
|
2011
|
+
source = resolveTargets({ ...args, to: from })[0]
|
|
2012
|
+
} catch {}
|
|
2013
|
+
if (!source) return onZeroTargets?.(name, { ...args, to: from }), null
|
|
2014
|
+
const values = detail ? { ...args.detail } : {}
|
|
2015
|
+
for (const [key, attr] of Object.entries(reads)) {
|
|
2016
|
+
const value = readValue(source, attr)
|
|
2017
|
+
if (value == null) return null
|
|
2018
|
+
values[key] = value
|
|
2019
|
+
}
|
|
2020
|
+
return detail ? { ...args, detail: values } : { ...args, ...values }
|
|
2021
|
+
}
|
|
2022
|
+
|
|
2023
|
+
// What a read may take off an element: a data-* attribute (absent reads "",
|
|
2024
|
+
// so it clears) or "@text", its textContent. Anything else warns and is null
|
|
2025
|
+
// — the interpret-time side of JS::READABLE_ATTR (two-sided default-deny).
|
|
2026
|
+
function readValue(el, attr) {
|
|
2027
|
+
if (attr === "@text") return el.textContent ?? ""
|
|
2028
|
+
if (typeof attr === "string" && /^data-[a-z][a-z0-9-]*$/i.test(attr)) return el.getAttribute(attr) ?? ""
|
|
2029
|
+
console.warn(`[phlex-reactive] refused client read of ${JSON.stringify(attr)} — skipped`)
|
|
2030
|
+
return null
|
|
2031
|
+
}
|
|
2032
|
+
|
|
1989
2033
|
// Resolve a reactive:js op's targets against its `target` root (issue #97).
|
|
1990
2034
|
// "@root" is the root element itself; a selector resolves WITHIN it; a bare
|
|
1991
2035
|
// selector with no root (no `target` attr on the stream) resolves document-wide
|
|
@@ -1997,6 +2041,9 @@ function applyOps(list, resolveTargets, onZeroTargets) {
|
|
|
1997
2041
|
// documents for the controller path).
|
|
1998
2042
|
function streamOpTargets(args, root) {
|
|
1999
2043
|
const to = args.to
|
|
2044
|
+
// A server-pushed op has no trigger (issue #442): the server refuses
|
|
2045
|
+
// "@trigger", and a hand-built one resolves to nothing.
|
|
2046
|
+
if (to === "@trigger") return []
|
|
2000
2047
|
if (root) {
|
|
2001
2048
|
if (to === "@root") return [root]
|
|
2002
2049
|
if (typeof to !== "string" || to === "") return []
|
|
@@ -3520,6 +3567,9 @@ export default class extends Controller {
|
|
|
3520
3567
|
// A detail miss (issue #346) is a complete no-op — checked before the
|
|
3521
3568
|
// once record is spent, preventDefault and the confirm gate.
|
|
3522
3569
|
if (!detailMatches(record, event)) continue
|
|
3570
|
+
// So is a within: miss (issue #442); a hit's row is the ops' @trigger.
|
|
3571
|
+
const row = record.within == null ? trigger : this.#delegatedTrigger(record.within, event)
|
|
3572
|
+
if (record.within != null && !row) continue
|
|
3523
3573
|
// So is a shortcut that is not this platform's twin or fired in an
|
|
3524
3574
|
// editable target (issue #357).
|
|
3525
3575
|
if (record.shortcut != null && !shortcutApplies(record.shortcut, event, this.application?.schema?.keyMappings))
|
|
@@ -3531,15 +3581,35 @@ export default class extends Controller {
|
|
|
3531
3581
|
this.#runBinding(
|
|
3532
3582
|
record.legacy ? { ...params, ops: record.ops, debounce: undefined, throttle: undefined, prevent: undefined } : record,
|
|
3533
3583
|
event,
|
|
3534
|
-
|
|
3584
|
+
row,
|
|
3535
3585
|
records.indexOf(record),
|
|
3586
|
+
trigger,
|
|
3536
3587
|
)
|
|
3537
3588
|
}
|
|
3538
3589
|
}
|
|
3539
3590
|
|
|
3591
|
+
// Issue #442: a within: record runs for an event from inside an element
|
|
3592
|
+
// matching the selector, strictly under the bound element and owned by this
|
|
3593
|
+
// root (issue #15: a nested root's row is that root's). That element is
|
|
3594
|
+
// returned as the ops' @trigger; anything else is a miss (null). So is a
|
|
3595
|
+
// selector that does not parse (Ruby cannot check CSS), warned under
|
|
3596
|
+
// verbose_errors so the dead binding is never silent in development.
|
|
3597
|
+
#delegatedTrigger(within, event) {
|
|
3598
|
+
const bound = event.currentTarget
|
|
3599
|
+
let row = null
|
|
3600
|
+
try {
|
|
3601
|
+
row = event.target?.closest?.(within)
|
|
3602
|
+
} catch {
|
|
3603
|
+
if (this.#verboseEnabled()) console.warn(`[phlex-reactive] on_client within: ${JSON.stringify(within)} is not a selector — nothing ran`)
|
|
3604
|
+
}
|
|
3605
|
+
return row && row !== bound && bound?.contains?.(row) && this.#ownsField(row) ? row : null
|
|
3606
|
+
}
|
|
3607
|
+
|
|
3540
3608
|
// One on_client binding (issue #95): its outside guard, preventDefault rule,
|
|
3541
3609
|
// confirm gate, then its ops — every flag read from the binding itself.
|
|
3542
|
-
|
|
3610
|
+
// `trigger` is the ops' @trigger and the confirm ctx.el (with within:, the
|
|
3611
|
+
// row); `bound`, the element the binding is on, keys its wait (issue #442).
|
|
3612
|
+
#runBinding(record, event, trigger, ordinal, bound = trigger) {
|
|
3543
3613
|
const { ops, confirm, confirmWhen, outside, window: windowBound, shortcut, prevent } = record
|
|
3544
3614
|
// Outside guard FIRST — identical semantics to dispatch() (issue #80): an
|
|
3545
3615
|
// outside: trigger is a COMPLETE no-op for events inside this root, before
|
|
@@ -3564,7 +3634,7 @@ export default class extends Controller {
|
|
|
3564
3634
|
// which must NEVER prompt. The same confirmResolver gate on(:action,
|
|
3565
3635
|
// confirm:) uses (issues #52/#55/#178), so a themed dialog covers both.
|
|
3566
3636
|
const message = this.#effectiveConfirmMessage(confirm, confirmWhen)
|
|
3567
|
-
const apply = this.#waitedOps(record, ordinal,
|
|
3637
|
+
const apply = this.#waitedOps(record, ordinal, bound, () => this.#applyOps(this.#parseOps(ops), trigger))
|
|
3568
3638
|
if (!message) return apply()
|
|
3569
3639
|
if (message instanceof Promise) {
|
|
3570
3640
|
return message.then((resolved) => (resolved ? this.#confirmThen(resolved, trigger, apply) : apply()))
|
|
@@ -4751,10 +4821,10 @@ export default class extends Controller {
|
|
|
4751
4821
|
// nested-reactive-root ownership filter, issue #15). The whitelist + skip
|
|
4752
4822
|
// logic lives in the shared applyOps so runOps and the reactive:js stream
|
|
4753
4823
|
// action interpret the SAME vocabulary the SAME way (client-side default-deny).
|
|
4754
|
-
#applyOps(list) {
|
|
4824
|
+
#applyOps(list, trigger) {
|
|
4755
4825
|
applyOps(
|
|
4756
4826
|
list,
|
|
4757
|
-
(args) => this.#opTargets(args),
|
|
4827
|
+
(args) => this.#opTargets(args, trigger),
|
|
4758
4828
|
(name, args) => this.#diagnoseZeroTargets(`client op "${name}"`, args),
|
|
4759
4829
|
)
|
|
4760
4830
|
}
|
|
@@ -4783,13 +4853,17 @@ export default class extends Controller {
|
|
|
4783
4853
|
if (__SPLIT__) withFeature("devtools", use)
|
|
4784
4854
|
}
|
|
4785
4855
|
|
|
4786
|
-
// Resolve an op's targets: "@root" is this element;
|
|
4787
|
-
//
|
|
4788
|
-
//
|
|
4789
|
-
//
|
|
4790
|
-
|
|
4856
|
+
// Resolve an op's targets: "@root" is this element; "@trigger" (issue #442)
|
|
4857
|
+
// the element whose on_client binding fired, when there is one and it is an
|
|
4858
|
+
// element (a window-bound binding's is the window; a reducer's $ops, a
|
|
4859
|
+
// feature's ops and a stream have none); a selector resolves WITHIN this
|
|
4860
|
+
// root and excludes nested reactive roots' subtrees (issue #15 semantics —
|
|
4861
|
+
// the same nearest-root ownership check the field walk uses); global: true
|
|
4862
|
+
// opts a single op out to document-wide.
|
|
4863
|
+
#opTargets(args, trigger) {
|
|
4791
4864
|
const to = args.to
|
|
4792
4865
|
if (to === "@root") return [this.element]
|
|
4866
|
+
if (to === "@trigger") return trigger?.nodeType === 1 ? [trigger] : []
|
|
4793
4867
|
if (typeof to !== "string" || to === "") return []
|
|
4794
4868
|
if (args.global) return [...document.querySelectorAll(to)]
|
|
4795
4869
|
return [...this.element.querySelectorAll(to)].filter((el) => this.#ownsField(el))
|
|
@@ -657,6 +657,8 @@ module Phlex
|
|
|
657
657
|
end
|
|
658
658
|
raise ArgumentError, "reactive_on_complete #{name.inspect} got no ops — a dead binding" if pairs.empty?
|
|
659
659
|
|
|
660
|
+
# Issue #442: a completion has no element whose binding fired.
|
|
661
|
+
Phlex::Reactive::JS.refuse_trigger!(pairs, "a reactive_on_complete run")
|
|
660
662
|
Phlex::Reactive::JS.assert_ops_allowed!(pairs) unless run.is_a?(Phlex::Reactive::JS)
|
|
661
663
|
pairs
|
|
662
664
|
end
|
|
@@ -504,6 +504,23 @@ module Phlex
|
|
|
504
504
|
# on a window-bound binding, an ArgumentError with shortcut:.
|
|
505
505
|
# div(**on_client(:drop, js.remove_class("#zone", "is-over"), prevent: false))
|
|
506
506
|
#
|
|
507
|
+
# Issue #442: `within:` makes ONE binding serve a whole subtree — a list
|
|
508
|
+
# of N rows binds once instead of N times. The record runs only for an
|
|
509
|
+
# event from inside an element matching the selector under the bound
|
|
510
|
+
# element (owned by this root: a row of a nested reactive root is that
|
|
511
|
+
# root's), and that element is the ops' `:trigger`; read each row's
|
|
512
|
+
# value off it with from:/attr: (see Phlex::Reactive::JS#set_value):
|
|
513
|
+
# ul(**on_client(:click, js.set_value("#item-value", from: :trigger, attr: "data-value")
|
|
514
|
+
# .set_attr(:trigger, "aria-selected", "true"),
|
|
515
|
+
# within: "[role=option]"))
|
|
516
|
+
# Anywhere else (the list's padding) is a complete no-op: no
|
|
517
|
+
# preventDefault, no confirm, no ops. The bound element is a container,
|
|
518
|
+
# so a click binding is not forced to type="button". window:, outside:
|
|
519
|
+
# and shortcut: raise (a delegated binding is element-bound), and so
|
|
520
|
+
# does once: (Stimulus would drop the listener on the first click, a
|
|
521
|
+
# miss included). A window-bound binding has no trigger element: its
|
|
522
|
+
# `:trigger` ops resolve to nothing (warned under verbose_errors).
|
|
523
|
+
#
|
|
507
524
|
# Ops are EPHEMERAL UI: any server re-render of the component (an action
|
|
508
525
|
# reply, a broadcast, a morph) rebuilds from server state and resets
|
|
509
526
|
# whatever they toggled — by design (the LiveView JS-commands caveat). Use
|
|
@@ -513,7 +530,7 @@ module Phlex
|
|
|
513
530
|
# accepted — a dead trigger should fail at render, not no-op in the
|
|
514
531
|
# browser.
|
|
515
532
|
def on_client(event, ops, window: false, once: false, outside: false, confirm: nil, detail: nil,
|
|
516
|
-
shortcut: nil, debounce: nil, throttle: nil, prevent: true)
|
|
533
|
+
shortcut: nil, debounce: nil, throttle: nil, prevent: true, within: nil)
|
|
517
534
|
unless ops.is_a?(Phlex::Reactive::JS)
|
|
518
535
|
raise ArgumentError,
|
|
519
536
|
"on_client expects a Phlex::Reactive::JS chain (e.g. js.toggle(\"#menu\")), " \
|
|
@@ -533,8 +550,10 @@ module Phlex
|
|
|
533
550
|
shortcut = client_shortcut!(event, shortcut, window:, outside:)
|
|
534
551
|
keeps_default!("on_client(#{event.inspect})", prevent, shortcut) unless prevent == true
|
|
535
552
|
window_bound = window || outside || !shortcut.nil?
|
|
553
|
+
client_within!(event, within, window:, outside:, shortcut:, once:) unless within.nil?
|
|
536
554
|
keep_default = !prevent && !window_bound
|
|
537
|
-
record = client_binding_record(event, ops, window_bound:, outside:, once:, confirm:, detail:, keep_default
|
|
555
|
+
record = client_binding_record(event, ops, window_bound:, outside:, once:, confirm:, detail:, keep_default:,
|
|
556
|
+
within:)
|
|
538
557
|
client_binding_wait!(record, event, debounce, throttle) unless debounce.nil? && throttle.nil?
|
|
539
558
|
# The record's `on` stays the bare keydown: the client reads a key
|
|
540
559
|
# filter from `on`, and `mod` is not a key.
|
|
@@ -553,7 +572,7 @@ module Phlex
|
|
|
553
572
|
# hidden = !navigator.clipboard.readText. Render the trigger `hidden`
|
|
554
573
|
# for reveal-when-available (a dead paste button never shows).
|
|
555
574
|
attrs[:data][:reactive_clipboard] = "true" if ops.ops.any? { |name, _| name == "paste_into" }
|
|
556
|
-
attrs[:type] = "button" if event == "click" && !window_bound
|
|
575
|
+
attrs[:type] = "button" if event == "click" && !window_bound && within.nil?
|
|
557
576
|
attrs
|
|
558
577
|
end
|
|
559
578
|
|
|
@@ -1596,7 +1615,8 @@ module Phlex
|
|
|
1596
1615
|
# overridable confirmResolver on(:action, confirm:) uses (#52/#55); the
|
|
1597
1616
|
# client prompts BEFORE applying the ops. Issue #179: a Hash confirm: is
|
|
1598
1617
|
# CONDITIONAL, compiled by the shared compile_conditional_confirm.
|
|
1599
|
-
def client_binding_record(event, ops, window_bound:, outside:, once:, confirm:, detail:, keep_default
|
|
1618
|
+
def client_binding_record(event, ops, window_bound:, outside:, once:, confirm:, detail:, keep_default:,
|
|
1619
|
+
within: nil)
|
|
1600
1620
|
record = { "on" => event }
|
|
1601
1621
|
# Issue #433: RIGHT AFTER "on" — phlex/reactive/early reads the pair
|
|
1602
1622
|
# `"on":"<event>","prevent":false` off the raw attribute before connect.
|
|
@@ -1607,6 +1627,9 @@ module Phlex
|
|
|
1607
1627
|
# this binding's :once listener — the client skips a spent once record.
|
|
1608
1628
|
record["once"] = true if once
|
|
1609
1629
|
record["detail"] = client_binding_detail(detail, once:) unless detail.nil?
|
|
1630
|
+
# Issue #442: written only when given, so a record without it is
|
|
1631
|
+
# byte-identical to before.
|
|
1632
|
+
record["within"] = within unless within.nil?
|
|
1610
1633
|
case confirm
|
|
1611
1634
|
when nil, false then nil
|
|
1612
1635
|
when String then record["confirm"] = confirm
|
|
@@ -1617,6 +1640,29 @@ module Phlex
|
|
|
1617
1640
|
record
|
|
1618
1641
|
end
|
|
1619
1642
|
|
|
1643
|
+
# Issue #442: validate on_client's within: — a non-empty CSS selector,
|
|
1644
|
+
# on an element-bound binding that can survive a miss.
|
|
1645
|
+
def client_within!(event, within, window:, outside:, shortcut:, once:)
|
|
1646
|
+
label = "on_client(#{event.inspect})"
|
|
1647
|
+
unless within.is_a?(String) && !within.strip.empty?
|
|
1648
|
+
raise ArgumentError,
|
|
1649
|
+
"#{label} within: takes a non-empty CSS selector String naming the elements to delegate " \
|
|
1650
|
+
"from (e.g. within: \"[role=option]\"), got #{within.inspect}"
|
|
1651
|
+
end
|
|
1652
|
+
{ "window:" => window, "outside:" => outside, "shortcut:" => shortcut }.each do |name, value|
|
|
1653
|
+
next unless value
|
|
1654
|
+
|
|
1655
|
+
raise ArgumentError,
|
|
1656
|
+
"#{label} within: delegates from the bound element's own subtree — it cannot be " \
|
|
1657
|
+
"window-bound; drop #{name}"
|
|
1658
|
+
end
|
|
1659
|
+
return unless once
|
|
1660
|
+
|
|
1661
|
+
raise ArgumentError,
|
|
1662
|
+
"#{label} within: cannot combine with once: — Stimulus removes a :once listener on its " \
|
|
1663
|
+
"first event, so a first event outside every match would leave the binding dead"
|
|
1664
|
+
end
|
|
1665
|
+
|
|
1620
1666
|
# Issue #393: the one message on() and on_client() raise when given both
|
|
1621
1667
|
# waits. Built only on that error path (on() is a hot path).
|
|
1622
1668
|
def both_waits_message(label)
|
data/lib/phlex/reactive/js.rb
CHANGED
|
@@ -23,8 +23,16 @@ module Phlex
|
|
|
23
23
|
#
|
|
24
24
|
# Targets: a CSS selector string is resolved WITHIN the component's root by
|
|
25
25
|
# default (nested reactive roots excluded, issue #15 semantics); `:root`
|
|
26
|
-
# targets the root element itself; `
|
|
27
|
-
#
|
|
26
|
+
# targets the root element itself; `:trigger` (issue #442) targets the
|
|
27
|
+
# element whose on_client binding fired (with on_client's within:, the
|
|
28
|
+
# matching element under it); `global: true` opts a single op out of root
|
|
29
|
+
# scoping (document escape hatch, e.g. a page-level overlay).
|
|
30
|
+
#
|
|
31
|
+
# Reads (issue #442): set_value, text and set_attr take `from:` + `attr:`
|
|
32
|
+
# in place of a literal value — the value is read in the browser off the
|
|
33
|
+
# `from` element (a data-* attribute or its text), so one chain bound once
|
|
34
|
+
# serves every row of a list:
|
|
35
|
+
# js.set_value("#item-value", from: :trigger, attr: "data-value")
|
|
28
36
|
#
|
|
29
37
|
# The op vocabulary is a fixed whitelist mirrored by the client interpreter
|
|
30
38
|
# — an op name the client doesn't know is warn-and-skipped there
|
|
@@ -35,6 +43,38 @@ module Phlex
|
|
|
35
43
|
# Serialized stand-in for "the component's own root element".
|
|
36
44
|
ROOT_SENTINEL = "@root"
|
|
37
45
|
|
|
46
|
+
# Serialized stand-in for "the element whose on_client binding fired"
|
|
47
|
+
# (issue #442). Only an on_client binding has one: a server-pushed op
|
|
48
|
+
# (reply.js, broadcast_to(js:)) or a reactive_on_complete run refuses it.
|
|
49
|
+
TRIGGER_SENTINEL = "@trigger"
|
|
50
|
+
|
|
51
|
+
# attr: :text's wire value — read the element's textContent.
|
|
52
|
+
TEXT_READ = "@text"
|
|
53
|
+
|
|
54
|
+
# What a from: read may take off an element (issue #442): a data-*
|
|
55
|
+
# attribute — state the server declared on the element — or its text.
|
|
56
|
+
# Two-sided like the write allowlist below: the client refuses anything
|
|
57
|
+
# else in a hand-built record. Narrow on purpose (widening later breaks
|
|
58
|
+
# nothing; narrowing would).
|
|
59
|
+
READABLE_ATTR = /\Adata-[a-z][a-z0-9-]*\z/i
|
|
60
|
+
|
|
61
|
+
# The ops whose value from: + attr: can read, and the arg keys that name
|
|
62
|
+
# an element (where a server-pushed op has no @trigger to put).
|
|
63
|
+
READ_OPS = %w[set_value text set_attr].freeze
|
|
64
|
+
TARGET_KEYS = %w[to from expanded submitter].freeze
|
|
65
|
+
|
|
66
|
+
# The field-writing ops write a field the author names: never the root
|
|
67
|
+
# (a container) nor the trigger (the element clicked).
|
|
68
|
+
FIELD_REFUSED = {
|
|
69
|
+
:root => "the component root", ROOT_SENTINEL => "the component root",
|
|
70
|
+
:trigger => "the trigger", TRIGGER_SENTINEL => "the trigger"
|
|
71
|
+
}.freeze
|
|
72
|
+
|
|
73
|
+
# The positional value set_value/text/set_attr default to, so nil keeps
|
|
74
|
+
# meaning "clear" and only from: may replace it.
|
|
75
|
+
NO_VALUE = Object.new.freeze
|
|
76
|
+
private_constant :NO_VALUE
|
|
77
|
+
|
|
38
78
|
# The attribute-name allowlist (issue #96) — the security-critical part of
|
|
39
79
|
# the attr ops, enforced HERE at build time AND again in the client
|
|
40
80
|
# interpreter (two-sided default-deny: a hand-built ops attr must not
|
|
@@ -75,15 +115,73 @@ module Phlex
|
|
|
75
115
|
"target must be :root or a CSS selector string, got #{to.inspect}"
|
|
76
116
|
end
|
|
77
117
|
|
|
118
|
+
# The op builder's target: normalize_target plus :trigger (issue #442).
|
|
119
|
+
# Separate so a pending hint or a css-vars binding given :trigger keeps
|
|
120
|
+
# raising — neither has a trigger element.
|
|
121
|
+
def self.normalize_client_target(to)
|
|
122
|
+
return TRIGGER_SENTINEL if to == :trigger
|
|
123
|
+
return ROOT_SENTINEL if to == :root
|
|
124
|
+
return to if to.is_a?(String)
|
|
125
|
+
|
|
126
|
+
raise ArgumentError,
|
|
127
|
+
"target must be :trigger, :root or a CSS selector string, got #{to.inspect}"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Also refuses @trigger (a raw list is server-pushed or a
|
|
131
|
+
# reactive_on_complete run: no trigger) and a read attr outside the
|
|
132
|
+
# data-*/text rule (issue #442).
|
|
78
133
|
def self.assert_ops_allowed!(list)
|
|
134
|
+
refuse_trigger!(list)
|
|
79
135
|
Array(list).each do |op, args|
|
|
80
|
-
next unless
|
|
136
|
+
next unless args.is_a?(::Hash)
|
|
137
|
+
|
|
138
|
+
name = arg(args, "name")
|
|
139
|
+
assert_allowed_attr(name.to_s) if name && ATTR_NAME_OPS.include?(op.to_s)
|
|
140
|
+
# A read: the client takes args.attr only on a read op with a from.
|
|
141
|
+
read_attr(arg(args, "attr"), op) if READ_OPS.include?(op.to_s) && arg(args, "from")
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Raise when an op in `list` (a JS chain or a raw [[op, args]] list)
|
|
146
|
+
# names @trigger (a target, from:, or dispatch's detail_from:, which
|
|
147
|
+
# reads off it). Called for every op the server pushes
|
|
148
|
+
# (Response.js_ops_json: reply.js and broadcast_to(js:)) and for a
|
|
149
|
+
# reactive_on_complete run.
|
|
150
|
+
def self.refuse_trigger!(list, where = "a server-pushed op (reply.js, broadcast_to(js:)) " \
|
|
151
|
+
"or a reactive_on_complete run")
|
|
152
|
+
op = trigger_op(list.is_a?(JS) ? list.ops : list)
|
|
153
|
+
return if op.nil?
|
|
154
|
+
|
|
155
|
+
raise ArgumentError,
|
|
156
|
+
"#{self}: #{op} names @trigger, the element whose on_client binding fired (a :trigger " \
|
|
157
|
+
"target, from: :trigger or detail_from:) — #{where} has no trigger. Name the element " \
|
|
158
|
+
"with a CSS selector."
|
|
159
|
+
end
|
|
81
160
|
|
|
82
|
-
|
|
83
|
-
|
|
161
|
+
# 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.
|
|
163
|
+
def self.trigger_op(list)
|
|
164
|
+
Array(list).each do |op, args|
|
|
165
|
+
next unless args.is_a?(::Hash)
|
|
166
|
+
return op.to_s if arg(args, "detail_from") || TARGET_KEYS.any? { arg(args, it) == TRIGGER_SENTINEL }
|
|
84
167
|
end
|
|
168
|
+
nil
|
|
85
169
|
end
|
|
86
170
|
|
|
171
|
+
# What attr: may read (issue #442): :text (or its "@text" wire form) or a
|
|
172
|
+
# data-* attribute name. Returns the wire value; raises otherwise.
|
|
173
|
+
def self.read_attr(attr, verb = "a read")
|
|
174
|
+
return TEXT_READ if [:text, TEXT_READ].include?(attr)
|
|
175
|
+
return attr if attr.is_a?(String) && attr.match?(READABLE_ATTR)
|
|
176
|
+
|
|
177
|
+
raise ArgumentError,
|
|
178
|
+
"#{self}: #{verb} attr: reads a data-* attribute or :text (textContent), got #{attr.inspect}"
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# A raw list's args may carry String or Symbol keys.
|
|
182
|
+
def self.arg(args, key) = args.key?(key) ? args[key] : args[key.to_sym]
|
|
183
|
+
private_class_method :arg
|
|
184
|
+
|
|
87
185
|
# The attribute-name allowlist (issue #96), case-insensitive. Refuses
|
|
88
186
|
# event-handler (on*), URL-bearing, and style attributes. Enforced at build
|
|
89
187
|
# time by the instance builder AND on the raw-list escape hatch — two-sided
|
|
@@ -172,9 +270,15 @@ module Phlex
|
|
|
172
270
|
# instead of toggling presence — current == on ? off : on, so an absent
|
|
173
271
|
# attribute becomes `on`. For aria-expanded/aria-pressed/data-state:
|
|
174
272
|
# js.toggle_attr("#trigger", "aria-expanded", "true", "false")
|
|
273
|
+
#
|
|
274
|
+
# set_attr(to, name, from:, attr:) (issue #442) reads the value off an
|
|
275
|
+
# element instead (see #set_value); the name allowlist above still
|
|
276
|
+
# decides what may be WRITTEN:
|
|
277
|
+
# js.set_attr(:trigger, "aria-selected", "true")
|
|
278
|
+
# js.set_attr("#preview", "data-state", from: :trigger, attr: "data-state")
|
|
175
279
|
|
|
176
|
-
def set_attr(to, name, value, global: false, keep: false)
|
|
177
|
-
append("set_attr", attr_args(to, name, global:, keep:,
|
|
280
|
+
def set_attr(to, name, value = NO_VALUE, from: nil, attr: nil, global: false, keep: false)
|
|
281
|
+
append("set_attr", attr_args(to, name, global:, keep:) { value_args(it, "set_attr", value, from:, attr:) })
|
|
178
282
|
end
|
|
179
283
|
|
|
180
284
|
def remove_attr(to, name, global: false, keep: false)
|
|
@@ -251,12 +355,13 @@ module Phlex
|
|
|
251
355
|
args = target_args(to, global:)
|
|
252
356
|
return append("submit", args) if submitter.nil?
|
|
253
357
|
|
|
254
|
-
unless submitter.is_a?(String) && !submitter.empty?
|
|
358
|
+
unless submitter == :trigger || (submitter.is_a?(String) && !submitter.empty?)
|
|
255
359
|
raise ArgumentError,
|
|
256
|
-
"js.submit submitter: must be a CSS selector string naming a submit control
|
|
360
|
+
"js.submit submitter: must be a CSS selector string naming a submit control " \
|
|
361
|
+
"(or :trigger), got #{submitter.inspect}"
|
|
257
362
|
end
|
|
258
363
|
|
|
259
|
-
append("submit", args.merge("submitter" => submitter).freeze)
|
|
364
|
+
append("submit", args.merge("submitter" => normalize_target(submitter)).freeze)
|
|
260
365
|
end
|
|
261
366
|
|
|
262
367
|
# --- Clipboard-source paste (issue #228) ---
|
|
@@ -279,12 +384,8 @@ module Phlex
|
|
|
279
384
|
# element (the two passes would fight over the same attribute).
|
|
280
385
|
|
|
281
386
|
def paste_into(to, global: false)
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
"#{self.class}: paste_into targets a field, not the component root — " \
|
|
285
|
-
"pass the input's CSS selector (e.g. js.paste_into(\"[name=code]\"))"
|
|
286
|
-
end
|
|
287
|
-
|
|
387
|
+
refuse_field_target!(to, "paste_into targets a field, not %s — " \
|
|
388
|
+
"pass the input's CSS selector (e.g. js.paste_into(\"[name=code]\"))")
|
|
288
389
|
append("paste_into", target_args(to, global:))
|
|
289
390
|
end
|
|
290
391
|
|
|
@@ -344,12 +445,8 @@ module Phlex
|
|
|
344
445
|
|
|
345
446
|
def preview_file(to, into:, name: nil, global: false, valid: false, wrap: nil, max_bytes: nil, accept: nil,
|
|
346
447
|
per_file: false)
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
"#{self.class}: preview_file reads a file input, not the component root — " \
|
|
350
|
-
"pass the input's CSS selector (e.g. js.preview_file(\"#photo-file\", into: \"#photo\"))"
|
|
351
|
-
end
|
|
352
|
-
|
|
448
|
+
refuse_field_target!(to, "preview_file reads a file input, not %s — pass the input's CSS selector " \
|
|
449
|
+
"(e.g. js.preview_file(\"#photo-file\", into: \"#photo\"))")
|
|
353
450
|
args = { "to" => normalize_target(to), "into" => preview_selector(:into, into) }
|
|
354
451
|
args["name"] = preview_selector(:name, name) unless name.nil?
|
|
355
452
|
args["global"] = true if global
|
|
@@ -368,9 +465,14 @@ module Phlex
|
|
|
368
465
|
# less powerful than set_attr. Pair with `global: true` to paint a value
|
|
369
466
|
# into a node OUTSIDE the component's root (the cross-root text escape,
|
|
370
467
|
# e.g. a read-only recap in another tab pane).
|
|
468
|
+
#
|
|
469
|
+
# text(to, from:, attr:) (issue #442) reads the text off an element
|
|
470
|
+
# instead (see #set_value) — the label of the row that was clicked:
|
|
471
|
+
# js.text("#item-label", from: :trigger, attr: "data-label")
|
|
371
472
|
|
|
372
|
-
def text(to, value, global: false)
|
|
373
|
-
args = { "to" => normalize_target(to)
|
|
473
|
+
def text(to, value = NO_VALUE, from: nil, attr: nil, global: false)
|
|
474
|
+
args = { "to" => normalize_target(to) }
|
|
475
|
+
value_args(args, "text", value, from:, attr:)
|
|
374
476
|
args["global"] = true if global
|
|
375
477
|
append("text", args.freeze)
|
|
376
478
|
end
|
|
@@ -413,15 +515,21 @@ module Phlex
|
|
|
413
515
|
# ACTOR-ONLY like paste_into: a broadcast that overwrote every
|
|
414
516
|
# subscriber's field would be hostile (BROADCAST_REFUSED_OPS).
|
|
415
517
|
# button(**on_client(:click, js.set_value("#notes", "Thanks"))) { "Thanks" }
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
518
|
+
#
|
|
519
|
+
# Reads (issue #442): `from:` + `attr:` in place of the value read it in
|
|
520
|
+
# the browser off an element — `from:` is :trigger, :root or a selector,
|
|
521
|
+
# resolved with the op's own scoping (global: included), first match;
|
|
522
|
+
# `attr:` is a data-* attribute or :text (textContent). A missing
|
|
523
|
+
# attribute reads "", so it clears; a `from:` matching nothing skips the
|
|
524
|
+
# op. Exactly one of the value or `from:`. The same on text and set_attr:
|
|
525
|
+
# ul(**on_client(:click, js.set_value("#item-value", from: :trigger, attr: "data-value"),
|
|
526
|
+
# within: "[role=option]"))
|
|
527
|
+
|
|
528
|
+
def set_value(to, value = NO_VALUE, from: nil, attr: nil, global: false)
|
|
529
|
+
refuse_field_target!(to, "set_value targets a field, not %s — " \
|
|
530
|
+
"pass the field's CSS selector (e.g. js.set_value(\"#notes\", \"Thanks\"))")
|
|
531
|
+
args = { "to" => normalize_target(to) }
|
|
532
|
+
value_args(args, "set_value", value, from:, attr:)
|
|
425
533
|
args["global"] = true if global
|
|
426
534
|
append("set_value", args.freeze)
|
|
427
535
|
end
|
|
@@ -465,8 +573,15 @@ module Phlex
|
|
|
465
573
|
# uniformly); `detail:` is the event's `detail` payload. The client uses raw
|
|
466
574
|
# element.dispatchEvent — the shared controller SHADOWS Stimulus's
|
|
467
575
|
# this.dispatch helper, so the interpreter must not use it.
|
|
468
|
-
|
|
576
|
+
#
|
|
577
|
+
# detail_from: (issue #442) maps detail keys to what to read off the
|
|
578
|
+
# TRIGGER (a data-* attribute or :text), merged over `detail:` in the
|
|
579
|
+
# browser — every value a String. Only an on_client binding has a
|
|
580
|
+
# trigger: a server-pushed dispatch refuses it.
|
|
581
|
+
# js.dispatch("app:item-picked", detail_from: { id: "data-id", text: :text })
|
|
582
|
+
def dispatch(name, to: nil, detail: {}, detail_from: {}, global: false)
|
|
469
583
|
args = { "name" => name.to_s, "to" => normalize_target(to.nil? ? :root : to), "detail" => detail }
|
|
584
|
+
args["detail_from"] = detail_reads(detail, detail_from) unless detail_from.nil? || detail_from == {}
|
|
470
585
|
args["global"] = true if global
|
|
471
586
|
append("dispatch", args.freeze)
|
|
472
587
|
end
|
|
@@ -609,17 +724,72 @@ module Phlex
|
|
|
609
724
|
[on, off].freeze
|
|
610
725
|
end
|
|
611
726
|
|
|
612
|
-
# An attr op's args: the target, the allowlisted name,
|
|
613
|
-
# (
|
|
614
|
-
|
|
727
|
+
# An attr op's args: the target, the allowlisted name, then whatever the
|
|
728
|
+
# block adds (set_attr's value or read). The name is validated LOUDLY at
|
|
729
|
+
# build time.
|
|
730
|
+
def attr_args(to, name, global:, keep: false)
|
|
615
731
|
name = name.to_s
|
|
616
732
|
assert_allowed_attr(name)
|
|
617
733
|
args = { "to" => normalize_target(to), "name" => name }
|
|
618
|
-
args
|
|
734
|
+
yield args if block_given?
|
|
619
735
|
args["global"] = true if global
|
|
620
736
|
with_keep(args, keep)
|
|
621
737
|
end
|
|
622
738
|
|
|
739
|
+
# The value of set_value/text/set_attr (issue #442): the literal
|
|
740
|
+
# (stringified; nil clears) as "value", or a read as "from" + "attr".
|
|
741
|
+
# Exactly one — loud at render.
|
|
742
|
+
def value_args(args, verb, value, from:, attr:)
|
|
743
|
+
literal = !value.equal?(NO_VALUE)
|
|
744
|
+
raise ArgumentError, "#{self.class}: #{verb} takes a value or from:, not both" if literal && !from.nil?
|
|
745
|
+
if from.nil? && !attr.nil?
|
|
746
|
+
raise ArgumentError,
|
|
747
|
+
"#{self.class}: #{verb} attr: needs from: (the element to read it off)"
|
|
748
|
+
end
|
|
749
|
+
return args["value"] = value.to_s if literal
|
|
750
|
+
raise ArgumentError, "#{self.class}: #{verb} needs a value or from: + attr:" if from.nil?
|
|
751
|
+
raise ArgumentError, "#{self.class}: #{verb} from: needs attr: (a data-* attribute or :text)" if attr.nil?
|
|
752
|
+
|
|
753
|
+
source = normalize_target(from)
|
|
754
|
+
raise ArgumentError, "#{self.class}: #{verb} from: needs a CSS selector, got an empty string" if source.empty?
|
|
755
|
+
|
|
756
|
+
args["from"] = source
|
|
757
|
+
args["attr"] = self.class.read_attr(attr, verb)
|
|
758
|
+
end
|
|
759
|
+
|
|
760
|
+
# dispatch's detail_from: — detail key → read attr, the keys Strings.
|
|
761
|
+
# A key also in detail: would silently override it, so it raises.
|
|
762
|
+
def detail_reads(detail, reads)
|
|
763
|
+
unless reads.is_a?(Hash)
|
|
764
|
+
raise ArgumentError,
|
|
765
|
+
"#{self.class}: dispatch detail_from: takes a Hash of detail keys to data-* attributes " \
|
|
766
|
+
"(or :text), got #{reads.inspect}"
|
|
767
|
+
end
|
|
768
|
+
|
|
769
|
+
literal = detail.is_a?(Hash) ? detail.keys.map(&:to_s) : []
|
|
770
|
+
normalized = reads.to_h do |key, attr|
|
|
771
|
+
key = key.to_s
|
|
772
|
+
if literal.include?(key)
|
|
773
|
+
raise ArgumentError,
|
|
774
|
+
"#{self.class}: dispatch detail_from: key #{key.inspect} is also in detail: — one would override the other"
|
|
775
|
+
end
|
|
776
|
+
|
|
777
|
+
[key, self.class.read_attr(attr, "dispatch detail_from: #{key.inspect}")]
|
|
778
|
+
end
|
|
779
|
+
if normalized.size < reads.size
|
|
780
|
+
raise ArgumentError, "#{self.class}: dispatch detail_from: keys must be unique as strings"
|
|
781
|
+
end
|
|
782
|
+
|
|
783
|
+
normalized.freeze
|
|
784
|
+
end
|
|
785
|
+
|
|
786
|
+
# set_value/paste_into/preview_file write a field the author names:
|
|
787
|
+
# `message` has a %s for what was named instead (the root, the trigger).
|
|
788
|
+
def refuse_field_target!(to, message)
|
|
789
|
+
where = FIELD_REFUSED[to]
|
|
790
|
+
raise ArgumentError, "#{self.class}: #{format(message, where)}" if where
|
|
791
|
+
end
|
|
792
|
+
|
|
623
793
|
# Build-time attr-name allowlist for the instance builder — delegates to the
|
|
624
794
|
# shared class-method check (also used by the raw-list escape hatch).
|
|
625
795
|
def assert_allowed_attr(name)
|
|
@@ -655,9 +825,9 @@ module Phlex
|
|
|
655
825
|
with_keep(args, keep)
|
|
656
826
|
end
|
|
657
827
|
|
|
658
|
-
#
|
|
659
|
-
# JS.
|
|
660
|
-
def normalize_target(to) = self.class.
|
|
828
|
+
# The op builder's target translation: :trigger, :root or a selector
|
|
829
|
+
# (JS.normalize_client_target; issue #442).
|
|
830
|
+
def normalize_target(to) = self.class.normalize_client_target(to)
|
|
661
831
|
end
|
|
662
832
|
end
|
|
663
833
|
end
|
|
@@ -302,10 +302,14 @@ data-reactive-ops="#{ERB::Util.html_escape(json)}"></turbo-stream>).html_safe
|
|
|
302
302
|
# Normalize `ops` to its JSON wire form, rejecting an empty chain — a
|
|
303
303
|
# reactive:js stream with no ops is a dead stream (a mistake at the call
|
|
304
304
|
# site), so fail loudly rather than emit an inert tag.
|
|
305
|
+
# Every op the server pushes (reply.js, broadcast_to(js:)) passes here,
|
|
306
|
+
# so this is where @trigger is refused for a chain too (issue #442): a
|
|
307
|
+
# pushed op has no element whose binding fired.
|
|
305
308
|
def js_ops_json(ops)
|
|
306
309
|
if ops.is_a?(Phlex::Reactive::JS)
|
|
307
310
|
raise ArgumentError, "js(...) got no ops — a dead reactive:js stream" if ops.empty?
|
|
308
311
|
|
|
312
|
+
Phlex::Reactive::JS.refuse_trigger!(ops)
|
|
309
313
|
ops.to_json
|
|
310
314
|
else
|
|
311
315
|
list = Array(ops)
|