phlex-reactive 0.14.4 → 0.15.0

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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +70 -0
  3. data/README.md +191 -1
  4. data/app/controllers/phlex/reactive/actions_controller.rb +28 -18
  5. data/app/javascript/phlex/reactive/core.js +5 -5
  6. data/app/javascript/phlex/reactive/core.min.js +3 -3
  7. data/app/javascript/phlex/reactive/core.min.js.map +4 -4
  8. data/app/javascript/phlex/reactive/early.js +28 -9
  9. data/app/javascript/phlex/reactive/early.min.js +2 -2
  10. data/app/javascript/phlex/reactive/early.min.js.map +3 -3
  11. data/app/javascript/phlex/reactive/features/bindings.js +125 -140
  12. data/app/javascript/phlex/reactive/features/bindings.min.js +2 -2
  13. data/app/javascript/phlex/reactive/features/bindings.min.js.map +3 -3
  14. data/app/javascript/phlex/reactive/features/form.js +63 -41
  15. data/app/javascript/phlex/reactive/features/form.min.js +2 -2
  16. data/app/javascript/phlex/reactive/features/form.min.js.map +3 -3
  17. data/app/javascript/phlex/reactive/features/presence.js +205 -0
  18. data/app/javascript/phlex/reactive/features/presence.min.js +4 -0
  19. data/app/javascript/phlex/reactive/features/presence.min.js.map +10 -0
  20. data/app/javascript/phlex/reactive/inspect.js +5 -1
  21. data/app/javascript/phlex/reactive/inspect.min.js +2 -2
  22. data/app/javascript/phlex/reactive/inspect.min.js.map +3 -3
  23. data/app/javascript/phlex/reactive/reactive_controller.js +3 -1
  24. data/app/javascript/phlex/reactive/reactive_controller.min.js +3 -3
  25. data/app/javascript/phlex/reactive/reactive_controller.min.js.map +8 -7
  26. data/app/javascript/phlex/reactive/runtime.js +175 -14
  27. data/lib/phlex/reactive/component/dsl.rb +5 -0
  28. data/lib/phlex/reactive/component/helpers.rb +79 -8
  29. data/lib/phlex/reactive/component/presence_declaration.rb +118 -0
  30. data/lib/phlex/reactive/component/registry.rb +3 -1
  31. data/lib/phlex/reactive/component.rb +1 -0
  32. data/lib/phlex/reactive/defer.rb +5 -0
  33. data/lib/phlex/reactive/engine.rb +9 -4
  34. data/lib/phlex/reactive/js.rb +24 -0
  35. data/lib/phlex/reactive/presence/component_methods.rb +130 -0
  36. data/lib/phlex/reactive/presence/roster.rb +90 -0
  37. data/lib/phlex/reactive/presence/transport.rb +154 -0
  38. data/lib/phlex/reactive/presence.rb +162 -0
  39. data/lib/phlex/reactive/presence_render_job.rb +26 -0
  40. data/lib/phlex/reactive/shortcut.rb +105 -0
  41. data/lib/phlex/reactive/streamable.rb +7 -3
  42. data/lib/phlex/reactive/version.rb +1 -1
  43. data/lib/phlex/reactive.rb +10 -0
  44. metadata +11 -1
@@ -36,6 +36,7 @@ import * as devtoolsFeature from "phlex/reactive/features/devtools"
36
36
  import * as hintsFeature from "phlex/reactive/features/hints"
37
37
  import * as deferFeature from "phlex/reactive/features/defer"
38
38
  import * as sortableFeature from "phlex/reactive/features/sortable"
39
+ import * as presenceFeature from "phlex/reactive/features/presence"
39
40
 
40
41
  // phlex/reactive/runtime — the reactive controller WITHOUT its feature
41
42
  // modules (issue #275): the code both client entries share. It is not an
@@ -199,6 +200,7 @@ export function registerReactiveStreamRender() {
199
200
  if (typeof document === "undefined" || typeof document.addEventListener !== "function") return
200
201
  streamRenderRegistered = true
201
202
  document.addEventListener("turbo:before-stream-render", (event) => {
203
+ streamIntoRoots(event)
202
204
  // The opt-in entry sees every stream (it may hold one for a module the
203
205
  // incoming content needs); with no entry hook, a loaded effects module
204
206
  // wraps the stream directly — in the default entry, always.
@@ -210,6 +212,39 @@ export function registerReactiveStreamRender() {
210
212
  // The names this registration had while it was two listeners.
211
213
  export { registerReactiveStreamRender as registerReactiveDismiss, registerReactiveStreamRender as registerReactiveEffects }
212
214
 
215
+ // --- A stream into a connected root (issue #356) ---
216
+ // A morph of a root makes it re-read its feature markers (#boundRootMorph); a
217
+ // stream that renders INTO it — append, prepend or update of the root or a
218
+ // child, before, after or replace of a child — fires neither a lifecycle nor
219
+ // a morph of the root, so a marker it brings was never connected. The roots
220
+ // a stream renders into are resolved here, before anything (effects, the opt-in
221
+ // entry's hold) wraps Turbo's render; once that render has run, each one still
222
+ // connected is told (STREAMED, not bubbling: an outer root's content is not
223
+ // its own). The root re-runs its connected features' seeds and connects what
224
+ // the new content needs, in both entries.
225
+ const STREAMED = "reactive:streamed"
226
+
227
+ function streamIntoRoots(event) {
228
+ const detail = event.detail
229
+ const render = detail?.render
230
+ const stream = detail?.newStream ?? event.target
231
+ const action = stream?.getAttribute?.("action")
232
+ const into = /^(append|prepend|update)$/.test(action)
233
+ if (typeof render !== "function" || !(into || /^(before|after|replace)$/.test(action))) return
234
+ const id = stream.getAttribute("target")
235
+ const selector = stream.getAttribute("targets")
236
+ const roots = new Set()
237
+ for (const target of id ? [document.getElementById(id)] : selector ? document.querySelectorAll(selector) : []) {
238
+ const root = (into ? target : target?.parentElement)?.closest?.('[data-controller~="reactive"]')
239
+ if (root) roots.add(root)
240
+ }
241
+ if (roots.size === 0) return
242
+ detail.render = async (el) => {
243
+ await render(el)
244
+ for (const root of roots) if (root.isConnected) root.dispatchEvent(new Event(STREAMED))
245
+ }
246
+ }
247
+
213
248
  // For the opt-in entry: what to do with a stream before its render — hold it
214
249
  // for a module the incoming content needs. `handle(event, effects)` gets the
215
250
  // loaded effects module (or undefined) and wraps the stream with it itself.
@@ -237,6 +272,66 @@ export function __resetReactiveStreamRenderForTest() {
237
272
  // for the attribute AND the property. Nothing else
238
273
  // (a root's token, a field's class) is held.
239
274
  // data-reactive-morph-value on a field lets the morph write it while focused.
275
+ // --- An engine without contenteditable="plaintext-only" (issue #355) ---
276
+ // reactive_editable renders contenteditable="plaintext-only": the browser
277
+ // itself then pastes plain text and inserts "\n" for Enter. An engine without
278
+ // it reads the value as INVALID — the element is not editable, cannot even be
279
+ // focused — so there every reactive editable is switched to "true" (and the
280
+ // bindings feature does the plain-text paste itself). However it reaches the
281
+ // page: present at load, appended by a stream into a root that is already
282
+ // connected, loaded in a frame, written back by a morph of an unfocused
283
+ // editable, or made reactive by a morph. One observer, only in such an engine; elsewhere the probe is all
284
+ // that runs. Detected once per page.
285
+ let plaintextOnly
286
+ let plaintextObserver = null
287
+ const PLAINTEXT_EDITABLE = "[data-reactive-editable][contenteditable=plaintext-only]"
288
+
289
+ function plaintextSupported() {
290
+ if (plaintextOnly === undefined) {
291
+ try {
292
+ const probe = document.createElement("div")
293
+ probe.contentEditable = "plaintext-only"
294
+ plaintextOnly = probe.contentEditable === "plaintext-only"
295
+ } catch {
296
+ plaintextOnly = false
297
+ }
298
+ }
299
+ return plaintextOnly
300
+ }
301
+
302
+ function makeEditable(el) {
303
+ if (el.matches?.(PLAINTEXT_EDITABLE)) el.setAttribute("contenteditable", "true")
304
+ el.querySelectorAll?.(PLAINTEXT_EDITABLE).forEach(makeEditable)
305
+ }
306
+
307
+ export function registerReactivePlaintext() {
308
+ // (No element factory, no page to observe: never probe a stub document.)
309
+ if (plaintextObserver || typeof document === "undefined" || typeof document.createElement !== "function") return
310
+ if (plaintextSupported()) return
311
+ makeEditable(document)
312
+ plaintextObserver = new MutationObserver((records) => {
313
+ for (const record of records) {
314
+ if (record.type === "attributes") makeEditable(record.target)
315
+ else record.addedNodes.forEach(makeEditable)
316
+ }
317
+ })
318
+ plaintextObserver.observe(document.documentElement, {
319
+ childList: true,
320
+ subtree: true,
321
+ attributes: true,
322
+ // (a morph can also make an existing plaintext-only element reactive)
323
+ attributeFilter: ["contenteditable", "data-reactive-editable"],
324
+ })
325
+ }
326
+
327
+ // For tests: what the probe answers (undefined probes again), with no
328
+ // observer installed.
329
+ export function __setReactivePlaintextOnlyForTest(value) {
330
+ plaintextObserver?.disconnect()
331
+ plaintextObserver = null
332
+ plaintextOnly = value
333
+ }
334
+
240
335
  let morphFocusRegistered = false
241
336
 
242
337
  //
@@ -244,8 +339,8 @@ let morphFocusRegistered = false
244
339
  // CHILDREN: a focused one keeps them (text and caret) while every attribute
245
340
  // still morphs — copied here from the render, then the element's morph is
246
341
  // cancelled (cancelling is all-or-nothing in Idiomorph). Its contenteditable
247
- // stays as it is: the bindings may have downgraded it for an engine without
248
- // plaintext-only. Every morph of an editable, held or not, records the
342
+ // stays as it is: the runtime switches it to "true" in an engine without
343
+ // plaintext-only (registerReactivePlaintext, #355). Every morph of an editable, held or not, records the
249
344
  // render's text as its SERVER TEXT (the Escape revert, the dirty baseline).
250
345
  function holdsTypedValue(el) {
251
346
  return (
@@ -486,6 +581,7 @@ export function registerReactiveActions() {
486
581
  registerReactiveDefer()
487
582
  registerReactiveStreamRender()
488
583
  registerReactiveMorphFocus()
584
+ registerReactivePlaintext()
489
585
  registerReactiveOffline()
490
586
  registerReactiveDev()
491
587
  }
@@ -766,6 +862,13 @@ const CLIENT_OPS = Object.freeze({
766
862
  }
767
863
  },
768
864
 
865
+ // Write this tab's presence state (issue #358) through the root's
866
+ // presence_set action. The feature warns outside a reactive_presence root.
867
+ presence: (el, args) =>
868
+ __SPLIT__
869
+ ? withFeature("presence", (presence) => presence.set(el, args.state))
870
+ : presenceFeature.set(el, args.state),
871
+
769
872
  // Dispatch a bubbling CustomEvent (issue #96). RAW element.dispatchEvent — the
770
873
  // controller SHADOWS Stimulus's this.dispatch helper, so it must not be used.
771
874
  dispatch: (el, args) => {
@@ -963,6 +1066,23 @@ function keyFilterMatches(filter, event, keyMappings) {
963
1066
  return typeof key === "string" && key.toLowerCase() === event.key.toLowerCase()
964
1067
  }
965
1068
 
1069
+ // Issue #357: a shortcut: ("mod+z", or { keys, inEditable }) is a window
1070
+ // keydown whose `mod` is meta on Apple platforms and ctrl elsewhere. Ruby
1071
+ // renders a meta twin and a ctrl twin; Stimulus filters the key, and this
1072
+ // 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.
1074
+ // (Stimulus typecasts a digit shortcut, "1", to a number: hence String().)
1075
+ const shortcutKeys = (spec) => (spec && typeof spec === "object" ? spec.keys : String(spec))
1076
+ // A shortcut record keeps a bare `on: "keydown"`, so bindingMatches lets every
1077
+ // keydown through and this check, per record, picks the chord that fired.
1078
+ function shortcutApplies(spec, event, keyMappings) {
1079
+ const target = event.target
1080
+ if (spec?.inEditable !== true && (target?.isContentEditable || /^(input|textarea|select)$/.test(target?.localName)))
1081
+ return false
1082
+ const mod = /[Mm]ac|iP/.test(navigator.userAgentData?.platform ?? navigator.platform) ? "meta" : "ctrl"
1083
+ return keyFilterMatches(shortcutKeys(spec).replace(/^mod\+/, `${mod}+`), event, keyMappings)
1084
+ }
1085
+
966
1086
  // Parse data-reactive-ops-param into binding records. Stimulus typecasts a
967
1087
  // lone record to an object; several records stay a string (JSON.parse fails on
968
1088
  // the join) and are split here. A LEGACY [[op, args]] list (array or JSON
@@ -1283,6 +1403,11 @@ function sortableNeeded(root) {
1283
1403
  return Boolean(root.matches?.(lists) || root.querySelector?.(lists))
1284
1404
  }
1285
1405
 
1406
+ // Presence (issue #358): the root is a reactive_presence roster.
1407
+ function presenceNeeded(root) {
1408
+ return root.getAttribute?.("data-reactive-presence") != null
1409
+ }
1410
+
1286
1411
  const PRODUCTION_FEATURES = [
1287
1412
  // (Its `waiting` hook is the opt-in entry's: core.js.) The draft restore
1288
1413
  // writes what a request reads, so it gates.
@@ -1295,6 +1420,9 @@ const PRODUCTION_FEATURES = [
1295
1420
  ["compute", [computeNeeded, null, null, true]],
1296
1421
  // Nothing it does at connect is read by a request: it never gates.
1297
1422
  ["sortable", [sortableNeeded, null]],
1423
+ // Its announce is a request of its own; nothing it does at connect is read
1424
+ // by another request: it never gates.
1425
+ ["presence", [presenceNeeded, null]],
1298
1426
  [
1299
1427
  // Stream effects and dismissing flashes are document-level: the module
1300
1428
  // has no connect. A root that DECLARES an effect only gets it fetched
@@ -1458,7 +1586,7 @@ function featureTimeoutMs() {
1458
1586
  export function reactiveFeatureNames() {
1459
1587
  return __SPLIT__
1460
1588
  ? [...FEATURES.keys()]
1461
- : ["persist", "defer", "form", "bindings", "compute", "sortable", "effects", "hints", "devtools"]
1589
+ : ["persist", "defer", "form", "bindings", "compute", "sortable", "presence", "effects", "hints", "devtools"]
1462
1590
  }
1463
1591
 
1464
1592
  export function __setReactiveFeatureForTest(name, needs, load, waiting, gates) {
@@ -1481,6 +1609,7 @@ export function __loadReactiveFeatureForTest(name) {
1481
1609
  bindings: bindingsFeature,
1482
1610
  compute: computeFeature,
1483
1611
  sortable: sortableFeature,
1612
+ presence: presenceFeature,
1484
1613
  effects: effectsFeature,
1485
1614
  hints: hintsFeature,
1486
1615
  devtools: devtoolsFeature,
@@ -1534,6 +1663,7 @@ const FORM = 4
1534
1663
  const BINDINGS = 8
1535
1664
  const COMPUTE = 16
1536
1665
  const SORTABLE = 32
1666
+ const PRESENCE = 64
1537
1667
 
1538
1668
  // The default entry's features are part of this file: install them now, as
1539
1669
  // the opt-in entry does when one arrives.
@@ -1755,7 +1885,7 @@ export default class extends Controller {
1755
1885
  #featureTurns = new Map() // name -> settles once that feature has had its turn to connect
1756
1886
  #featureEpoch = 0
1757
1887
  #featureHandle // the `core` handle features receive, built on first use
1758
- // The default entry (issue #305): one bit per feature, PERSIST … SORTABLE —
1888
+ // The default entry (issue #305): one bit per feature, PERSIST … PRESENCE —
1759
1889
  // asked for (its marker read true) and connected on this connection.
1760
1890
  #featuresAsked = 0
1761
1891
  #featuresOn = 0
@@ -1802,15 +1932,20 @@ export default class extends Controller {
1802
1932
  // The same listener hears an editable being focused (issue #351): a stream
1803
1933
  // can append a root's first reactive_editable without morphing the root,
1804
1934
  // and its keys and paste need the bindings feature before anything is
1805
- // typed into it.
1935
+ // typed into it. And it hears a stream that rendered into the root
1936
+ // (STREAMED, issue #356): the features already connected re-run their
1937
+ // seeds first (a morph's own listeners do that for a morph), then what the
1938
+ // new content needs connects.
1806
1939
  if (this.element.getAttribute?.("data-reactive-token-value") != null || deferNeeded(this.element)) {
1807
1940
  this.#boundRootMorph = (event) => {
1808
1941
  if (event.type === "focusin" ? !event.target.hasAttribute?.("data-reactive-editable") : event.target !== this.element) return
1942
+ if (event.type === STREAMED) this.#reseed()
1809
1943
  if (__SPLIT__) this.#loadFeatures(true)
1810
1944
  else this.#connectFeaturesNow(true)
1811
1945
  }
1812
1946
  this.element.addEventListener?.("turbo:morph-element", this.#boundRootMorph)
1813
1947
  this.element.addEventListener?.("focusin", this.#boundRootMorph)
1948
+ this.element.addEventListener?.(STREAMED, this.#boundRootMorph)
1814
1949
  }
1815
1950
 
1816
1951
  // Feature modules (issue #275): connect the ones that are here and START
@@ -1849,6 +1984,7 @@ export default class extends Controller {
1849
1984
  this.#connectFeatureNow(BINDINGS, "bindings", bindingsNeeded, (core) => bindingsFeature.connect(this, core, morphed))
1850
1985
  this.#connectFeatureNow(COMPUTE, "compute", computeNeeded, (core) => computeFeature.connect(this, core, morphed))
1851
1986
  this.#connectFeatureNow(SORTABLE, "sortable", sortableNeeded, (core) => sortableFeature.connect(this, core))
1987
+ this.#connectFeatureNow(PRESENCE, "presence", presenceNeeded, (core) => presenceFeature.connect(this, core))
1852
1988
  }
1853
1989
  }
1854
1990
 
@@ -1886,6 +2022,7 @@ export default class extends Controller {
1886
2022
  if (on & BINDINGS) this.#disconnectFeatureNow("bindings", () => bindingsFeature.disconnect(this, core))
1887
2023
  if (on & COMPUTE) this.#disconnectFeatureNow("compute", () => computeFeature.disconnect(this, core))
1888
2024
  if (on & SORTABLE) this.#disconnectFeatureNow("sortable", () => sortableFeature.disconnect(this, core))
2025
+ if (on & PRESENCE) this.#disconnectFeatureNow("presence", () => presenceFeature.disconnect(this, core))
1889
2026
  }
1890
2027
  }
1891
2028
 
@@ -2074,7 +2211,9 @@ export default class extends Controller {
2074
2211
  ),
2075
2212
  listnavOptions: (event) => this.#listnavOptions(event),
2076
2213
  collectFields: () => this.#collectFields(),
2077
- // A reactive_editable's value and its last server-rendered text (#351).
2214
+ // A reactive_editable's value and its last server-rendered text (#351),
2215
+ // and whether the engine has contenteditable="plaintext-only" (#355).
2216
+ plain: plaintextSupported,
2078
2217
  text: editableValue,
2079
2218
  served: serverText,
2080
2219
  // The confirm gate, as dispatch() runs it: the resolver inside the chain
@@ -2089,15 +2228,17 @@ export default class extends Controller {
2089
2228
 
2090
2229
  // Re-run the connect-time seeds that read field values, in connect() order
2091
2230
  // — for a feature that changed those values after connect() ran (the draft
2092
- // restore). Each is the same re-sync its feature runs after a morph; a root
2093
- // that did not opt into one skips it. on-complete re-ARMS without firing.
2231
+ // restore), or content a stream rendered into the root (issue #356). Each is
2232
+ // the same re-sync its feature runs after a morph; a root that did not opt
2233
+ // into one skips it. on-complete re-ARMS without firing. Form and bindings
2234
+ // also wire a binding whose gate reads true only now (a stream's content).
2094
2235
  #reseed() {
2095
2236
  if (!__SPLIT__) {
2096
- if (this.#featuresOn & FORM) formFeature.scan(this, this.#featureCore())
2237
+ if (this.#featuresOn & FORM) formFeature.reseed(this)
2097
2238
  if (this.#featuresOn & BINDINGS) bindingsFeature.reseed(this)
2098
2239
  if (this.#featuresOn & COMPUTE) computeFeature.seed(this)
2099
2240
  } else {
2100
- this.#features.get("form")?.scan(this, this.#featureCore())
2241
+ this.#features.get("form")?.reseed(this)
2101
2242
  this.#features.get("bindings")?.reseed(this)
2102
2243
  this.#features.get("compute")?.seed(this)
2103
2244
  }
@@ -2270,6 +2411,7 @@ export default class extends Controller {
2270
2411
  if (this.#boundRootMorph) {
2271
2412
  this.element.removeEventListener?.("turbo:morph-element", this.#boundRootMorph)
2272
2413
  this.element.removeEventListener?.("focusin", this.#boundRootMorph)
2414
+ this.element.removeEventListener?.(STREAMED, this.#boundRootMorph)
2273
2415
  }
2274
2416
  // Early triggers (issue #273): a disconnected root records again.
2275
2417
  earlyState().connected.delete(this.element)
@@ -2296,9 +2438,13 @@ export default class extends Controller {
2296
2438
  // modifier params (issue #80). The client decides preventDefault behavior
2297
2439
  // from event.params — set by the Ruby on() — never by sniffing the
2298
2440
  // Stimulus descriptor.
2299
- const { action, params, debounce, throttle, confirm, confirmWhen, outside, window: windowBound, optimistic } =
2441
+ const { action, params, debounce, throttle, confirm, confirmWhen, outside, window: windowBound, optimistic, shortcut } =
2300
2442
  event.params
2301
2443
  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
2302
2448
 
2303
2449
  // The pending-state hint (issue #181): data-reactive-busy-param. During a
2304
2450
  // deploy overlap a page rendered by the PREVIOUS gem still emits the old
@@ -2345,7 +2491,11 @@ export default class extends Controller {
2345
2491
  // (which has no form-navigation default to lose) skips it and flips now, and
2346
2492
  // the failure revert snaps it back. A `change`-bound trigger is unaffected —
2347
2493
  // `change` isn't cancelable, so preventDefault was already a no-op there.
2348
- if (!windowBound && !this.#keepsNativeToggle(optimistic, target)) event.preventDefault()
2494
+ //
2495
+ // A shortcut (issue #357) IS prevented: its key filter has already narrowed
2496
+ // the window binding to one chord the page claims (mod+s must not also open
2497
+ // the browser's save dialog).
2498
+ if ((shortcut != null || !windowBound) && !this.#keepsNativeToggle(optimistic, target)) event.preventDefault()
2349
2499
 
2350
2500
  // A reactive_lazy(on:) shell's trigger (issue #276) goes through the one
2351
2501
  // materialize entry point, which dedupes it against the observer, the
@@ -2484,6 +2634,10 @@ export default class extends Controller {
2484
2634
  // A detail miss (issue #346) is a complete no-op — checked before the
2485
2635
  // once record is spent, preventDefault and the confirm gate.
2486
2636
  if (!detailMatches(record, event)) continue
2637
+ // So is a shortcut that is not this platform's twin or fired in an
2638
+ // editable target (issue #357).
2639
+ if (record.shortcut != null && !shortcutApplies(record.shortcut, event, this.application?.schema?.keyMappings))
2640
+ continue
2487
2641
  if (onceBindingSpent(this, event, record)) continue
2488
2642
  this.#runBinding(record.legacy ? { ...params, ops: record.ops } : record, event, trigger)
2489
2643
  }
@@ -2491,7 +2645,7 @@ export default class extends Controller {
2491
2645
 
2492
2646
  // One on_client binding (issue #95): its outside guard, preventDefault rule,
2493
2647
  // confirm gate, then its ops — every flag read from the binding itself.
2494
- #runBinding({ ops, confirm, confirmWhen, outside, window: windowBound }, event, trigger) {
2648
+ #runBinding({ ops, confirm, confirmWhen, outside, window: windowBound, shortcut }, event, trigger) {
2495
2649
  // Outside guard FIRST — identical semantics to dispatch() (issue #80): an
2496
2650
  // outside: trigger is a COMPLETE no-op for events inside this root, before
2497
2651
  // preventDefault and before any op runs.
@@ -2503,7 +2657,8 @@ export default class extends Controller {
2503
2657
  // would kill native clicks site-wide (issue #80 rationale). Runs BEFORE the
2504
2658
  // (possibly async) confirm gate below — a native default can't wait for a
2505
2659
  // pending dialog (same ordering as dispatch()).
2506
- if (!windowBound) event.preventDefault()
2660
+ // A shortcut (issue #357) is prevented: it is one chord the page claims.
2661
+ if (shortcut != null || !windowBound) event.preventDefault()
2507
2662
 
2508
2663
  // Resolve the effective confirm message — static string, or the conditional
2509
2664
  // Hash form (issue #179) evaluated over collected fields (a promise of it
@@ -3044,6 +3199,12 @@ export default class extends Controller {
3044
3199
  // already gets the action's HTTP response. Harmless without pgbus.
3045
3200
  const connectionId = this.#connectionId()
3046
3201
  if (connectionId) headers["X-Pgbus-Connection"] = connectionId
3202
+ // A presence root's own connection (issue #358): the row its presence
3203
+ // writes land on, whatever source comes first on the page.
3204
+ const presence =
3205
+ presenceNeeded(this.element) &&
3206
+ document.getElementById(`reactive-presence-src-${this.element.id}`)?.getAttribute("connection-id")
3207
+ if (presence) headers["X-Reactive-Presence"] = presence
3047
3208
 
3048
3209
  // ONLY `fetch` itself is the network boundary — offline, DNS, a reset
3049
3210
  // connection. Everything below this inner try (reading the body,
@@ -356,6 +356,11 @@ module Phlex
356
356
  raise ArgumentError, "#{self}: reactive_dormant takes true or false, got #{dormant.inspect}"
357
357
  end
358
358
 
359
+ # Issue #358: a dormant root never connects, so it could never join.
360
+ # A ClientBindings-only class has no presence declaration to refuse.
361
+ refuse_dormant_presence! if dormant && respond_to?(:reactive_presence_declaration) &&
362
+ reactive_presence_declaration
363
+
359
364
  Phlex::Reactive::Dormant.reject_visible_lazy!(self) if dormant
360
365
 
361
366
  Registry.write_scalar(self, :dormant, dormant)
@@ -127,6 +127,10 @@ module Phlex
127
127
  (on_complete = self.class.reactive_on_complete_attr)
128
128
  data[:reactive_on_complete] = on_complete
129
129
  end
130
+ # Presence (issue #358): the signed src of the roster's presence
131
+ # stream, from which the client mints its subscription. Only a class
132
+ # that declares reactive_presence has the method.
133
+ data.merge!(reactive_presence_data) if respond_to?(:reactive_presence_data)
130
134
  { data: }
131
135
  end
132
136
 
@@ -280,6 +284,17 @@ module Phlex
280
284
  # (trailing-edge) — passing both raises ArgumentError.
281
285
  # div(**mix(reactive_root, on(:track, event: "scroll", window: true, throttle: 250)))
282
286
  #
287
+ # `shortcut:` (issue #357, also RESERVED) binds a page-level keyboard
288
+ # chord: a window keydown whose `mod` is meta on Apple platforms and
289
+ # ctrl elsewhere (rendered as a meta twin and a ctrl twin; the client
290
+ # runs the one for its platform). Unlike a plain window binding, the
291
+ # client prevents the chord's native default, and it skips the chord
292
+ # while focus is in an input, textarea, select or contenteditable
293
+ # (native text undo keeps working) unless the Hash form opts in. It
294
+ # decides event:, window: and outside:, so passing one raises.
295
+ # span(**on(:undo, shortcut: "mod+z"))
296
+ # span(**on(:close, shortcut: { keys: "esc", in_editable: true }))
297
+ #
283
298
  # `optimistic:` (issue #98) — a small, ALWAYS-REVERSIBLE vocabulary of
284
299
  # COSMETIC hints the client applies the instant the trigger fires and
285
300
  # REVERTS if the round trip fails, so a click/toggle gives instant feedback
@@ -324,9 +339,9 @@ module Phlex
324
339
  # busy_on for scoped indicators.
325
340
  # button(**on(:save, busy: "Saving…")) { "Save" }
326
341
  # button(**on(:destroy, confirm: "Sure?", busy: { add_class: "opacity-50" })) { "Delete" }
327
- def on(action_name, event: "click", debounce: nil, throttle: nil, confirm: nil,
342
+ def on(action_name, event: nil, debounce: nil, throttle: nil, confirm: nil,
328
343
  window: false, once: false, outside: false, optimistic: nil, busy: nil,
329
- **params)
344
+ shortcut: nil, **params)
330
345
  reject_removed_on_kwargs!(action_name, params)
331
346
  # A typo'd or forgotten action renders fine and only surfaces as an
332
347
  # opaque 403 at CLICK time (the endpoint's default-deny). Under
@@ -362,8 +377,11 @@ module Phlex
362
377
  "exclusive (debounce is trailing-edge, throttle is leading-edge); pick one"
363
378
  end
364
379
 
365
- window_bound = window || outside
366
- action = "#{event}#{"@window" if window_bound}->reactive#dispatch#{":once" if once}"
380
+ # Guarded here, not inside: building the caller label allocates (on() is a hot path).
381
+ shortcut = shortcut_for!("on(#{action_name.inspect})", shortcut, event:, window:, outside:) unless shortcut.nil?
382
+ event ||= "click"
383
+ window_bound = window || outside || !shortcut.nil?
384
+ action = trigger_descriptors(event, shortcut, window_bound, "dispatch", once)
367
385
  attrs = {
368
386
  data: {
369
387
  action:,
@@ -384,6 +402,9 @@ module Phlex
384
402
  # The client decides preventDefault behavior from event.params (never by
385
403
  # sniffing the descriptor), so EVERY window binding flags the param.
386
404
  attrs[:data][:reactive_window_param] = "true" if window_bound
405
+ # Issue #357: the client reads the shortcut to pick the twin for its
406
+ # platform, skip editable targets and prevent the chord.
407
+ attrs[:data][:reactive_shortcut_param] = shortcut_param_attr(shortcut) if shortcut
387
408
  # Force type="button" for click triggers so a bare button inside a <form>
388
409
  # can't submit it — EXCEPT when checked: :keep is declared: that hint's
389
410
  # whole point is to let a click-bound checkbox/radio flip natively, and a
@@ -406,7 +427,9 @@ module Phlex
406
427
  # modifiers (#80): outside-click-to-close a dropdown is
407
428
  # div(**mix(reactive_root, on_client(:click, js.hide("#menu"), outside: true)))
408
429
  # Window-bound triggers are never preventDefault-ed by the client and skip
409
- # the forced type="button".
430
+ # the forced type="button". `shortcut:` (issue #357) works as on()'s, on
431
+ # a bare :keydown only:
432
+ # div(**mix(reactive_root, on_client(:keydown, js.toggle("#palette"), shortcut: "mod+k")))
410
433
  #
411
434
  # Issue #271: each call emits ONE self-describing binding record —
412
435
  # data-reactive-ops-param='{"on":"click","window":true,"outside":true,"ops":[…]}'
@@ -437,7 +460,8 @@ module Phlex
437
460
  # Validation is loud: only a non-empty Phlex::Reactive::JS chain is
438
461
  # accepted — a dead trigger should fail at render, not no-op in the
439
462
  # browser.
440
- def on_client(event, ops, window: false, once: false, outside: false, confirm: nil, detail: nil)
463
+ def on_client(event, ops, window: false, once: false, outside: false, confirm: nil, detail: nil,
464
+ shortcut: nil)
441
465
  unless ops.is_a?(Phlex::Reactive::JS)
442
466
  raise ArgumentError,
443
467
  "on_client expects a Phlex::Reactive::JS chain (e.g. js.toggle(\"#menu\")), " \
@@ -454,11 +478,15 @@ module Phlex
454
478
  "submit event this trigger is bound to. Bind the submit op to another event " \
455
479
  "(change/input), or gate it behind a reducer's $ops / reactive_on_complete."
456
480
  end
457
- window_bound = window || outside
481
+ shortcut = client_shortcut!(event, shortcut, window:, outside:)
482
+ window_bound = window || outside || !shortcut.nil?
458
483
  record = client_binding_record(event, ops, window_bound:, outside:, once:, confirm:, detail:)
484
+ # The record's `on` stays the bare keydown: the client reads a key
485
+ # filter from `on`, and `mod` is not a key.
486
+ record["shortcut"] = shortcut.param if shortcut
459
487
  attrs = {
460
488
  data: {
461
- action: "#{event}#{"@window" if window_bound}->reactive#runOps#{":once" if once}",
489
+ action: trigger_descriptors(event, shortcut, window_bound, "runOps", once),
462
490
  # Space-free (issue #271): compact JSON has no structural
463
491
  # whitespace, so every space is inside a string, where \u0020
464
492
  # parses to the same string — and mix's space-join stays splittable.
@@ -1732,6 +1760,49 @@ module Phlex
1732
1760
  "on(#{action_name.inspect}) #{key}: was removed in issue #181 — use #{REMOVED_ON_KWARGS[key]}"
1733
1761
  end
1734
1762
 
1763
+ # Issue #357: parse on()'s shortcut: (nil when absent). A shortcut
1764
+ # decides the event, the window binding and the containment rule, so
1765
+ # an explicit event:/window:/outside: next to it is a mistake.
1766
+ def shortcut_for!(caller, shortcut, event:, window:, outside:)
1767
+ { "event:" => event, "window:" => window, "outside:" => outside }.each do |name, value|
1768
+ next unless value
1769
+
1770
+ raise ArgumentError,
1771
+ "#{caller} got shortcut: with #{name} — a shortcut is always a window keydown " \
1772
+ "binding; drop #{name}"
1773
+ end
1774
+ Phlex::Reactive::Shortcut.parse(shortcut)
1775
+ end
1776
+
1777
+ # on_client's event is positional: with shortcut: it must be the bare
1778
+ # keydown (the key filter comes from the shortcut).
1779
+ def client_shortcut!(event, shortcut, window:, outside:)
1780
+ return if shortcut.nil?
1781
+
1782
+ unless event == "keydown"
1783
+ raise ArgumentError,
1784
+ "on_client(#{event.inspect}) got shortcut: — bind it to :keydown " \
1785
+ "(on_client(:keydown, ops, shortcut: \"mod+k\")); the key comes from the shortcut"
1786
+ end
1787
+ shortcut_for!("on_client(:keydown)", shortcut, event: nil, window:, outside:)
1788
+ end
1789
+
1790
+ # The data-action descriptors of an on()/on_client trigger: one, or a
1791
+ # shortcut's meta and ctrl twins (space-joined, as mix joins them).
1792
+ # The common, shortcut-less path is ONE interpolation (on() is a hot path).
1793
+ def trigger_descriptors(event, shortcut, window_bound, method, once)
1794
+ return "#{event}#{"@window" if window_bound}->reactive##{method}#{":once" if once}" unless shortcut
1795
+
1796
+ shortcut.filters.map { "keydown.#{it}@window->reactive##{method}#{":once" if once}" }.join(" ")
1797
+ end
1798
+
1799
+ # A Hash param is JSON (Stimulus's param reader parses it back into an
1800
+ # object); the String form stays a plain string.
1801
+ def shortcut_param_attr(shortcut)
1802
+ param = shortcut.param
1803
+ param.is_a?(Hash) ? param.to_json : param
1804
+ end
1805
+
1735
1806
  # Normalize + validate a pending-state hint (issue #181), returning its
1736
1807
  # JSON wire form. ONE vocabulary + ONE normalizer for both optimistic: (kind
1737
1808
  # :optimistic, reverts on FAILURE) and busy: (kind :busy, reverts on
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlex
4
+ module Reactive
5
+ module Component
6
+ # The reactive_presence macro (issue #358). Not named `Presence`: this
7
+ # module is in every reactive component's constant lookup, where a bare
8
+ # noun would shadow a host app's kit component of that name (the #233
9
+ # rule). The behaviour it adds lives in Phlex::Reactive::Presence.
10
+ module PresenceDeclaration
11
+ extend ActiveSupport::Concern
12
+
13
+ # A presence name is the last part of the stream name, so it is one
14
+ # identifier: no colon (the stream-name separator), no spaces.
15
+ PRESENCE_NAME = /\A[a-z][a-z0-9_]*\z/
16
+
17
+ class_methods do
18
+ # Declare this component the roster of a presence stream:
19
+ # [record, :presence, name], or [*key, :presence, name] for a
20
+ # record-less component.
21
+ #
22
+ # reactive_record :board
23
+ # reactive_presence :viewers, state: { selected: :integer }, ttl: 45
24
+ #
25
+ # `state:` is each tab's ephemeral state, typed with the same table as
26
+ # action params; undeclared keys are dropped. `ttl:` is how many
27
+ # seconds a silent tab survives. Adds the presence_set action (what
28
+ # js.presence sends) and `presence` (the roster) to the component.
29
+ def reactive_presence(name, state: {}, ttl: Phlex::Reactive::Presence::DEFAULT_TTL, key: nil)
30
+ require_server_actions!(:reactive_presence)
31
+ declaration = build_presence_declaration(name, state, ttl, key)
32
+ refuse_dormant_presence! if reactive_dormant?
33
+ # The name's fit in pgbus's stream-name budget is configuration (the
34
+ # queue prefix), so it is refused here; the render re-checks it.
35
+ transport = Phlex::Reactive::Presence::Transport
36
+ if transport.wired? && !transport.fits?(declaration.name)
37
+ raise Phlex::Reactive::ConfigurationError, transport.over_budget_message(self, declaration.name)
38
+ end
39
+
40
+ # The generated action. Exempt from verify_authorized: the signed
41
+ # token already proves the actor rendered this root, and the write
42
+ # is typed, default-deny, ephemeral and lands only on the actor's own
43
+ # row (keyed by its secret SSE connection id). A component that needs
44
+ # a check defines authorize_presence_state!(**state); the action runs
45
+ # it first.
46
+ action :presence_set, params: declaration.state
47
+ skip_verify_authorized :presence_set
48
+ Registry.write_scalar(self, :presence, declaration)
49
+ include Phlex::Reactive::Presence::ComponentMethods
50
+
51
+ # Class load runs before a database exists: the signatures alone gate it.
52
+ transport.register_ttl(declaration.name, ttl) if transport.wired?
53
+ declaration
54
+ end
55
+
56
+ # The Phlex::Reactive::Presence::Declaration, or nil.
57
+ def reactive_presence_declaration
58
+ Registry.resolve_scalar(self, :presence, :reactive_presence_declaration)
59
+ end
60
+
61
+ # The presence stream name a roster of `record` subscribes to — what
62
+ # streams_authorize sees. For a strict per-record check at connect
63
+ # time: compare it with the key of each record the user may view.
64
+ # A record-less roster (key:) ignores `record`.
65
+ def reactive_presence_stream_name_for(record = nil)
66
+ declaration = reactive_presence_declaration
67
+ subject = declaration.key || [record]
68
+ Phlex::Reactive::Presence::Transport.stream_name([*subject, :presence, declaration.name])
69
+ end
70
+
71
+ private
72
+
73
+ def build_presence_declaration(name, state, ttl, key)
74
+ name = name.to_s
75
+ unless PRESENCE_NAME.match?(name)
76
+ raise ArgumentError,
77
+ "#{self.name}: reactive_presence name must be a lowercase identifier (it ends the stream " \
78
+ "name), got #{name.inspect}"
79
+ end
80
+ unless ttl.is_a?(Integer) && ttl.positive?
81
+ raise ArgumentError, "#{self.name}: reactive_presence ttl: must be a positive Integer (seconds), " \
82
+ "got #{ttl.inspect}"
83
+ end
84
+ refuse_ttl_within_heartbeat!(ttl)
85
+
86
+ state = state.transform_keys(&:to_sym)
87
+ if state.key?(Phlex::Reactive::Presence::NOTE_KEY.to_sym)
88
+ raise ArgumentError,
89
+ "#{self.name}: reactive_presence state key #{Phlex::Reactive::Presence::NOTE_KEY} is reserved"
90
+ end
91
+
92
+ Phlex::Reactive::Presence::Declaration.new(
93
+ name: name.to_sym, state: state.freeze, ttl:, key: key && Array(key).dup.freeze
94
+ )
95
+ end
96
+
97
+ # A ttl at or below the heartbeat would expire a live tab between two
98
+ # beats: its row would vanish and come back on the next one.
99
+ def refuse_ttl_within_heartbeat!(ttl)
100
+ heartbeat = Phlex::Reactive::Presence::Transport.heartbeat_interval
101
+ return if ttl > heartbeat
102
+
103
+ raise ArgumentError,
104
+ "#{name}: reactive_presence ttl: #{ttl} is at or below the transport's heartbeat interval " \
105
+ "(#{heartbeat} s), so a live tab would expire between two beats. Use more than #{heartbeat} " \
106
+ "(two missed beats is a good floor)."
107
+ end
108
+
109
+ def refuse_dormant_presence!
110
+ raise ArgumentError,
111
+ "#{name}: reactive_presence cannot be dormant — a dormant root never connects, so its tab " \
112
+ "would never join the stream. Drop reactive_dormant on this component."
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end