phlex-reactive 0.16.8 → 0.16.10
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 +66 -1
- data/README.md +61 -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/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 +116 -17
- 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
|
@@ -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)
|
|
@@ -1972,13 +1997,15 @@ function bindingsAlreadyRan(event, controller) {
|
|
|
1972
1997
|
function applyOps(list, resolveTargets, onZeroTargets) {
|
|
1973
1998
|
for (const entry of list) {
|
|
1974
1999
|
if (!Array.isArray(entry)) continue
|
|
1975
|
-
|
|
2000
|
+
let [name, args = {}] = entry
|
|
1976
2001
|
if (!Object.hasOwn(CLIENT_OPS, name)) {
|
|
1977
2002
|
console.warn(`[phlex-reactive] unknown client op ${JSON.stringify(name)} — skipped`)
|
|
1978
2003
|
continue
|
|
1979
2004
|
}
|
|
1980
2005
|
const targets = resolveTargets(args)
|
|
1981
2006
|
if (targets.length === 0 && onZeroTargets) onZeroTargets(name, args)
|
|
2007
|
+
if (targets.length > 0) args = readArgs(name, args, resolveTargets, onZeroTargets)
|
|
2008
|
+
if (!args) continue
|
|
1982
2009
|
for (const el of targets) {
|
|
1983
2010
|
CLIENT_OPS[name](el, args, resolveTargets)
|
|
1984
2011
|
if (args?.keep === true) keepResult(el, name, args, resolveTargets)
|
|
@@ -1986,6 +2013,48 @@ function applyOps(list, resolveTargets, onZeroTargets) {
|
|
|
1986
2013
|
}
|
|
1987
2014
|
}
|
|
1988
2015
|
|
|
2016
|
+
// --- Reads (issue #442) ----------------------------------------------------
|
|
2017
|
+
// set_value, text and set_attr with `from` + `attr` take their value off an
|
|
2018
|
+
// element instead of a literal; dispatch's detail_from builds the detail off
|
|
2019
|
+
// the trigger. The read runs ONCE per op, before any target is written, with
|
|
2020
|
+
// the op's own scoping ("@trigger", "@root", a selector, global:), first
|
|
2021
|
+
// match. Returns the args with the value (or detail) in place, or null to skip
|
|
2022
|
+
// the op: a source that resolves to nothing (the zero-target diagnostic) or a
|
|
2023
|
+
// read attr off the data-*/text rule (warned). copy_media's `from` is an
|
|
2024
|
+
// element it copies, not a read, so it never comes here.
|
|
2025
|
+
const READ_OPS = new Set(["set_value", "text", "set_attr"])
|
|
2026
|
+
|
|
2027
|
+
function readArgs(name, args, resolveTargets, onZeroTargets) {
|
|
2028
|
+
const detail = name === "dispatch"
|
|
2029
|
+
const reads = detail ? args.detail_from : READ_OPS.has(name) && args.from != null && { value: args.attr }
|
|
2030
|
+
if (!reads) return args
|
|
2031
|
+
const from = detail ? "@trigger" : args.from
|
|
2032
|
+
let source
|
|
2033
|
+
// A from that does not parse is a miss like one that matches nothing (the
|
|
2034
|
+
// copy_media precedent): the op is skipped, its siblings still run.
|
|
2035
|
+
try {
|
|
2036
|
+
source = resolveTargets({ ...args, to: from })[0]
|
|
2037
|
+
} catch {}
|
|
2038
|
+
if (!source) return onZeroTargets?.(name, { ...args, to: from }), null
|
|
2039
|
+
const values = detail ? { ...args.detail } : {}
|
|
2040
|
+
for (const [key, attr] of Object.entries(reads)) {
|
|
2041
|
+
const value = readValue(source, attr)
|
|
2042
|
+
if (value == null) return null
|
|
2043
|
+
values[key] = value
|
|
2044
|
+
}
|
|
2045
|
+
return detail ? { ...args, detail: values } : { ...args, ...values }
|
|
2046
|
+
}
|
|
2047
|
+
|
|
2048
|
+
// What a read may take off an element: a data-* attribute (absent reads "",
|
|
2049
|
+
// so it clears) or "@text", its textContent. Anything else warns and is null
|
|
2050
|
+
// — the interpret-time side of JS::READABLE_ATTR (two-sided default-deny).
|
|
2051
|
+
function readValue(el, attr) {
|
|
2052
|
+
if (attr === "@text") return el.textContent ?? ""
|
|
2053
|
+
if (typeof attr === "string" && /^data-[a-z][a-z0-9-]*$/i.test(attr)) return el.getAttribute(attr) ?? ""
|
|
2054
|
+
console.warn(`[phlex-reactive] refused client read of ${JSON.stringify(attr)} — skipped`)
|
|
2055
|
+
return null
|
|
2056
|
+
}
|
|
2057
|
+
|
|
1989
2058
|
// Resolve a reactive:js op's targets against its `target` root (issue #97).
|
|
1990
2059
|
// "@root" is the root element itself; a selector resolves WITHIN it; a bare
|
|
1991
2060
|
// selector with no root (no `target` attr on the stream) resolves document-wide
|
|
@@ -1997,6 +2066,9 @@ function applyOps(list, resolveTargets, onZeroTargets) {
|
|
|
1997
2066
|
// documents for the controller path).
|
|
1998
2067
|
function streamOpTargets(args, root) {
|
|
1999
2068
|
const to = args.to
|
|
2069
|
+
// A server-pushed op has no trigger (issue #442): the server refuses
|
|
2070
|
+
// "@trigger", and a hand-built one resolves to nothing.
|
|
2071
|
+
if (to === "@trigger") return []
|
|
2000
2072
|
if (root) {
|
|
2001
2073
|
if (to === "@root") return [root]
|
|
2002
2074
|
if (typeof to !== "string" || to === "") return []
|
|
@@ -3520,6 +3592,9 @@ export default class extends Controller {
|
|
|
3520
3592
|
// A detail miss (issue #346) is a complete no-op — checked before the
|
|
3521
3593
|
// once record is spent, preventDefault and the confirm gate.
|
|
3522
3594
|
if (!detailMatches(record, event)) continue
|
|
3595
|
+
// So is a within: miss (issue #442); a hit's row is the ops' @trigger.
|
|
3596
|
+
const row = record.within == null ? trigger : this.#delegatedTrigger(record.within, event)
|
|
3597
|
+
if (record.within != null && !row) continue
|
|
3523
3598
|
// So is a shortcut that is not this platform's twin or fired in an
|
|
3524
3599
|
// editable target (issue #357).
|
|
3525
3600
|
if (record.shortcut != null && !shortcutApplies(record.shortcut, event, this.application?.schema?.keyMappings))
|
|
@@ -3531,15 +3606,35 @@ export default class extends Controller {
|
|
|
3531
3606
|
this.#runBinding(
|
|
3532
3607
|
record.legacy ? { ...params, ops: record.ops, debounce: undefined, throttle: undefined, prevent: undefined } : record,
|
|
3533
3608
|
event,
|
|
3534
|
-
|
|
3609
|
+
row,
|
|
3535
3610
|
records.indexOf(record),
|
|
3611
|
+
trigger,
|
|
3536
3612
|
)
|
|
3537
3613
|
}
|
|
3538
3614
|
}
|
|
3539
3615
|
|
|
3616
|
+
// Issue #442: a within: record runs for an event from inside an element
|
|
3617
|
+
// matching the selector, strictly under the bound element and owned by this
|
|
3618
|
+
// root (issue #15: a nested root's row is that root's). That element is
|
|
3619
|
+
// returned as the ops' @trigger; anything else is a miss (null). So is a
|
|
3620
|
+
// selector that does not parse (Ruby cannot check CSS), warned under
|
|
3621
|
+
// verbose_errors so the dead binding is never silent in development.
|
|
3622
|
+
#delegatedTrigger(within, event) {
|
|
3623
|
+
const bound = event.currentTarget
|
|
3624
|
+
let row = null
|
|
3625
|
+
try {
|
|
3626
|
+
row = event.target?.closest?.(within)
|
|
3627
|
+
} catch {
|
|
3628
|
+
if (this.#verboseEnabled()) console.warn(`[phlex-reactive] on_client within: ${JSON.stringify(within)} is not a selector — nothing ran`)
|
|
3629
|
+
}
|
|
3630
|
+
return row && row !== bound && bound?.contains?.(row) && this.#ownsField(row) ? row : null
|
|
3631
|
+
}
|
|
3632
|
+
|
|
3540
3633
|
// One on_client binding (issue #95): its outside guard, preventDefault rule,
|
|
3541
3634
|
// confirm gate, then its ops — every flag read from the binding itself.
|
|
3542
|
-
|
|
3635
|
+
// `trigger` is the ops' @trigger and the confirm ctx.el (with within:, the
|
|
3636
|
+
// row); `bound`, the element the binding is on, keys its wait (issue #442).
|
|
3637
|
+
#runBinding(record, event, trigger, ordinal, bound = trigger) {
|
|
3543
3638
|
const { ops, confirm, confirmWhen, outside, window: windowBound, shortcut, prevent } = record
|
|
3544
3639
|
// Outside guard FIRST — identical semantics to dispatch() (issue #80): an
|
|
3545
3640
|
// outside: trigger is a COMPLETE no-op for events inside this root, before
|
|
@@ -3564,7 +3659,7 @@ export default class extends Controller {
|
|
|
3564
3659
|
// which must NEVER prompt. The same confirmResolver gate on(:action,
|
|
3565
3660
|
// confirm:) uses (issues #52/#55/#178), so a themed dialog covers both.
|
|
3566
3661
|
const message = this.#effectiveConfirmMessage(confirm, confirmWhen)
|
|
3567
|
-
const apply = this.#waitedOps(record, ordinal,
|
|
3662
|
+
const apply = this.#waitedOps(record, ordinal, bound, () => this.#applyOps(this.#parseOps(ops), trigger))
|
|
3568
3663
|
if (!message) return apply()
|
|
3569
3664
|
if (message instanceof Promise) {
|
|
3570
3665
|
return message.then((resolved) => (resolved ? this.#confirmThen(resolved, trigger, apply) : apply()))
|
|
@@ -4751,10 +4846,10 @@ export default class extends Controller {
|
|
|
4751
4846
|
// nested-reactive-root ownership filter, issue #15). The whitelist + skip
|
|
4752
4847
|
// logic lives in the shared applyOps so runOps and the reactive:js stream
|
|
4753
4848
|
// action interpret the SAME vocabulary the SAME way (client-side default-deny).
|
|
4754
|
-
#applyOps(list) {
|
|
4849
|
+
#applyOps(list, trigger) {
|
|
4755
4850
|
applyOps(
|
|
4756
4851
|
list,
|
|
4757
|
-
(args) => this.#opTargets(args),
|
|
4852
|
+
(args) => this.#opTargets(args, trigger),
|
|
4758
4853
|
(name, args) => this.#diagnoseZeroTargets(`client op "${name}"`, args),
|
|
4759
4854
|
)
|
|
4760
4855
|
}
|
|
@@ -4783,13 +4878,17 @@ export default class extends Controller {
|
|
|
4783
4878
|
if (__SPLIT__) withFeature("devtools", use)
|
|
4784
4879
|
}
|
|
4785
4880
|
|
|
4786
|
-
// Resolve an op's targets: "@root" is this element;
|
|
4787
|
-
//
|
|
4788
|
-
//
|
|
4789
|
-
//
|
|
4790
|
-
|
|
4881
|
+
// Resolve an op's targets: "@root" is this element; "@trigger" (issue #442)
|
|
4882
|
+
// the element whose on_client binding fired, when there is one and it is an
|
|
4883
|
+
// element (a window-bound binding's is the window; a reducer's $ops, a
|
|
4884
|
+
// feature's ops and a stream have none); a selector resolves WITHIN this
|
|
4885
|
+
// root and excludes nested reactive roots' subtrees (issue #15 semantics —
|
|
4886
|
+
// the same nearest-root ownership check the field walk uses); global: true
|
|
4887
|
+
// opts a single op out to document-wide.
|
|
4888
|
+
#opTargets(args, trigger) {
|
|
4791
4889
|
const to = args.to
|
|
4792
4890
|
if (to === "@root") return [this.element]
|
|
4891
|
+
if (to === "@trigger") return trigger?.nodeType === 1 ? [trigger] : []
|
|
4793
4892
|
if (typeof to !== "string" || to === "") return []
|
|
4794
4893
|
if (args.global) return [...document.querySelectorAll(to)]
|
|
4795
4894
|
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)
|