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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +43 -1
- data/README.md +71 -5
- 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 +40 -6
- data/lib/phlex/reactive/component/helpers.rb +52 -3
- 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) => {
|
|
@@ -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?,
|
|
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
|
|
1158
|
-
// bindings. The root declares one, or
|
|
1159
|
-
// the connect-time gates ran before
|
|
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?
|
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
|
|