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.
@@ -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). A
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, shortcut } =
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)
@@ -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
- NAMED_KEYS = %w[enter tab esc space up down left right home end page_up page_down].freeze
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; custom keyMappings are not " \
99
- "known on the server, bind those with event: and window: instead)"
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. They
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
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Phlex
4
4
  module Reactive
5
- VERSION = "0.15.0"
5
+ VERSION = "0.15.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.15.0
4
+ version: 0.15.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson