phlex-reactive 0.14.0 → 0.14.1

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) => {
@@ -1154,12 +1169,13 @@ function formNeeded(root) {
1154
1169
 
1155
1170
  // Show bindings and cross-root show targets, completion bindings, option
1156
1171
  // 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.)
1172
+ // issue #319) and their cross-root targets (#343), the tag-chip input and
1173
+ // draft nested rows: a form's client-only bindings. The root declares one, or
1174
+ // owns an element that does. (The probes the connect-time gates ran before
1175
+ // the split, unchanged.)
1160
1176
  function bindingsNeeded(root) {
1161
1177
  return (
1162
- ["show-targets", "on-complete", "filter-input", "tags-field"].some(
1178
+ ["show-targets", "group-targets", "on-complete", "filter-input", "tags-field"].some(
1163
1179
  (marker) => root.getAttribute?.(`data-reactive-${marker}`) != null,
1164
1180
  ) ||
1165
1181
  (root.querySelectorAll?.("[data-reactive-show-field], [data-reactive-show]") ?? []).length > 0 ||
@@ -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.1"
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.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson