phlex-reactive 0.15.0 → 0.15.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 +25 -0
- data/README.md +21 -4
- data/app/javascript/phlex/reactive/core.min.js +3 -3
- data/app/javascript/phlex/reactive/core.min.js.map +3 -3
- data/app/javascript/phlex/reactive/early.js +12 -4
- data/app/javascript/phlex/reactive/early.min.js +2 -2
- data/app/javascript/phlex/reactive/early.min.js.map +3 -3
- data/app/javascript/phlex/reactive/reactive_controller.min.js +3 -3
- data/app/javascript/phlex/reactive/reactive_controller.min.js.map +3 -3
- data/app/javascript/phlex/reactive/runtime.js +142 -7
- data/lib/phlex/reactive/component/helpers.rb +15 -0
- data/lib/phlex/reactive/js.rb +20 -0
- data/lib/phlex/reactive/shortcut.rb +18 -5
- data/lib/phlex/reactive/streamable.rb +7 -2
- data/lib/phlex/reactive/version.rb +1 -1
- metadata +1 -1
|
@@ -201,6 +201,7 @@ export function registerReactiveStreamRender() {
|
|
|
201
201
|
streamRenderRegistered = true
|
|
202
202
|
document.addEventListener("turbo:before-stream-render", (event) => {
|
|
203
203
|
streamIntoRoots(event)
|
|
204
|
+
streamModals(event)
|
|
204
205
|
// The opt-in entry sees every stream (it may hold one for a module the
|
|
205
206
|
// incoming content needs); with no entry hook, a loaded effects module
|
|
206
207
|
// wraps the stream directly — in the default entry, always.
|
|
@@ -245,6 +246,116 @@ function streamIntoRoots(event) {
|
|
|
245
246
|
}
|
|
246
247
|
}
|
|
247
248
|
|
|
249
|
+
// --- Modal dialogs (issue #365) ---
|
|
250
|
+
// show_modal / close_dialog reach the browser's own modal dialog: the top
|
|
251
|
+
// layer, the focus trap, ::backdrop, Escape and focus restored to the opener.
|
|
252
|
+
// A dialog the SERVER renders open with data-reactive-modal becomes modal as
|
|
253
|
+
// soon as the client sees it: at a root's connect (a full page, a replaced
|
|
254
|
+
// root), after a morph of a root or of the page, and after a stream whose
|
|
255
|
+
// content holds a dialog. Duck-typed on tagName (the submitFormFor precedent:
|
|
256
|
+
// fake-node and test friendly).
|
|
257
|
+
function isDialog(el) {
|
|
258
|
+
return el?.tagName === "DIALOG" && typeof el.showModal === "function"
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// `:modal` is the only way to tell a modal dialog from one opened with the
|
|
262
|
+
// `open` attribute. An engine that does not know the pseudo-class throws a
|
|
263
|
+
// SyntaxError; it then counts as not modal (show_modal reopens it modally).
|
|
264
|
+
function isModal(dialog) {
|
|
265
|
+
try {
|
|
266
|
+
return dialog.matches(":modal")
|
|
267
|
+
} catch {
|
|
268
|
+
return false
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function showModal(el) {
|
|
273
|
+
if (!isDialog(el)) {
|
|
274
|
+
// Said where a zero-target op is (issue #237): under verbose_errors or debug.
|
|
275
|
+
if (el?.closest?.('[data-reactive-verbose="true"], [data-reactive-debug="true"]')) {
|
|
276
|
+
console.warn(`[phlex-reactive] show_modal targets a <dialog>, not <${el.localName}> — skipped`)
|
|
277
|
+
}
|
|
278
|
+
return
|
|
279
|
+
}
|
|
280
|
+
if (!el.isConnected || isModal(el)) return
|
|
281
|
+
// showModal() throws on a dialog already open non-modally: close it first,
|
|
282
|
+
// in the same task, so nothing paints in between.
|
|
283
|
+
if (el.open) el.close()
|
|
284
|
+
el.showModal()
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function closeDialog(el) {
|
|
288
|
+
if (isDialog(el) && el.open) el.close()
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Make every open dialog[data-reactive-modal] in `scope` modal — `scope` itself
|
|
292
|
+
// too, for a root that is the dialog.
|
|
293
|
+
function upgradeModals(scope) {
|
|
294
|
+
const sel = "dialog[data-reactive-modal]"
|
|
295
|
+
const dialogs = [...(scope?.querySelectorAll?.(sel) ?? [])]
|
|
296
|
+
if (scope?.matches?.(sel)) dialogs.push(scope)
|
|
297
|
+
for (const dialog of dialogs) {
|
|
298
|
+
if (dialog.open && !isModal(dialog)) showModal(dialog)
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// After a stream whose content holds a dialog has rendered, upgrade the page's
|
|
303
|
+
// marked dialogs. Document-wide: a replace's target is gone by then.
|
|
304
|
+
function streamModals(event) {
|
|
305
|
+
const detail = event.detail
|
|
306
|
+
const render = detail?.render
|
|
307
|
+
const stream = detail?.newStream ?? event.target
|
|
308
|
+
if (typeof render !== "function" || !stream?.querySelector?.("template")?.content?.querySelector?.("dialog")) return
|
|
309
|
+
detail.render = async (el) => {
|
|
310
|
+
await render(el)
|
|
311
|
+
upgradeModals(document)
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
let modalsRegistered = false
|
|
316
|
+
|
|
317
|
+
export function registerReactiveModals() {
|
|
318
|
+
if (modalsRegistered) return
|
|
319
|
+
if (typeof document === "undefined" || typeof document.addEventListener !== "function") return
|
|
320
|
+
modalsRegistered = true
|
|
321
|
+
// A morph of a root (a reply or broadcast morph, a page refresh) may have
|
|
322
|
+
// inserted an open marked dialog or opened one; a page morph covers dialogs
|
|
323
|
+
// outside any root.
|
|
324
|
+
document.addEventListener("turbo:morph-element", (event) => {
|
|
325
|
+
if (event.target?.matches?.('[data-controller~="reactive"]')) upgradeModals(event.target)
|
|
326
|
+
})
|
|
327
|
+
document.addEventListener("turbo:morph", () => upgradeModals(document))
|
|
328
|
+
// A morph that removes `open` from a modal dialog (the server now renders it
|
|
329
|
+
// closed): close() it instead, which leaves the top layer, ends the page's
|
|
330
|
+
// inertness and restores focus. Removing the attribute alone left all that
|
|
331
|
+
// in place under the older spec text, and close() is a no-op once `open` is
|
|
332
|
+
// gone — so the removal is cancelled and close() removes it. Idiomorph asks
|
|
333
|
+
// "remove" for EVERY attribute of the old element, kept or not, and that ask
|
|
334
|
+
// carries no new element: whether `open` is really leaving is read from the
|
|
335
|
+
// element morph's new render, which comes first.
|
|
336
|
+
const closing = new WeakSet()
|
|
337
|
+
document.addEventListener("turbo:before-morph-element", (event) => {
|
|
338
|
+
const el = event.target
|
|
339
|
+
const next = event.detail?.newElement
|
|
340
|
+
if (!isDialog(el) || !next) return
|
|
341
|
+
if (next.hasAttribute?.("open")) closing.delete(el)
|
|
342
|
+
else closing.add(el)
|
|
343
|
+
})
|
|
344
|
+
document.addEventListener("turbo:before-morph-attribute", (event) => {
|
|
345
|
+
const el = event.target
|
|
346
|
+
const detail = event.detail
|
|
347
|
+
if (detail?.attributeName !== "open" || detail.mutationType !== "remove") return
|
|
348
|
+
if (!closing.has(el) || !el.open || !isModal(el)) return
|
|
349
|
+
closing.delete(el)
|
|
350
|
+
event.preventDefault()
|
|
351
|
+
el.close()
|
|
352
|
+
})
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export function __resetReactiveModalsForTest() {
|
|
356
|
+
modalsRegistered = false
|
|
357
|
+
}
|
|
358
|
+
|
|
248
359
|
// For the opt-in entry: what to do with a stream before its render — hold it
|
|
249
360
|
// for a module the incoming content needs. `handle(event, effects)` gets the
|
|
250
361
|
// loaded effects module (or undefined) and wraps the stream with it itself.
|
|
@@ -581,6 +692,7 @@ export function registerReactiveActions() {
|
|
|
581
692
|
registerReactiveDefer()
|
|
582
693
|
registerReactiveStreamRender()
|
|
583
694
|
registerReactiveMorphFocus()
|
|
695
|
+
registerReactiveModals()
|
|
584
696
|
registerReactivePlaintext()
|
|
585
697
|
registerReactiveOffline()
|
|
586
698
|
registerReactiveDev()
|
|
@@ -838,6 +950,11 @@ const CLIENT_OPS = Object.freeze({
|
|
|
838
950
|
focus: (el) => el.focus?.(),
|
|
839
951
|
focus_first: (el) => firstFocusable(el)?.focus?.(),
|
|
840
952
|
|
|
953
|
+
// Modal dialog ops (issue #365): HTMLDialogElement#showModal / #close.
|
|
954
|
+
// ACTOR-ONLY like focus (BROADCAST_REFUSED_OPS server-side).
|
|
955
|
+
show_modal: (el) => showModal(el),
|
|
956
|
+
close_dialog: (el) => closeDialog(el),
|
|
957
|
+
|
|
841
958
|
// Text op (issue #159): set textContent — XSS-safe by construction (never
|
|
842
959
|
// innerHTML), strictly less powerful than set_attr. Change-guarded like
|
|
843
960
|
// #mirrorText. With global: true it is the cross-root text escape: paint a
|
|
@@ -1025,9 +1142,13 @@ function parseOps(raw) {
|
|
|
1025
1142
|
// record(s) whose descriptor matches the firing event: event.type, the key
|
|
1026
1143
|
// filter, and window-boundness.
|
|
1027
1144
|
|
|
1028
|
-
// Stimulus's default keyMappings (letters and digits map to themselves)
|
|
1145
|
+
// Stimulus's default keyMappings (letters and digits map to themselves), plus
|
|
1146
|
+
// delete and backspace (issue #366): Stimulus lacks those two, so a shortcut
|
|
1147
|
+
// naming one binds a bare keydown and only shortcutApplies matches it. A
|
|
1029
1148
|
// custom Stimulus schema is not mirrored — an unknown filter never matches.
|
|
1030
1149
|
const KEY_FILTER_MAP = Object.freeze({
|
|
1150
|
+
backspace: "Backspace",
|
|
1151
|
+
delete: "Delete",
|
|
1031
1152
|
enter: "Enter",
|
|
1032
1153
|
tab: "Tab",
|
|
1033
1154
|
esc: "Escape",
|
|
@@ -1070,7 +1191,9 @@ function keyFilterMatches(filter, event, keyMappings) {
|
|
|
1070
1191
|
// keydown whose `mod` is meta on Apple platforms and ctrl elsewhere. Ruby
|
|
1071
1192
|
// renders a meta twin and a ctrl twin; Stimulus filters the key, and this
|
|
1072
1193
|
// decides whether the twin that fired is this platform's and whether the
|
|
1073
|
-
// target is one the user is typing in. Either miss is a complete no-op.
|
|
1194
|
+
// target is one the user is typing in. Either miss is a complete no-op. A
|
|
1195
|
+
// delete/backspace chord (issue #366) has no twins and no Stimulus filter:
|
|
1196
|
+
// its bare keydown hears every key, and this check alone picks the chord.
|
|
1074
1197
|
// (Stimulus typecasts a digit shortcut, "1", to a number: hence String().)
|
|
1075
1198
|
const shortcutKeys = (spec) => (spec && typeof spec === "object" ? spec.keys : String(spec))
|
|
1076
1199
|
// A shortcut record keeps a bare `on: "keydown"`, so bindingMatches lets every
|
|
@@ -1965,6 +2088,9 @@ export default class extends Controller {
|
|
|
1965
2088
|
if (__SPLIT__) this.#loadFeatures()
|
|
1966
2089
|
else this.#connectFeaturesNow()
|
|
1967
2090
|
|
|
2091
|
+
// A dialog the server rendered open with data-reactive-modal (issue #365).
|
|
2092
|
+
upgradeModals(this.element)
|
|
2093
|
+
|
|
1968
2094
|
// LAST, after every feature above is wired: a replayed trigger must find
|
|
1969
2095
|
// the controller exactly as a live event after connect would.
|
|
1970
2096
|
this.#announceConnected()
|
|
@@ -2432,19 +2558,22 @@ export default class extends Controller {
|
|
|
2432
2558
|
// a per-controller promise makes each dispatch wait for the previous one, so
|
|
2433
2559
|
// it always uses the freshest token.
|
|
2434
2560
|
dispatch(event) {
|
|
2561
|
+
// A shortcut (issue #357) that is the other platform's twin, or pressed
|
|
2562
|
+
// in a field the user is typing in, is a complete no-op: the native
|
|
2563
|
+
// default (text undo) proceeds and nothing is announced. Checked FIRST: a
|
|
2564
|
+
// delete/backspace shortcut (issue #366) binds a bare window keydown, so
|
|
2565
|
+
// every key the user presses lands here.
|
|
2566
|
+
const shortcut = event.params?.shortcut
|
|
2567
|
+
if (shortcut != null && !shortcutApplies(shortcut, event, this.application?.schema?.keyMappings)) return
|
|
2435
2568
|
// A :once trigger already replayed on connect (issue #273) is spent.
|
|
2436
2569
|
if (earlyOnceSwallows(event, "dispatch", this.application?.schema?.keyMappings, this.element)) return
|
|
2437
2570
|
// `window` (renamed: never shadow the global) and `outside` are the event-
|
|
2438
2571
|
// modifier params (issue #80). The client decides preventDefault behavior
|
|
2439
2572
|
// from event.params — set by the Ruby on() — never by sniffing the
|
|
2440
2573
|
// Stimulus descriptor.
|
|
2441
|
-
const { action, params, debounce, throttle, confirm, confirmWhen, outside, window: windowBound, optimistic
|
|
2574
|
+
const { action, params, debounce, throttle, confirm, confirmWhen, outside, window: windowBound, optimistic } =
|
|
2442
2575
|
event.params
|
|
2443
2576
|
if (!action) return
|
|
2444
|
-
// A shortcut (issue #357) that is the other platform's twin, or pressed
|
|
2445
|
-
// in a field the user is typing in, is a complete no-op: the native
|
|
2446
|
-
// default (text undo) proceeds and nothing is announced.
|
|
2447
|
-
if (shortcut != null && !shortcutApplies(shortcut, event, this.application?.schema?.keyMappings)) return
|
|
2448
2577
|
|
|
2449
2578
|
// The pending-state hint (issue #181): data-reactive-busy-param. During a
|
|
2450
2579
|
// deploy overlap a page rendered by the PREVIOUS gem still emits the old
|
|
@@ -2600,6 +2729,12 @@ export default class extends Controller {
|
|
|
2600
2729
|
// the component resets whatever they toggled (by design — a signed action
|
|
2601
2730
|
// owns state that must survive re-renders).
|
|
2602
2731
|
runOps(event) {
|
|
2732
|
+
// A lone shortcut record (Stimulus typecasts it to an object) whose chord
|
|
2733
|
+
// did not fire bails before any bookkeeping: a delete/backspace shortcut
|
|
2734
|
+
// (issue #366) binds a bare window keydown that hears every key. Several
|
|
2735
|
+
// records are checked one by one below.
|
|
2736
|
+
const lone = event.params?.ops
|
|
2737
|
+
if (lone?.shortcut != null && !shortcutApplies(lone.shortcut, event, this.application?.schema?.keyMappings)) return
|
|
2603
2738
|
if (earlyOnceSwallows(event, "runOps", this.application?.schema?.keyMappings, this.element)) return
|
|
2604
2739
|
if (bindingsAlreadyRan(event, this)) return
|
|
2605
2740
|
const params = event.params ?? {}
|
|
@@ -1790,12 +1790,27 @@ module Phlex
|
|
|
1790
1790
|
# The data-action descriptors of an on()/on_client trigger: one, or a
|
|
1791
1791
|
# shortcut's meta and ctrl twins (space-joined, as mix joins them).
|
|
1792
1792
|
# The common, shortcut-less path is ONE interpolation (on() is a hot path).
|
|
1793
|
+
# A chord whose key Stimulus lacks (issue #366: delete, backspace) binds a
|
|
1794
|
+
# bare window keydown: the client's shortcut check matches it alone.
|
|
1793
1795
|
def trigger_descriptors(event, shortcut, window_bound, method, once)
|
|
1794
1796
|
return "#{event}#{"@window" if window_bound}->reactive##{method}#{":once" if once}" unless shortcut
|
|
1797
|
+
return bare_shortcut_descriptor(shortcut, method, once) unless shortcut.stimulus_filterable?
|
|
1795
1798
|
|
|
1796
1799
|
shortcut.filters.map { "keydown.#{it}@window->reactive##{method}#{":once" if once}" }.join(" ")
|
|
1797
1800
|
end
|
|
1798
1801
|
|
|
1802
|
+
# Stimulus removes a `:once` listener after its FIRST event, and a bare
|
|
1803
|
+
# keydown hears every key, so the first keypress of any kind would spend it.
|
|
1804
|
+
def bare_shortcut_descriptor(shortcut, method, once)
|
|
1805
|
+
if once
|
|
1806
|
+
raise ArgumentError,
|
|
1807
|
+
"shortcut: #{shortcut.keys.inspect} cannot take once: — its key is matched by the " \
|
|
1808
|
+
"library, not Stimulus, so the first key pressed of any kind would spend the binding"
|
|
1809
|
+
end
|
|
1810
|
+
|
|
1811
|
+
"keydown@window->reactive##{method}"
|
|
1812
|
+
end
|
|
1813
|
+
|
|
1799
1814
|
# A Hash param is JSON (Stimulus's param reader parses it back into an
|
|
1800
1815
|
# object); the String form stays a plain string.
|
|
1801
1816
|
def shortcut_param_attr(shortcut)
|
data/lib/phlex/reactive/js.rb
CHANGED
|
@@ -186,6 +186,26 @@ module Phlex
|
|
|
186
186
|
append("focus_first", target_args(to, global:))
|
|
187
187
|
end
|
|
188
188
|
|
|
189
|
+
# --- Modal dialogs (issue #365) ---
|
|
190
|
+
#
|
|
191
|
+
# show_modal(to) — HTMLDialogElement#showModal(): the top layer, a
|
|
192
|
+
# focus trap, ::backdrop, Escape to close and focus
|
|
193
|
+
# restored to the opener, all the browser's own. A
|
|
194
|
+
# dialog already open NON-modally is closed and
|
|
195
|
+
# reopened as a modal; one already modal is left as is.
|
|
196
|
+
# close_dialog(to) — HTMLDialogElement#close() (a closed one is left as is).
|
|
197
|
+
# A target that is not a <dialog> is skipped. Both ACTOR-ONLY like focus:
|
|
198
|
+
# opening a modal steals focus, so a broadcast refuses them — a
|
|
199
|
+
# broadcaster renders the dialog open with data: { reactive_modal: true }.
|
|
200
|
+
|
|
201
|
+
def show_modal(to, global: false)
|
|
202
|
+
append("show_modal", target_args(to, global:))
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def close_dialog(to, global: false)
|
|
206
|
+
append("close_dialog", target_args(to, global:))
|
|
207
|
+
end
|
|
208
|
+
|
|
189
209
|
# --- Submit (issue #226) ---
|
|
190
210
|
#
|
|
191
211
|
# submit(to = :root) — requestSubmit() the TARGET'S OWN form: the target
|
|
@@ -13,17 +13,24 @@ module Phlex
|
|
|
13
13
|
# exactly), so a `mod` chord renders TWO descriptors, a meta twin and a ctrl
|
|
14
14
|
# twin, and the client runs only the twin for its platform. Stimulus keeps
|
|
15
15
|
# the key filtering; the client adds the platform check, the editable-target
|
|
16
|
-
# guard and preventDefault.
|
|
16
|
+
# guard and preventDefault. The exception is a key Stimulus's default map
|
|
17
|
+
# lacks (delete, backspace — issue #366): it binds a bare window keydown,
|
|
18
|
+
# no twins, and the client matches the whole chord.
|
|
17
19
|
#
|
|
18
20
|
# Validation is loud, at render: Stimulus reports an unknown key token by
|
|
19
21
|
# throwing at keypress time, which is too late to notice.
|
|
20
22
|
class Shortcut
|
|
21
23
|
MODIFIERS = %w[mod meta ctrl alt shift].freeze
|
|
22
24
|
# Stimulus's default keyMappings: the named keys plus a-z and 0-9.
|
|
23
|
-
|
|
25
|
+
STIMULUS_KEYS = %w[enter tab esc space up down left right home end page_up page_down].freeze
|
|
26
|
+
# Keys Stimulus's default map lacks (issue #366): a key filter naming one
|
|
27
|
+
# makes Stimulus throw at keypress, so such a chord renders a bare
|
|
28
|
+
# `keydown@window` and the client alone matches it.
|
|
29
|
+
LIBRARY_KEYS = %w[delete backspace].freeze
|
|
30
|
+
NAMED_KEYS = (STIMULUS_KEYS + LIBRARY_KEYS).freeze
|
|
24
31
|
HASH_KEYS = %i[keys in_editable].freeze
|
|
25
32
|
|
|
26
|
-
private_constant :MODIFIERS, :NAMED_KEYS, :HASH_KEYS
|
|
33
|
+
private_constant :MODIFIERS, :STIMULUS_KEYS, :LIBRARY_KEYS, :NAMED_KEYS, :HASH_KEYS
|
|
27
34
|
|
|
28
35
|
attr_reader :keys
|
|
29
36
|
|
|
@@ -61,6 +68,11 @@ module Phlex
|
|
|
61
68
|
freeze
|
|
62
69
|
end
|
|
63
70
|
|
|
71
|
+
# False for a chord whose key Stimulus's default map lacks (delete,
|
|
72
|
+
# backspace): it binds without a key filter and without twins — the
|
|
73
|
+
# client's shortcut check matches the key and picks the platform's `mod`.
|
|
74
|
+
def stimulus_filterable? = !LIBRARY_KEYS.include?(@keys.split("+").last)
|
|
75
|
+
|
|
64
76
|
# The Stimulus key filters to bind: the meta and ctrl twins for a `mod`
|
|
65
77
|
# chord, else the chord itself.
|
|
66
78
|
def filters
|
|
@@ -95,8 +107,9 @@ module Phlex
|
|
|
95
107
|
|
|
96
108
|
raise ArgumentError,
|
|
97
109
|
"shortcut: #{source.inspect} has an unknown key #{key.first.inspect} — use a letter, a digit, " \
|
|
98
|
-
"or one of #{NAMED_KEYS.join(", ")} (Stimulus's default key map
|
|
99
|
-
"known on the server, bind
|
|
110
|
+
"or one of #{NAMED_KEYS.join(", ")} (Stimulus's default key map, plus delete and backspace, " \
|
|
111
|
+
"which the library matches itself; custom keyMappings are not known on the server, bind " \
|
|
112
|
+
"those with event: and window: instead)"
|
|
100
113
|
end
|
|
101
114
|
|
|
102
115
|
def known_key?(key) = NAMED_KEYS.include?(key) || key.match?(/\A[a-z0-9]\z/)
|
|
@@ -38,11 +38,14 @@ module Phlex
|
|
|
38
38
|
# (issue #226) would force-submit every subscriber's form; paste_into
|
|
39
39
|
# (issue #228) would read every subscriber's clipboard; check_group
|
|
40
40
|
# (issue #342) would tick or clear every subscriber's selection; presence
|
|
41
|
-
# (issue #358) would write every subscriber's presence state
|
|
41
|
+
# (issue #358) would write every subscriber's presence state; show_modal
|
|
42
|
+
# and close_dialog (issue #365) would open or close a modal, and move
|
|
43
|
+
# focus, on every screen at once. They
|
|
42
44
|
# belong to the actor's own reply (reply.js) or gesture (on_client / a
|
|
43
45
|
# reducer's $ops), never a broadcast. Names mirror Phlex::Reactive::JS's verbs.
|
|
44
46
|
BROADCAST_REFUSED_OPS = %w[
|
|
45
47
|
focus focus_first submit paste_into persist_state persist_clear check_group presence
|
|
48
|
+
show_modal close_dialog
|
|
46
49
|
].freeze
|
|
47
50
|
|
|
48
51
|
# The broadcast_to verb kwargs (issue #185) → their Turbo stream action.
|
|
@@ -197,7 +200,9 @@ module Phlex
|
|
|
197
200
|
"steals it in every subscriber's tab; broadcasting submit force-submits every " \
|
|
198
201
|
"subscriber's form; broadcasting paste_into reads every subscriber's clipboard; " \
|
|
199
202
|
"broadcasting check_group ticks or clears every subscriber's selection; " \
|
|
200
|
-
"broadcasting presence writes every subscriber's presence state
|
|
203
|
+
"broadcasting presence writes every subscriber's presence state; " \
|
|
204
|
+
"broadcasting show_modal or close_dialog opens or closes a modal (and moves focus) on " \
|
|
205
|
+
"every screen — render the dialog open or closed, with data: { reactive_modal: true }, instead." \
|
|
201
206
|
"These are actor concerns (reply.js / on_client / $ops)."
|
|
202
207
|
end
|
|
203
208
|
|