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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +30 -1
- data/README.md +52 -4
- data/app/javascript/phlex/reactive/core.min.js +2 -2
- data/app/javascript/phlex/reactive/core.min.js.map +3 -3
- data/app/javascript/phlex/reactive/features/bindings.js +88 -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/reactive_controller.js +1 -0
- data/app/javascript/phlex/reactive/reactive_controller.min.js +2 -2
- data/app/javascript/phlex/reactive/reactive_controller.min.js.map +5 -5
- data/app/javascript/phlex/reactive/runtime.js +20 -4
- data/lib/phlex/reactive/component/selection.rb +117 -0
- data/lib/phlex/reactive/js.rb +30 -0
- data/lib/phlex/reactive/streamable.rb +7 -5
- data/lib/phlex/reactive/version.rb +1 -1
- metadata +1 -1
|
@@ -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
|
|
1158
|
-
// bindings. The root declares one, or
|
|
1159
|
-
// the connect-time gates ran before
|
|
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?
|
data/lib/phlex/reactive/js.rb
CHANGED
|
@@ -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
|
|
40
|
-
#
|
|
41
|
-
#
|
|
42
|
-
|
|
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
|
|