phlex-reactive 0.14.0 → 0.14.2

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.
@@ -682,6 +682,21 @@ const CLIENT_OPS = Object.freeze({
682
682
  if (el.textContent !== text) el.textContent = text
683
683
  },
684
684
 
685
+ // Tick or untick a whole checkbox group (issue #342) — "clear selection".
686
+ // The bindings feature flips the root's OWNED boxes the way its select-all
687
+ // header does (every box set, then input + change on each flipped one) and
688
+ // re-syncs the group bindings once. global: true ignores the op's own root
689
+ // and asks every reactive root on the page; each flips only the boxes it
690
+ // owns (nested roots included), so every root holding the group answers.
691
+ check_group: (el, args) => {
692
+ const roots = args.global ? document.querySelectorAll('[data-controller~="reactive"]') : [el]
693
+ for (const root of roots) {
694
+ __SPLIT__
695
+ ? withFeature("bindings", (bindings) => bindings.checkGroup(root, args.group, args.checked))
696
+ : bindingsFeature.checkGroup(root, args.group, args.checked)
697
+ }
698
+ },
699
+
685
700
  // Dispatch a bubbling CustomEvent (issue #96). RAW element.dispatchEvent — the
686
701
  // controller SHADOWS Stimulus's this.dispatch helper, so it must not be used.
687
702
  dispatch: (el, args) => {
@@ -831,8 +846,8 @@ function parseOps(raw) {
831
846
  }
832
847
 
833
848
  // --- on_client binding records (issue #271) -----------------------------------
834
- // Each on_client call emits ONE record ({on, ops, window?, outside?, confirm?,
835
- // confirmWhen?}); mix space-joins several onto one element (spaces inside a
849
+ // Each on_client call emits ONE record ({on, ops, window?, outside?, once?,
850
+ // detail?, confirm?, confirmWhen?}); mix space-joins several onto one element (spaces inside a
836
851
  // record ride as \u0020, so the join is unambiguous). Stimulus hands every
837
852
  // runOps descriptor on an element the SAME event.params, so runOps selects the
838
853
  // record(s) whose descriptor matches the firing event: event.type, the key
@@ -925,6 +940,21 @@ function bindingMatches(record, event, keyMappings) {
925
940
  return dot < 0 || keyMappings === null || keyFilterMatches(on.slice(dot + 1), event, keyMappings)
926
941
  }
927
942
 
943
+ // Issue #346: a record's `detail` lists top-level keys of event.detail and the
944
+ // JSON scalar each must strictly equal — on_client("turbo:submit-end", ops,
945
+ // detail: { success: true }) runs only after a successful submit. A missing or
946
+ // non-object event.detail, or a missing key, never matches (default-deny); a
947
+ // record without `detail` matches whatever the event carries.
948
+ function detailMatches(record, event) {
949
+ const expected = record.detail
950
+ if (expected === undefined) return true
951
+ if (expected === null || typeof expected !== "object" || Array.isArray(expected)) return false
952
+ const detail = event.detail
953
+ if (detail === null || typeof detail !== "object") return false
954
+ // A missing key reads undefined, which no JSON scalar (null included) equals.
955
+ return Object.keys(expected).every((key) => detail[key] === expected[key])
956
+ }
957
+
928
958
  // A `once: true` record (PR #272) is spent after its first run. Stimulus
929
959
  // removes only that binding's own listener; a regular same-event sibling on
930
960
  // the element keeps calling runOps with BOTH records, so the spent one is
@@ -1154,12 +1184,13 @@ function formNeeded(root) {
1154
1184
 
1155
1185
  // Show bindings and cross-root show targets, completion bindings, option
1156
1186
  // filtering, the bulk-selection group bindings (enable, select-all, count —
1157
- // issue #319), the tag-chip input and draft nested rows: a form's client-only
1158
- // bindings. The root declares one, or owns an element that does. (The probes
1159
- // the connect-time gates ran before the split, unchanged.)
1187
+ // issue #319) and their cross-root targets (#343), the tag-chip input and
1188
+ // draft nested rows: a form's client-only bindings. The root declares one, or
1189
+ // owns an element that does. (The probes the connect-time gates ran before
1190
+ // the split, unchanged.)
1160
1191
  function bindingsNeeded(root) {
1161
1192
  return (
1162
- ["show-targets", "on-complete", "filter-input", "tags-field"].some(
1193
+ ["show-targets", "group-targets", "on-complete", "filter-input", "tags-field"].some(
1163
1194
  (marker) => root.getAttribute?.(`data-reactive-${marker}`) != null,
1164
1195
  ) ||
1165
1196
  (root.querySelectorAll?.("[data-reactive-show-field], [data-reactive-show]") ?? []).length > 0 ||
@@ -2348,6 +2379,9 @@ export default class extends Controller {
2348
2379
  // An early replay never runs an outside: record (issue #303: an outside
2349
2380
  // click before connect had nothing to close).
2350
2381
  if (record.outside && event[EARLY_KEY]) continue
2382
+ // A detail miss (issue #346) is a complete no-op — checked before the
2383
+ // once record is spent, preventDefault and the confirm gate.
2384
+ if (!detailMatches(record, event)) continue
2351
2385
  if (onceBindingSpent(this, event, record)) continue
2352
2386
  this.#runBinding(record.legacy ? { ...params, ops: record.ops } : record, event, trigger)
2353
2387
  }
@@ -417,6 +417,16 @@ module Phlex
417
417
  # on_client(:click, js.hide("#menu"), outside: true),
418
418
  # on_client("keydown.esc", js.hide("#menu"))))
419
419
  #
420
+ # Issue #346: `detail:` gates the binding on the event's payload — a Hash
421
+ # of top-level event.detail keys to the JSON scalar each must strictly
422
+ # equal. The ops run after a SUCCESSFUL submit only, and a failed one
423
+ # keeps the selection for the retry:
424
+ # form(**on_client("turbo:submit-end", js.check_group("ids[]", false),
425
+ # detail: { success: true }))
426
+ # A missing event.detail or key never matches; a miss is a complete
427
+ # no-op (no confirm, no preventDefault). Not Turbo-specific: any
428
+ # CustomEvent whose detail carries a flag works.
429
+ #
420
430
  # Ops are EPHEMERAL UI: any server re-render of the component (an action
421
431
  # reply, a broadcast, a morph) rebuilds from server state and resets
422
432
  # whatever they toggled — by design (the LiveView JS-commands caveat). Use
@@ -425,7 +435,7 @@ module Phlex
425
435
  # Validation is loud: only a non-empty Phlex::Reactive::JS chain is
426
436
  # accepted — a dead trigger should fail at render, not no-op in the
427
437
  # browser.
428
- def on_client(event, ops, window: false, once: false, outside: false, confirm: nil)
438
+ def on_client(event, ops, window: false, once: false, outside: false, confirm: nil, detail: nil)
429
439
  unless ops.is_a?(Phlex::Reactive::JS)
430
440
  raise ArgumentError,
431
441
  "on_client expects a Phlex::Reactive::JS chain (e.g. js.toggle(\"#menu\")), " \
@@ -443,7 +453,7 @@ module Phlex
443
453
  "(change/input), or gate it behind a reducer's $ops / reactive_on_complete."
444
454
  end
445
455
  window_bound = window || outside
446
- record = client_binding_record(event, ops, window_bound:, outside:, once:, confirm:)
456
+ record = client_binding_record(event, ops, window_bound:, outside:, once:, confirm:, detail:)
447
457
  attrs = {
448
458
  data: {
449
459
  action: "#{event}#{"@window" if window_bound}->reactive#runOps#{":once" if once}",
@@ -1440,13 +1450,14 @@ module Phlex
1440
1450
  # overridable confirmResolver on(:action, confirm:) uses (#52/#55); the
1441
1451
  # client prompts BEFORE applying the ops. Issue #179: a Hash confirm: is
1442
1452
  # CONDITIONAL, compiled by the shared compile_conditional_confirm.
1443
- def client_binding_record(event, ops, window_bound:, outside:, once:, confirm:)
1453
+ def client_binding_record(event, ops, window_bound:, outside:, once:, confirm:, detail:)
1444
1454
  record = { "on" => event }
1445
1455
  record["window"] = true if window_bound
1446
1456
  record["outside"] = true if outside
1447
1457
  # A mix-ed regular sibling keeps calling runOps after Stimulus drops
1448
1458
  # this binding's :once listener — the client skips a spent once record.
1449
1459
  record["once"] = true if once
1460
+ record["detail"] = client_binding_detail(detail, once:) unless detail.nil?
1450
1461
  case confirm
1451
1462
  when nil, false then nil
1452
1463
  when String then record["confirm"] = confirm
@@ -1457,6 +1468,44 @@ module Phlex
1457
1468
  record
1458
1469
  end
1459
1470
 
1471
+ # Issue #346: validate + stringify on_client's detail: filter. Loud at
1472
+ # render, like the other on_client validations — a dead filter fails
1473
+ # here, never silently in the browser.
1474
+ def client_binding_detail(detail, once:)
1475
+ unless detail.is_a?(Hash) && !detail.empty?
1476
+ raise ArgumentError,
1477
+ "on_client detail: takes a non-empty Hash of event.detail keys to values " \
1478
+ "(e.g. detail: { success: true }), got #{detail.inspect}"
1479
+ end
1480
+ if once
1481
+ raise ArgumentError,
1482
+ "on_client detail: cannot combine with once: — Stimulus removes a :once listener on its " \
1483
+ "first event, so a first non-matching event would leave the binding dead"
1484
+ end
1485
+ normalized = detail.to_h do |key, value|
1486
+ unless client_detail_scalar?(value)
1487
+ raise ArgumentError,
1488
+ "on_client detail: values must be JSON scalars (true/false/nil, a String or a finite " \
1489
+ "number), got #{value.inspect} for #{key.inspect}"
1490
+ end
1491
+ [key.to_s, value]
1492
+ end
1493
+ # :success and "success" would collapse into one key, dropping a condition.
1494
+ if normalized.size < detail.size
1495
+ raise ArgumentError, "on_client detail: keys must be unique as strings, got #{detail.keys.inspect}"
1496
+ end
1497
+
1498
+ normalized
1499
+ end
1500
+
1501
+ def client_detail_scalar?(value)
1502
+ case value
1503
+ when true, false, nil, String, Integer then true
1504
+ when Float then value.finite?
1505
+ else false
1506
+ end
1507
+ end
1508
+
1460
1509
  def apply_confirm!(data, confirm)
1461
1510
  case confirm
1462
1511
  when String
@@ -57,8 +57,125 @@ module Phlex
57
57
  mix({ data: { reactive_count: selection_group!(:reactive_count, group) } }, attrs)
58
58
  end
59
59
 
60
+ # CROSS-ROOT group bindings (issue #343) — the count and enable
61
+ # sibling of reactive_show_targets. The group bindings above only see
62
+ # boxes this root owns (#15), so a list that is its own reactive root
63
+ # cannot drive a bulk bar the page renders outside it. The root that
64
+ # OWNS the boxes declares the outside ids it drives instead. Spread it
65
+ # on the ROOT (mix alongside reactive_root and reactive_show_targets):
66
+ #
67
+ # div(**mix(reactive_root,
68
+ # reactive_show_targets("#bulk-bar" => { if: { "ids[]" => { checked: 1.. } } }),
69
+ # reactive_group_targets("ids[]",
70
+ # count: "#bulk-count", # textContent = ticked count
71
+ # enable: { "#bulk-archive" => 1.. }))) # disabled = !(count in range)
72
+ #
73
+ # Targets are SINGLE ID SELECTORS (raised here, warn-skipped by the
74
+ # client) resolved document-wide; a missing one is skipped. An enable
75
+ # value is an Integer (an exact count), a Range (a threshold) or a full
76
+ # if:/if_any:/unless: conditions Hash. They re-sync with the in-root
77
+ # bindings: every change, a morph, rows added or removed.
78
+ #
79
+ # ONE call per root (mix space-joins a second call's JSON into an
80
+ # unparseable attr); several groups go in the hash form:
81
+ #
82
+ # reactive_group_targets("ids[]" => { count: "#c" }, "tags[]" => { enable: { "#t" => 1.. } })
83
+ #
84
+ # The outside markup gets its first paint from reactive_group_target_attrs.
85
+ def reactive_group_targets(group = nil, **options)
86
+ if group.nil? && (options.key?(:count) || options.key?(:enable))
87
+ raise ArgumentError, "reactive_group_targets needs the group first: " \
88
+ "reactive_group_targets(\"ids[]\", #{options.keys.map { "#{it}: ..." }.join(", ")})"
89
+ end
90
+
91
+ groups =
92
+ if group.nil? then options
93
+ elsif group.is_a?(Hash) then group
94
+ else { group => options }
95
+ end
96
+ if groups.empty?
97
+ raise ArgumentError, "reactive_group_targets needs a group and its targets: " \
98
+ "reactive_group_targets(\"ids[]\", count: \"#id\", enable: { \"#id\" => 1.. })"
99
+ end
100
+
101
+ wire = groups.to_h do |name, targets|
102
+ name = selection_group!(:reactive_group_targets, name)
103
+ [name, normalize_group_targets(name, targets)]
104
+ end
105
+ { data: { reactive_group_targets: wire.to_json } }
106
+ end
107
+
108
+ # First paint for an element a reactive_group_targets root drives but
109
+ # another component renders (computed from THIS component's
110
+ # reactive_values, so the outside markup never flashes):
111
+ #
112
+ # span(id: "bulk-count") { reactive_group_target_attrs("ids[]", :count).to_s } # the ticked count
113
+ # button(id: "bulk-archive", **reactive_group_target_attrs("ids[]", :enable, 1..)) # { disabled: }
114
+ #
115
+ # :count is the Integer (0 for an empty or absent group). :enable is
116
+ # { disabled: true|false }, or {} when reactive_values does not cover
117
+ # the group (the client seeds it at connect).
118
+ def reactive_group_target_attrs(group, kind, condition = nil)
119
+ name = selection_group!(:reactive_group_target_attrs, group)
120
+ case kind
121
+ when :count then Phlex::Reactive::ShowConditions.checked_count(show_values(nil)&.dig(name))
122
+ when :enable
123
+ match = first_paint_match(group_enable_payload(name, "reactive_group_target_attrs", condition)["any"], nil)
124
+ match.nil? ? {} : { disabled: !match }
125
+ else
126
+ raise ArgumentError, "reactive_group_target_attrs takes :count or :enable, got #{kind.inspect}"
127
+ end
128
+ end
129
+
60
130
  private
61
131
 
132
+ def normalize_group_targets(name, targets)
133
+ unless targets.is_a?(Hash) && (targets.key?(:count) || targets.key?(:enable))
134
+ raise ArgumentError, "reactive_group_targets(#{name.inspect}) needs count: or enable:, got #{targets.inspect}"
135
+ end
136
+ if (unknown = targets.keys - %i[count enable]).any?
137
+ raise ArgumentError, "reactive_group_targets(#{name.inspect}): unknown option(s) " \
138
+ "#{unknown.map(&:inspect).join(", ")} — it takes count: and enable:"
139
+ end
140
+
141
+ wire = {}
142
+ wire["count"] = Array(targets[:count]).map { group_target_selector!(name, it) } if targets.key?(:count)
143
+ if targets.key?(:enable)
144
+ wire["enable"] = targets[:enable].to_h do |selector, condition|
145
+ [group_target_selector!(name, selector), group_enable_payload(name, "reactive_group_targets", condition)]
146
+ end
147
+ end
148
+ wire
149
+ end
150
+
151
+ def group_target_selector!(name, selector)
152
+ selector = selector.to_s
153
+ return selector if selector.match?(DSL::MIRROR_ID_SELECTOR)
154
+
155
+ raise ArgumentError, "reactive_group_targets(#{name.inspect}) target #{selector.inspect} must be a single " \
156
+ "ID selector (\"#id\") — cross-root targets are id-allowlisted, like reactive_show_targets"
157
+ end
158
+
159
+ # An enable condition as the { "any" => groups } DNF payload: an
160
+ # Integer or Range is the group's checked count, a Hash is the full
161
+ # reactive_show conditions language.
162
+ def group_enable_payload(name, helper, condition)
163
+ conditions =
164
+ case condition
165
+ when Integer, Range then { if: { name => { checked: condition } } }
166
+ when Hash
167
+ if (unknown = condition.keys - Helpers::SHOW_CONDITION_KEYS).any?
168
+ raise ArgumentError, "#{helper}(#{name.inspect}): unknown conditions key(s) " \
169
+ "#{unknown.map(&:inspect).join(", ")} — it takes if:/if_any:/unless:"
170
+ end
171
+ condition
172
+ else
173
+ raise ArgumentError, "#{helper}(#{name.inspect}) enable takes an Integer (exact count), a Range " \
174
+ "(threshold) or an if:/if_any:/unless: conditions Hash, got #{condition.inspect}"
175
+ end
176
+ { "any" => Phlex::Reactive::ShowConditions.normalize(**conditions) }
177
+ end
178
+
62
179
  def selection_group!(helper, group)
63
180
  name = group.to_s
64
181
  return name unless name.strip.empty?
@@ -259,6 +259,36 @@ module Phlex
259
259
  append("text", args.freeze)
260
260
  end
261
261
 
262
+ # --- Tick or untick a checkbox group (issue #342) ---
263
+ #
264
+ # check_group(group, checked = true) — set every OWNED box of the group
265
+ # (the #15 rule: a nested root's boxes are its own) the way a
266
+ # reactive_select_all header does: every box first, then `input` +
267
+ # `change` on each one it flipped, then ONE re-sync of the header, count,
268
+ # show and enable bindings. The group is a checkbox name resolved like
269
+ # reactive_select_all's: a bare name takes reactive_scope, a bracketed one
270
+ # ("ids[]") is used verbatim. `global: true` is for a trigger outside the
271
+ # root that owns the group: EVERY connected reactive root on the page flips the
272
+ # boxes of that name it owns (nested roots included). ACTOR-ONLY like focus/submit: a broadcast that cleared
273
+ # every subscriber's selection would be hostile (BROADCAST_REFUSED_OPS).
274
+ # button(**on_client(:click, js.check_group("ids[]", false))) { "✕" }
275
+
276
+ # Positional `checked` on purpose: js.check_group("ids[]", false) reads as
277
+ # the op it is (the issue #342 API).
278
+ def check_group(group, checked = true, global: false) # rubocop:disable Style/OptionalBooleanParameter
279
+ name = -group.to_s
280
+ if name.strip.empty?
281
+ raise ArgumentError, "check_group needs a checkbox group name (e.g. \"ids[]\"), got #{group.inspect}"
282
+ end
283
+ unless [true, false].include?(checked)
284
+ raise ArgumentError, "check_group(#{name.inspect}) takes true or false, got #{checked.inspect}"
285
+ end
286
+
287
+ args = { "to" => ROOT_SENTINEL, "group" => name, "checked" => checked }
288
+ args["global"] = true if global
289
+ append("check_group", args.freeze)
290
+ end
291
+
262
292
  # --- Dispatch a bubbling CustomEvent (issue #96) ---
263
293
  #
264
294
  # dispatch(name, to: nil, detail: {}) — emit a bubbling CustomEvent so other
@@ -36,10 +36,11 @@ module Phlex
36
36
  # Actor-only ops: a js broadcast rejects a broadcast that carries one.
37
37
  # Focus ops steal focus in every subscriber's tab (issue #96); submit
38
38
  # (issue #226) would force-submit every subscriber's form; paste_into
39
- # (issue #228) would read every subscriber's clipboard. They belong to
40
- # the actor's own reply (reply.js) or gesture (on_client / a reducer's
41
- # $ops), never a broadcast. Names mirror Phlex::Reactive::JS's verbs.
42
- BROADCAST_REFUSED_OPS = %w[focus focus_first submit paste_into persist_state persist_clear].freeze
39
+ # (issue #228) would read every subscriber's clipboard; check_group
40
+ # (issue #342) would tick or clear every subscriber's selection. They
41
+ # belong to the actor's own reply (reply.js) or gesture (on_client / a
42
+ # reducer's $ops), never a broadcast. Names mirror Phlex::Reactive::JS's verbs.
43
+ BROADCAST_REFUSED_OPS = %w[focus focus_first submit paste_into persist_state persist_clear check_group].freeze
43
44
 
44
45
  # The broadcast_to verb kwargs (issue #185) → their Turbo stream action.
45
46
  # SELF-TARGETING verbs derive the target from the component's #id (require a
@@ -191,7 +192,8 @@ module Phlex
191
192
  raise ArgumentError,
192
193
  "broadcast_to(js:) refuses actor-only op(s) #{refused.join(", ")} — broadcasting focus " \
193
194
  "steals it in every subscriber's tab; broadcasting submit force-submits every " \
194
- "subscriber's form; broadcasting paste_into reads every subscriber's clipboard. " \
195
+ "subscriber's form; broadcasting paste_into reads every subscriber's clipboard; " \
196
+ "broadcasting check_group ticks or clears every subscriber's selection. " \
195
197
  "These are actor concerns (reply.js / on_client / $ops)."
196
198
  end
197
199
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Phlex
4
4
  module Reactive
5
- VERSION = "0.14.0"
5
+ VERSION = "0.14.2"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: phlex-reactive
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.14.0
4
+ version: 0.14.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson