phlex-reactive 0.16.7 → 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 +94 -1
- data/README.md +106 -10
- 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/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/bindings.js +52 -17
- 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/compute.js +32 -4
- data/app/javascript/phlex/reactive/features/compute.min.js +2 -2
- data/app/javascript/phlex/reactive/features/compute.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 +6 -6
- data/app/javascript/phlex/reactive/runtime.js +146 -22
- 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
|
@@ -136,6 +136,20 @@ export function registerReactiveToken() {
|
|
|
136
136
|
// is emitted AFTER all render streams in the reply (the endpoint appends it
|
|
137
137
|
// last), so focus("[name=next]") sees the freshly morphed DOM — Turbo applies
|
|
138
138
|
// streams in document order.
|
|
139
|
+
//
|
|
140
|
+
// Focus ops wait one frame (issue #446). Turbo renders a stream message inside
|
|
141
|
+
// its focus preservation: it reads document.activeElement, appends the
|
|
142
|
+
// streams, waits one animation frame, reads it again and, when the element
|
|
143
|
+
// focused before has an id and focus moved, focuses that element again. This
|
|
144
|
+
// action runs behind a frame each stream queued before that compare, so a
|
|
145
|
+
// focus, focus_first or blur applied here would be undone, and the old
|
|
146
|
+
// field's focus binding would run again. The ops before the first focus op
|
|
147
|
+
// still run now; that op and the rest of its chain (order kept) run in a frame
|
|
148
|
+
// queued from here, the one after the compare, which then saw nothing move and
|
|
149
|
+
// restored nothing. Plain requestAnimationFrame, never a timeout: in a hidden
|
|
150
|
+
// tab Turbo's compare waits for the first frame too.
|
|
151
|
+
const FOCUS_OPS = ["focus", "focus_first", "blur"]
|
|
152
|
+
|
|
139
153
|
export function registerReactiveJs() {
|
|
140
154
|
const actions = window.Turbo?.StreamActions
|
|
141
155
|
if (!actions || actions["reactive:js"]) return
|
|
@@ -155,16 +169,21 @@ export function registerReactiveJs() {
|
|
|
155
169
|
}
|
|
156
170
|
return
|
|
157
171
|
}
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
172
|
+
const run = (ops) =>
|
|
173
|
+
applyOps(
|
|
174
|
+
ops,
|
|
175
|
+
(args) => streamOpTargets(args, root),
|
|
176
|
+
verbose
|
|
177
|
+
? (name, args) =>
|
|
178
|
+
__SPLIT__
|
|
179
|
+
? withFeature("devtools", (devtools) => devtools.diagnoseStream(name, args, root))
|
|
180
|
+
: devtoolsFeature.diagnoseStream(name, args, root)
|
|
181
|
+
: undefined,
|
|
182
|
+
)
|
|
183
|
+
const at = list.findIndex((entry) => FOCUS_OPS.includes(entry?.[0]))
|
|
184
|
+
if (at < 0 || typeof requestAnimationFrame !== "function") return run(list)
|
|
185
|
+
run(list.slice(0, at))
|
|
186
|
+
requestAnimationFrame(() => run(list.slice(at)))
|
|
168
187
|
}
|
|
169
188
|
}
|
|
170
189
|
|
|
@@ -1953,13 +1972,15 @@ function bindingsAlreadyRan(event, controller) {
|
|
|
1953
1972
|
function applyOps(list, resolveTargets, onZeroTargets) {
|
|
1954
1973
|
for (const entry of list) {
|
|
1955
1974
|
if (!Array.isArray(entry)) continue
|
|
1956
|
-
|
|
1975
|
+
let [name, args = {}] = entry
|
|
1957
1976
|
if (!Object.hasOwn(CLIENT_OPS, name)) {
|
|
1958
1977
|
console.warn(`[phlex-reactive] unknown client op ${JSON.stringify(name)} — skipped`)
|
|
1959
1978
|
continue
|
|
1960
1979
|
}
|
|
1961
1980
|
const targets = resolveTargets(args)
|
|
1962
1981
|
if (targets.length === 0 && onZeroTargets) onZeroTargets(name, args)
|
|
1982
|
+
if (targets.length > 0) args = readArgs(name, args, resolveTargets, onZeroTargets)
|
|
1983
|
+
if (!args) continue
|
|
1963
1984
|
for (const el of targets) {
|
|
1964
1985
|
CLIENT_OPS[name](el, args, resolveTargets)
|
|
1965
1986
|
if (args?.keep === true) keepResult(el, name, args, resolveTargets)
|
|
@@ -1967,6 +1988,48 @@ function applyOps(list, resolveTargets, onZeroTargets) {
|
|
|
1967
1988
|
}
|
|
1968
1989
|
}
|
|
1969
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
|
+
|
|
1970
2033
|
// Resolve a reactive:js op's targets against its `target` root (issue #97).
|
|
1971
2034
|
// "@root" is the root element itself; a selector resolves WITHIN it; a bare
|
|
1972
2035
|
// selector with no root (no `target` attr on the stream) resolves document-wide
|
|
@@ -1978,6 +2041,9 @@ function applyOps(list, resolveTargets, onZeroTargets) {
|
|
|
1978
2041
|
// documents for the controller path).
|
|
1979
2042
|
function streamOpTargets(args, root) {
|
|
1980
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 []
|
|
1981
2047
|
if (root) {
|
|
1982
2048
|
if (to === "@root") return [root]
|
|
1983
2049
|
if (typeof to !== "string" || to === "") return []
|
|
@@ -2046,6 +2112,10 @@ function streamOpTargets(args, root) {
|
|
|
2046
2112
|
// core.owns(el) is this control this root's own, not
|
|
2047
2113
|
// a nested reactive root's (issue #15)
|
|
2048
2114
|
// core.ownership() the hoisted per-pass form of owns
|
|
2115
|
+
// core.bindingOwns(el) may this root's bindings READ this
|
|
2116
|
+
// control: owns, or a shared field
|
|
2117
|
+
// just below (issue #440)
|
|
2118
|
+
// core.bindingOwnership() the hoisted per-pass form of it
|
|
2049
2119
|
// core.opTargets(args) the elements an op's `to:` names
|
|
2050
2120
|
// core.applyOps(list, defaultTo) run an op list root-scoped
|
|
2051
2121
|
// core.diagnose(label, args) the zero-target warning
|
|
@@ -2991,6 +3061,8 @@ export default class extends Controller {
|
|
|
2991
3061
|
},
|
|
2992
3062
|
owns: (el) => this.#ownsField(el),
|
|
2993
3063
|
ownership: () => this.#ownershipFilter(),
|
|
3064
|
+
bindingOwns: (el) => this.#bindingOwns(el),
|
|
3065
|
+
bindingOwnership: () => this.#bindingOwnership(),
|
|
2994
3066
|
opTargets: (args) => this.#opTargets(args),
|
|
2995
3067
|
diagnose: (label, args) => this.#diagnoseZeroTargets(label, args),
|
|
2996
3068
|
// Apply an op list root-scoped, with the zero-target diagnostics; a
|
|
@@ -3495,6 +3567,9 @@ export default class extends Controller {
|
|
|
3495
3567
|
// A detail miss (issue #346) is a complete no-op — checked before the
|
|
3496
3568
|
// once record is spent, preventDefault and the confirm gate.
|
|
3497
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
|
|
3498
3573
|
// So is a shortcut that is not this platform's twin or fired in an
|
|
3499
3574
|
// editable target (issue #357).
|
|
3500
3575
|
if (record.shortcut != null && !shortcutApplies(record.shortcut, event, this.application?.schema?.keyMappings))
|
|
@@ -3506,15 +3581,35 @@ export default class extends Controller {
|
|
|
3506
3581
|
this.#runBinding(
|
|
3507
3582
|
record.legacy ? { ...params, ops: record.ops, debounce: undefined, throttle: undefined, prevent: undefined } : record,
|
|
3508
3583
|
event,
|
|
3509
|
-
|
|
3584
|
+
row,
|
|
3510
3585
|
records.indexOf(record),
|
|
3586
|
+
trigger,
|
|
3511
3587
|
)
|
|
3512
3588
|
}
|
|
3513
3589
|
}
|
|
3514
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
|
+
|
|
3515
3608
|
// One on_client binding (issue #95): its outside guard, preventDefault rule,
|
|
3516
3609
|
// confirm gate, then its ops — every flag read from the binding itself.
|
|
3517
|
-
|
|
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) {
|
|
3518
3613
|
const { ops, confirm, confirmWhen, outside, window: windowBound, shortcut, prevent } = record
|
|
3519
3614
|
// Outside guard FIRST — identical semantics to dispatch() (issue #80): an
|
|
3520
3615
|
// outside: trigger is a COMPLETE no-op for events inside this root, before
|
|
@@ -3539,7 +3634,7 @@ export default class extends Controller {
|
|
|
3539
3634
|
// which must NEVER prompt. The same confirmResolver gate on(:action,
|
|
3540
3635
|
// confirm:) uses (issues #52/#55/#178), so a themed dialog covers both.
|
|
3541
3636
|
const message = this.#effectiveConfirmMessage(confirm, confirmWhen)
|
|
3542
|
-
const apply = this.#waitedOps(record, ordinal,
|
|
3637
|
+
const apply = this.#waitedOps(record, ordinal, bound, () => this.#applyOps(this.#parseOps(ops), trigger))
|
|
3543
3638
|
if (!message) return apply()
|
|
3544
3639
|
if (message instanceof Promise) {
|
|
3545
3640
|
return message.then((resolved) => (resolved ? this.#confirmThen(resolved, trigger, apply) : apply()))
|
|
@@ -4397,7 +4492,8 @@ export default class extends Controller {
|
|
|
4397
4492
|
// the control's own server root sits directly below this one. One level: a
|
|
4398
4493
|
// grand-host never sees it. Whatever reads #collectFields sees it too: the
|
|
4399
4494
|
// request, and the conditional confirm (it judges the fields the action is
|
|
4400
|
-
// about to post).
|
|
4495
|
+
// about to post). The client bindings read such a control too
|
|
4496
|
+
// (#bindingOwnership); listnav, ops, busy_on, dirty tracking and persist
|
|
4401
4497
|
// keep the strict nearest-root #ownershipFilter / #ownsField.
|
|
4402
4498
|
#serverFieldFilter() {
|
|
4403
4499
|
const nested = this.element.querySelectorAll(AWAKE_ROOT)
|
|
@@ -4410,6 +4506,30 @@ export default class extends Controller {
|
|
|
4410
4506
|
}
|
|
4411
4507
|
}
|
|
4412
4508
|
|
|
4509
|
+
// What this root's client BINDINGS read (issue #440): its own controls, as
|
|
4510
|
+
// #ownsField, plus a control marked data-reactive-shared-field whose own
|
|
4511
|
+
// server root is this one (under client-only roots, issue #410) or sits
|
|
4512
|
+
// directly below it: one level, the #serverFieldFilter rule. Field reads
|
|
4513
|
+
// only: show (every term, after:, clear:, disable: true), enable, show and
|
|
4514
|
+
// group targets, checked_* counts, on_complete, media, css vars, compute
|
|
4515
|
+
// inputs and outputs. Which binding ELEMENTS a root evaluates, and every op, listnav,
|
|
4516
|
+
// busy_on, dirty and persist read, stay on the strict #ownsField. An
|
|
4517
|
+
// unmarked control in a nested root costs one attribute read more.
|
|
4518
|
+
#bindingOwns(el) {
|
|
4519
|
+
if (this.#ownsField(el)) return true
|
|
4520
|
+
if (el.getAttribute?.("data-reactive-shared-field") == null) return false
|
|
4521
|
+
const own = serverRoot(el, AWAKE_ROOT, this.element)
|
|
4522
|
+
return own === this.element || serverRoot(own?.parentElement, AWAKE_ROOT, this.element) === this.element
|
|
4523
|
+
}
|
|
4524
|
+
|
|
4525
|
+
// The hoisted per-pass form of #bindingOwns, with #ownershipFilter's fast
|
|
4526
|
+
// path: no nested root, every control is this root's. Never cached on the
|
|
4527
|
+
// instance (a morph replaces nodes).
|
|
4528
|
+
#bindingOwnership() {
|
|
4529
|
+
if (this.element.querySelectorAll(AWAKE_ROOT).length === 0) return () => true
|
|
4530
|
+
return (el) => this.#bindingOwns(el)
|
|
4531
|
+
}
|
|
4532
|
+
|
|
4413
4533
|
// One walk over THIS root's named controls (not a nested reactive root's),
|
|
4414
4534
|
// returning both the scalar `fields` and any chosen `files`. The ownership
|
|
4415
4535
|
// predicate is hoisted ONCE (issue #117) via #ownershipFilter — in the common
|
|
@@ -4701,10 +4821,10 @@ export default class extends Controller {
|
|
|
4701
4821
|
// nested-reactive-root ownership filter, issue #15). The whitelist + skip
|
|
4702
4822
|
// logic lives in the shared applyOps so runOps and the reactive:js stream
|
|
4703
4823
|
// action interpret the SAME vocabulary the SAME way (client-side default-deny).
|
|
4704
|
-
#applyOps(list) {
|
|
4824
|
+
#applyOps(list, trigger) {
|
|
4705
4825
|
applyOps(
|
|
4706
4826
|
list,
|
|
4707
|
-
(args) => this.#opTargets(args),
|
|
4827
|
+
(args) => this.#opTargets(args, trigger),
|
|
4708
4828
|
(name, args) => this.#diagnoseZeroTargets(`client op "${name}"`, args),
|
|
4709
4829
|
)
|
|
4710
4830
|
}
|
|
@@ -4733,13 +4853,17 @@ export default class extends Controller {
|
|
|
4733
4853
|
if (__SPLIT__) withFeature("devtools", use)
|
|
4734
4854
|
}
|
|
4735
4855
|
|
|
4736
|
-
// Resolve an op's targets: "@root" is this element;
|
|
4737
|
-
//
|
|
4738
|
-
//
|
|
4739
|
-
//
|
|
4740
|
-
|
|
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) {
|
|
4741
4864
|
const to = args.to
|
|
4742
4865
|
if (to === "@root") return [this.element]
|
|
4866
|
+
if (to === "@trigger") return trigger?.nodeType === 1 ? [trigger] : []
|
|
4743
4867
|
if (typeof to !== "string" || to === "") return []
|
|
4744
4868
|
if (args.global) return [...document.querySelectorAll(to)]
|
|
4745
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)
|