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.
@@ -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
- applyOps(
159
- list,
160
- (args) => streamOpTargets(args, root),
161
- verbose
162
- ? (name, args) =>
163
- __SPLIT__
164
- ? withFeature("devtools", (devtools) => devtools.diagnoseStream(name, args, root))
165
- : devtoolsFeature.diagnoseStream(name, args, root)
166
- : undefined,
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
- const [name, args = {}] = entry
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
- trigger,
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
- #runBinding(record, event, trigger, ordinal) {
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, trigger, () => this.#applyOps(this.#parseOps(ops)))
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). Bindings, computes, listnav, dirty tracking and persist
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; a selector resolves
4737
- // WITHIN this root and excludes nested reactive roots' subtrees (issue #15
4738
- // semantics — the same nearest-root ownership check the field walk uses);
4739
- // global: true opts a single op out to document-wide.
4740
- #opTargets(args) {
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)