phlex-reactive 0.14.3 → 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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +107 -0
  3. data/README.md +258 -5
  4. data/app/controllers/phlex/reactive/actions_controller.rb +28 -18
  5. data/app/javascript/phlex/reactive/core.js +7 -1
  6. data/app/javascript/phlex/reactive/core.min.js +6 -2
  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 +188 -107
  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 +75 -46
  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/persist.js +12 -3
  18. data/app/javascript/phlex/reactive/features/persist.min.js +2 -2
  19. data/app/javascript/phlex/reactive/features/persist.min.js.map +3 -3
  20. data/app/javascript/phlex/reactive/features/presence.js +205 -0
  21. data/app/javascript/phlex/reactive/features/presence.min.js +4 -0
  22. data/app/javascript/phlex/reactive/features/presence.min.js.map +10 -0
  23. data/app/javascript/phlex/reactive/features/sortable.js +463 -0
  24. data/app/javascript/phlex/reactive/features/sortable.min.js +4 -0
  25. data/app/javascript/phlex/reactive/features/sortable.min.js.map +10 -0
  26. data/app/javascript/phlex/reactive/inspect.js +5 -1
  27. data/app/javascript/phlex/reactive/inspect.min.js +2 -2
  28. data/app/javascript/phlex/reactive/inspect.min.js.map +3 -3
  29. data/app/javascript/phlex/reactive/reactive_controller.js +5 -1
  30. data/app/javascript/phlex/reactive/reactive_controller.min.js +6 -2
  31. data/app/javascript/phlex/reactive/reactive_controller.min.js.map +10 -8
  32. data/app/javascript/phlex/reactive/runtime.js +286 -18
  33. data/lib/phlex/reactive/component/dsl.rb +5 -0
  34. data/lib/phlex/reactive/component/editable.rb +78 -0
  35. data/lib/phlex/reactive/component/helpers.rb +81 -8
  36. data/lib/phlex/reactive/component/presence_declaration.rb +118 -0
  37. data/lib/phlex/reactive/component/registry.rb +3 -1
  38. data/lib/phlex/reactive/component/sortable.rb +212 -0
  39. data/lib/phlex/reactive/component.rb +1 -0
  40. data/lib/phlex/reactive/defer.rb +5 -0
  41. data/lib/phlex/reactive/engine.rb +9 -4
  42. data/lib/phlex/reactive/js.rb +24 -0
  43. data/lib/phlex/reactive/presence/component_methods.rb +130 -0
  44. data/lib/phlex/reactive/presence/roster.rb +90 -0
  45. data/lib/phlex/reactive/presence/transport.rb +154 -0
  46. data/lib/phlex/reactive/presence.rb +162 -0
  47. data/lib/phlex/reactive/presence_render_job.rb +26 -0
  48. data/lib/phlex/reactive/shortcut.rb +105 -0
  49. data/lib/phlex/reactive/streamable.rb +7 -3
  50. data/lib/phlex/reactive/version.rb +1 -1
  51. data/lib/phlex/reactive.rb +10 -0
  52. metadata +16 -1
@@ -35,6 +35,8 @@ import * as effectsFeature from "phlex/reactive/features/effects"
35
35
  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
+ import * as sortableFeature from "phlex/reactive/features/sortable"
39
+ import * as presenceFeature from "phlex/reactive/features/presence"
38
40
 
39
41
  // phlex/reactive/runtime — the reactive controller WITHOUT its feature
40
42
  // modules (issue #275): the code both client entries share. It is not an
@@ -198,6 +200,7 @@ export function registerReactiveStreamRender() {
198
200
  if (typeof document === "undefined" || typeof document.addEventListener !== "function") return
199
201
  streamRenderRegistered = true
200
202
  document.addEventListener("turbo:before-stream-render", (event) => {
203
+ streamIntoRoots(event)
201
204
  // The opt-in entry sees every stream (it may hold one for a module the
202
205
  // incoming content needs); with no entry hook, a loaded effects module
203
206
  // wraps the stream directly — in the default entry, always.
@@ -209,6 +212,39 @@ export function registerReactiveStreamRender() {
209
212
  // The names this registration had while it was two listeners.
210
213
  export { registerReactiveStreamRender as registerReactiveDismiss, registerReactiveStreamRender as registerReactiveEffects }
211
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
+
212
248
  // For the opt-in entry: what to do with a stream before its render — hold it
213
249
  // for a module the incoming content needs. `handle(event, effects)` gets the
214
250
  // loaded effects module (or undefined) and wraps the stream with it itself.
@@ -236,12 +272,80 @@ export function __resetReactiveStreamRenderForTest() {
236
272
  // for the attribute AND the property. Nothing else
237
273
  // (a root's token, a field's class) is held.
238
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
+
239
335
  let morphFocusRegistered = false
240
336
 
337
+ //
338
+ // A reactive_editable (issue #351) is held the same way, but its "value" is its
339
+ // CHILDREN: a focused one keeps them (text and caret) while every attribute
340
+ // still morphs — copied here from the render, then the element's morph is
341
+ // cancelled (cancelling is all-or-nothing in Idiomorph). Its contenteditable
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
344
+ // render's text as its SERVER TEXT (the Escape revert, the dirty baseline).
241
345
  function holdsTypedValue(el) {
242
346
  return (
243
347
  el === document.activeElement &&
244
- (el.localName === "input" || el.localName === "textarea") &&
348
+ (el.localName === "input" || el.localName === "textarea" || el.hasAttribute("data-reactive-editable")) &&
245
349
  !el.hasAttribute("data-reactive-morph-value") &&
246
350
  !!el.closest('[data-controller~="reactive"]')
247
351
  )
@@ -254,11 +358,26 @@ export function registerReactiveMorphFocus() {
254
358
  document.addEventListener("turbo:before-morph-element", (event) => {
255
359
  const el = event.target
256
360
  const next = event.detail?.newElement // absent when Turbo asks before a removal
257
- if (!next || event.defaultPrevented || el.localName !== "input" || !holdsTypedValue(el)) return
361
+ if (!next || event.defaultPrevented) return
362
+ if (el.hasAttribute?.("data-reactive-editable")) {
363
+ serverTexts.set(el, editableValue(next))
364
+ if (!holdsTypedValue(el)) return
365
+ for (const { name } of [...el.attributes]) {
366
+ if (!next.hasAttribute(name) && name !== "contenteditable") el.removeAttribute(name)
367
+ }
368
+ for (const { name, value } of next.attributes) {
369
+ if (name !== "contenteditable" && el.getAttribute(name) !== value) el.setAttribute(name, value)
370
+ }
371
+ return event.preventDefault()
372
+ }
373
+ if (el.localName !== "input" || !holdsTypedValue(el)) return
258
374
  const value = next.getAttribute("value")
259
375
  if (value == null) el.removeAttribute("value")
260
376
  else if (el.getAttribute("value") !== value) el.setAttribute("value", value)
261
377
  })
378
+ // An editable a morph never reached (a stream append) records its server
379
+ // text when first focused — before anything can be typed into it.
380
+ document.addEventListener("focusin", (event) => seedServerText(event.target))
262
381
  document.addEventListener("turbo:before-morph-attribute", (event) => {
263
382
  if (event.detail?.attributeName === "value" && holdsTypedValue(event.target)) event.preventDefault()
264
383
  })
@@ -462,6 +581,7 @@ export function registerReactiveActions() {
462
581
  registerReactiveDefer()
463
582
  registerReactiveStreamRender()
464
583
  registerReactiveMorphFocus()
584
+ registerReactivePlaintext()
465
585
  registerReactiveOffline()
466
586
  registerReactiveDev()
467
587
  }
@@ -623,6 +743,51 @@ export const EDITOR_SELECTOR =
623
743
  ":is(lexxy-editor, trix-editor, [contenteditable=''], [contenteditable=true], [contenteditable=plaintext-only])"
624
744
  export const EDITOR_TAGS = new Set(["lexxy-editor", "trix-editor"])
625
745
 
746
+ // A reactive_editable's value (issue #351): its text with line breaks — a
747
+ // <br> is one, a block element (the <div> a contenteditable=true Enter
748
+ // inserts) starts and ends a line — "\r\n" normalised, ONE trailing newline
749
+ // dropped (the <br> browsers keep at the end), and folded to spaces when the
750
+ // field is single-line. Read from the DOM, not innerText: no layout, the same
751
+ // on a detached render and under any white-space.
752
+ export function editableValue(el) {
753
+ let text = ""
754
+ // A block's edge asks for a line break; the next text or <br> pays it,
755
+ // once, unless the text already ends in one.
756
+ let line = false
757
+ const add = (s) => {
758
+ if (line && text && !text.endsWith("\n")) text += "\n"
759
+ line = false
760
+ text += s
761
+ }
762
+ const walk = (node) => {
763
+ for (const child of node.childNodes) {
764
+ const block = /^(div|p|li)$/.test(child.localName)
765
+ if (block) line = true
766
+ if (child.nodeType === 3) child.data && add(child.data)
767
+ else if (child.localName === "br") add("\n")
768
+ else if (child.nodeType === 1) walk(child)
769
+ if (block) line = true
770
+ }
771
+ }
772
+ walk(el)
773
+ text = text.replace(/\r\n?/g, "\n").replace(/\n$/, "")
774
+ return el.getAttribute("data-reactive-editable") === "multi" ? text : text.replace(/\n/g, " ")
775
+ }
776
+
777
+ // element -> the text of its last server render (issue #351): what Escape
778
+ // reverts to and what dirty tracking diffs against. Seeded when the root
779
+ // connects (or on first focus) and advanced by every morph of the element.
780
+ const serverTexts = new WeakMap()
781
+
782
+ function seedServerText(el) {
783
+ if (el?.hasAttribute?.("data-reactive-editable") && !serverTexts.has(el)) serverTexts.set(el, editableValue(el))
784
+ }
785
+
786
+ export function serverText(el) {
787
+ seedServerText(el)
788
+ return serverTexts.get(el)
789
+ }
790
+
626
791
  // A RICH editor (lexxy/trix) is only ready once its custom element upgraded —
627
792
  // Trix defines its elements in a setTimeout after load — and exposes the
628
793
  // string `value` accessor; reading it before that yields "", which is issue
@@ -697,6 +862,13 @@ const CLIENT_OPS = Object.freeze({
697
862
  }
698
863
  },
699
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
+
700
872
  // Dispatch a bubbling CustomEvent (issue #96). RAW element.dispatchEvent — the
701
873
  // controller SHADOWS Stimulus's this.dispatch helper, so it must not be used.
702
874
  dispatch: (el, args) => {
@@ -894,6 +1066,23 @@ function keyFilterMatches(filter, event, keyMappings) {
894
1066
  return typeof key === "string" && key.toLowerCase() === event.key.toLowerCase()
895
1067
  }
896
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
+
897
1086
  // Parse data-reactive-ops-param into binding records. Stimulus typecasts a
898
1087
  // lone record to an object; several records stay a string (JSON.parse fails on
899
1088
  // the join) and are split here. A LEGACY [[op, args]] list (array or JSON
@@ -1184,8 +1373,9 @@ function formNeeded(root) {
1184
1373
 
1185
1374
  // Show bindings and cross-root show targets, completion bindings, option
1186
1375
  // filtering, the bulk-selection group bindings (enable, select-all, count —
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
1376
+ // issue #319) and their cross-root targets (#343), the tag-chip input, draft
1377
+ // nested rows and editable text's keys and paste (#351): a form's client-only
1378
+ // bindings. The root declares one, or
1189
1379
  // owns an element that does. (The probes the connect-time gates ran before
1190
1380
  // the split, unchanged.)
1191
1381
  function bindingsNeeded(root) {
@@ -1195,7 +1385,7 @@ function bindingsNeeded(root) {
1195
1385
  ) ||
1196
1386
  (root.querySelectorAll?.("[data-reactive-show-field], [data-reactive-show]") ?? []).length > 0 ||
1197
1387
  root.querySelector?.(
1198
- "[data-reactive-nested-json], [data-reactive-nested-list], [data-reactive-confirm-when-param], [data-reactive-enable], [data-reactive-select-all], [data-reactive-count]",
1388
+ "[data-reactive-nested-json], [data-reactive-nested-list], [data-reactive-confirm-when-param], [data-reactive-enable], [data-reactive-select-all], [data-reactive-count], [data-reactive-editable]",
1199
1389
  ) != null
1200
1390
  )
1201
1391
  }
@@ -1205,6 +1395,19 @@ function computeNeeded(root) {
1205
1395
  return root.getAttribute?.("data-reactive-compute-inputs-param") != null
1206
1396
  }
1207
1397
 
1398
+ // Sortable lists (issue #350): the root is, or holds, a sortable list or a
1399
+ // source list. (A nested root's list loads the module for its parent too; the
1400
+ // module then checks ownership before it picks a row up.)
1401
+ function sortableNeeded(root) {
1402
+ const lists = "[data-reactive-sortable],[data-reactive-sortable-source]"
1403
+ return Boolean(root.matches?.(lists) || root.querySelector?.(lists))
1404
+ }
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
+
1208
1411
  const PRODUCTION_FEATURES = [
1209
1412
  // (Its `waiting` hook is the opt-in entry's: core.js.) The draft restore
1210
1413
  // writes what a request reads, so it gates.
@@ -1215,6 +1418,11 @@ const PRODUCTION_FEATURES = [
1215
1418
  ["bindings", [bindingsNeeded, null, null, true]],
1216
1419
  // The seed writes output fields a request collects, so it gates.
1217
1420
  ["compute", [computeNeeded, null, null, true]],
1421
+ // Nothing it does at connect is read by a request: it never gates.
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]],
1218
1426
  [
1219
1427
  // Stream effects and dismissing flashes are document-level: the module
1220
1428
  // has no connect. A root that DECLARES an effect only gets it fetched
@@ -1378,7 +1586,7 @@ function featureTimeoutMs() {
1378
1586
  export function reactiveFeatureNames() {
1379
1587
  return __SPLIT__
1380
1588
  ? [...FEATURES.keys()]
1381
- : ["persist", "defer", "form", "bindings", "compute", "effects", "hints", "devtools"]
1589
+ : ["persist", "defer", "form", "bindings", "compute", "sortable", "presence", "effects", "hints", "devtools"]
1382
1590
  }
1383
1591
 
1384
1592
  export function __setReactiveFeatureForTest(name, needs, load, waiting, gates) {
@@ -1400,6 +1608,8 @@ export function __loadReactiveFeatureForTest(name) {
1400
1608
  form: formFeature,
1401
1609
  bindings: bindingsFeature,
1402
1610
  compute: computeFeature,
1611
+ sortable: sortableFeature,
1612
+ presence: presenceFeature,
1403
1613
  effects: effectsFeature,
1404
1614
  hints: hintsFeature,
1405
1615
  devtools: devtoolsFeature,
@@ -1440,6 +1650,9 @@ const FEATURE_SHARED = {
1440
1650
  enter: enterReactiveActivity,
1441
1651
  exit: exitReactiveActivity,
1442
1652
  count: countReactiveRequest,
1653
+ // The editable collector and server text (issue #351), for the draft.
1654
+ text: editableValue,
1655
+ served: serverText,
1443
1656
  waiting: new WeakMap(),
1444
1657
  }
1445
1658
 
@@ -1449,6 +1662,8 @@ const DEFER = 2
1449
1662
  const FORM = 4
1450
1663
  const BINDINGS = 8
1451
1664
  const COMPUTE = 16
1665
+ const SORTABLE = 32
1666
+ const PRESENCE = 64
1452
1667
 
1453
1668
  // The default entry's features are part of this file: install them now, as
1454
1669
  // the opt-in entry does when one arrives.
@@ -1670,7 +1885,7 @@ export default class extends Controller {
1670
1885
  #featureTurns = new Map() // name -> settles once that feature has had its turn to connect
1671
1886
  #featureEpoch = 0
1672
1887
  #featureHandle // the `core` handle features receive, built on first use
1673
- // The default entry (issue #305): one bit per feature, PERSIST … COMPUTE —
1888
+ // The default entry (issue #305): one bit per feature, PERSIST … PRESENCE —
1674
1889
  // asked for (its marker read true) and connected on this connection.
1675
1890
  #featuresAsked = 0
1676
1891
  #featuresOn = 0
@@ -1714,13 +1929,23 @@ export default class extends Controller {
1714
1929
  // without any marker, before and after the morph, a few attribute reads and
1715
1930
  // never an import. (turbo:morph-element BUBBLES: only a morph of the root
1716
1931
  // itself counts.) A tokenless, client-only root has no such listener.
1932
+ // The same listener hears an editable being focused (issue #351): a stream
1933
+ // can append a root's first reactive_editable without morphing the root,
1934
+ // and its keys and paste need the bindings feature before anything is
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.
1717
1939
  if (this.element.getAttribute?.("data-reactive-token-value") != null || deferNeeded(this.element)) {
1718
1940
  this.#boundRootMorph = (event) => {
1719
- if (event.target !== this.element) return
1941
+ if (event.type === "focusin" ? !event.target.hasAttribute?.("data-reactive-editable") : event.target !== this.element) return
1942
+ if (event.type === STREAMED) this.#reseed()
1720
1943
  if (__SPLIT__) this.#loadFeatures(true)
1721
1944
  else this.#connectFeaturesNow(true)
1722
1945
  }
1723
1946
  this.element.addEventListener?.("turbo:morph-element", this.#boundRootMorph)
1947
+ this.element.addEventListener?.("focusin", this.#boundRootMorph)
1948
+ this.element.addEventListener?.(STREAMED, this.#boundRootMorph)
1724
1949
  }
1725
1950
 
1726
1951
  // Feature modules (issue #275): connect the ones that are here and START
@@ -1731,6 +1956,10 @@ export default class extends Controller {
1731
1956
  // re-runs the seeds itself (#reseed). Never awaited — connect() must reach
1732
1957
  // the drain at the end in the same task (issue #274: an await before it
1733
1958
  // runs a waking click twice).
1959
+ // The editables' server texts (issue #351), before a draft restore
1960
+ // writes into them.
1961
+ this.element.querySelectorAll?.("[data-reactive-editable]").forEach(seedServerText)
1962
+
1734
1963
  this.#featureEpoch++
1735
1964
  this.featuresReady = FEATURES_READY
1736
1965
  if (__SPLIT__) this.#loadFeatures()
@@ -1754,6 +1983,8 @@ export default class extends Controller {
1754
1983
  this.#connectFeatureNow(FORM, "form", formNeeded, (core) => formFeature.connect(this, core, morphed))
1755
1984
  this.#connectFeatureNow(BINDINGS, "bindings", bindingsNeeded, (core) => bindingsFeature.connect(this, core, morphed))
1756
1985
  this.#connectFeatureNow(COMPUTE, "compute", computeNeeded, (core) => computeFeature.connect(this, core, morphed))
1986
+ this.#connectFeatureNow(SORTABLE, "sortable", sortableNeeded, (core) => sortableFeature.connect(this, core))
1987
+ this.#connectFeatureNow(PRESENCE, "presence", presenceNeeded, (core) => presenceFeature.connect(this, core))
1757
1988
  }
1758
1989
  }
1759
1990
 
@@ -1790,6 +2021,8 @@ export default class extends Controller {
1790
2021
  if (on & FORM) this.#disconnectFeatureNow("form", () => formFeature.disconnect(this, core))
1791
2022
  if (on & BINDINGS) this.#disconnectFeatureNow("bindings", () => bindingsFeature.disconnect(this, core))
1792
2023
  if (on & COMPUTE) this.#disconnectFeatureNow("compute", () => computeFeature.disconnect(this, core))
2024
+ if (on & SORTABLE) this.#disconnectFeatureNow("sortable", () => sortableFeature.disconnect(this, core))
2025
+ if (on & PRESENCE) this.#disconnectFeatureNow("presence", () => presenceFeature.disconnect(this, core))
1793
2026
  }
1794
2027
  }
1795
2028
 
@@ -1957,7 +2190,9 @@ export default class extends Controller {
1957
2190
  this.#featureHandle ??= {
1958
2191
  emit: (name, detail, options) => this.#emit(name, detail, options),
1959
2192
  reseed: () => this.#reseed(),
1960
- proceed: (target, action, params) => this.#proceed(target, action, params),
2193
+ // Resolves with the reply's HTML once the request has applied, or with
2194
+ // nothing when it failed or was vetoed. `busy` is on(busy:)'s hint.
2195
+ proceed: (target, action, params, busy) => this.#proceed(target, action, params, 0, 0, null, busy),
1961
2196
  forgetToken: () => {
1962
2197
  this.#tokenCache = undefined
1963
2198
  },
@@ -1976,6 +2211,11 @@ export default class extends Controller {
1976
2211
  ),
1977
2212
  listnavOptions: (event) => this.#listnavOptions(event),
1978
2213
  collectFields: () => this.#collectFields(),
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,
2217
+ text: editableValue,
2218
+ served: serverText,
1979
2219
  // The confirm gate, as dispatch() runs it: the resolver inside the chain
1980
2220
  // (a synchronous throw is a cancel), never a rejection out.
1981
2221
  confirm: (message, context) =>
@@ -1988,15 +2228,17 @@ export default class extends Controller {
1988
2228
 
1989
2229
  // Re-run the connect-time seeds that read field values, in connect() order
1990
2230
  // — for a feature that changed those values after connect() ran (the draft
1991
- // restore). Each is the same re-sync its feature runs after a morph; a root
1992
- // 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).
1993
2235
  #reseed() {
1994
2236
  if (!__SPLIT__) {
1995
- if (this.#featuresOn & FORM) formFeature.scan(this, this.#featureCore())
2237
+ if (this.#featuresOn & FORM) formFeature.reseed(this)
1996
2238
  if (this.#featuresOn & BINDINGS) bindingsFeature.reseed(this)
1997
2239
  if (this.#featuresOn & COMPUTE) computeFeature.seed(this)
1998
2240
  } else {
1999
- this.#features.get("form")?.scan(this, this.#featureCore())
2241
+ this.#features.get("form")?.reseed(this)
2000
2242
  this.#features.get("bindings")?.reseed(this)
2001
2243
  this.#features.get("compute")?.seed(this)
2002
2244
  }
@@ -2168,6 +2410,8 @@ export default class extends Controller {
2168
2410
  this.#clearAllThrottles()
2169
2411
  if (this.#boundRootMorph) {
2170
2412
  this.element.removeEventListener?.("turbo:morph-element", this.#boundRootMorph)
2413
+ this.element.removeEventListener?.("focusin", this.#boundRootMorph)
2414
+ this.element.removeEventListener?.(STREAMED, this.#boundRootMorph)
2171
2415
  }
2172
2416
  // Early triggers (issue #273): a disconnected root records again.
2173
2417
  earlyState().connected.delete(this.element)
@@ -2194,9 +2438,13 @@ export default class extends Controller {
2194
2438
  // modifier params (issue #80). The client decides preventDefault behavior
2195
2439
  // from event.params — set by the Ruby on() — never by sniffing the
2196
2440
  // Stimulus descriptor.
2197
- const { action, params, debounce, throttle, confirm, confirmWhen, outside, window: windowBound, optimistic } =
2441
+ const { action, params, debounce, throttle, confirm, confirmWhen, outside, window: windowBound, optimistic, shortcut } =
2198
2442
  event.params
2199
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
2200
2448
 
2201
2449
  // The pending-state hint (issue #181): data-reactive-busy-param. During a
2202
2450
  // deploy overlap a page rendered by the PREVIOUS gem still emits the old
@@ -2243,7 +2491,11 @@ export default class extends Controller {
2243
2491
  // (which has no form-navigation default to lose) skips it and flips now, and
2244
2492
  // the failure revert snaps it back. A `change`-bound trigger is unaffected —
2245
2493
  // `change` isn't cancelable, so preventDefault was already a no-op there.
2246
- 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()
2247
2499
 
2248
2500
  // A reactive_lazy(on:) shell's trigger (issue #276) goes through the one
2249
2501
  // materialize entry point, which dedupes it against the observer, the
@@ -2382,6 +2634,10 @@ export default class extends Controller {
2382
2634
  // A detail miss (issue #346) is a complete no-op — checked before the
2383
2635
  // once record is spent, preventDefault and the confirm gate.
2384
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
2385
2641
  if (onceBindingSpent(this, event, record)) continue
2386
2642
  this.#runBinding(record.legacy ? { ...params, ops: record.ops } : record, event, trigger)
2387
2643
  }
@@ -2389,7 +2645,7 @@ export default class extends Controller {
2389
2645
 
2390
2646
  // One on_client binding (issue #95): its outside guard, preventDefault rule,
2391
2647
  // confirm gate, then its ops — every flag read from the binding itself.
2392
- #runBinding({ ops, confirm, confirmWhen, outside, window: windowBound }, event, trigger) {
2648
+ #runBinding({ ops, confirm, confirmWhen, outside, window: windowBound, shortcut }, event, trigger) {
2393
2649
  // Outside guard FIRST — identical semantics to dispatch() (issue #80): an
2394
2650
  // outside: trigger is a COMPLETE no-op for events inside this root, before
2395
2651
  // preventDefault and before any op runs.
@@ -2401,7 +2657,8 @@ export default class extends Controller {
2401
2657
  // would kill native clicks site-wide (issue #80 rationale). Runs BEFORE the
2402
2658
  // (possibly async) confirm gate below — a native default can't wait for a
2403
2659
  // pending dialog (same ordering as dispatch()).
2404
- 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()
2405
2662
 
2406
2663
  // Resolve the effective confirm message — static string, or the conditional
2407
2664
  // Hash form (issue #179) evaluated over collected fields (a promise of it
@@ -2942,6 +3199,12 @@ export default class extends Controller {
2942
3199
  // already gets the action's HTTP response. Harmless without pgbus.
2943
3200
  const connectionId = this.#connectionId()
2944
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
2945
3208
 
2946
3209
  // ONLY `fetch` itself is the network boundary — offline, DNS, a reset
2947
3210
  // connection. Everything below this inner try (reading the body,
@@ -3068,6 +3331,9 @@ export default class extends Controller {
3068
3331
  // complete a tick later. Apps needing post-morph timing listen to Turbo's
3069
3332
  // own events; this one is for "the action round trip succeeded".
3070
3333
  this.#emit("reactive:applied", { action, params: allParams, html })
3334
+ // What core.proceed resolves with: a feature can tell a reply that
3335
+ // applied from a failure (every other exit returns nothing).
3336
+ return html
3071
3337
  } catch (error) {
3072
3338
  // The server already processed this action successfully (we're past the
3073
3339
  // fetch) — a throw here is a CLIENT-side apply failure (a malformed
@@ -3290,7 +3556,9 @@ export default class extends Controller {
3290
3556
  // property — only the attribute — so read getAttribute, not el.name.
3291
3557
  const name = el.getAttribute("name")
3292
3558
  if (!name) return
3293
- const own = el.value ?? el.textContent ?? el.innerHTML ?? ""
3559
+ const own = el.getAttribute("data-reactive-editable") != null
3560
+ ? editableValue(el)
3561
+ : (el.value ?? el.textContent ?? el.innerHTML ?? "")
3294
3562
  // A `[]` name is a group here too: the editor APPENDS its value
3295
3563
  // instead of replacing the slot. Assigning a scalar posted
3296
3564
  // `{"notes[]": "<p>x</p>"}` while the draft snapshot pushed the same
@@ -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)
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlex
4
+ module Reactive
5
+ module Component
6
+ # Editable text (issue #351): a contenteditable element as a first-class,
7
+ # PLAIN-TEXT field — an inline title, a card label, a note edited where
8
+ # it is shown.
9
+ #
10
+ # h3(**reactive_editable(:title, placeholder: "Untitled")) { @card.title }
11
+ # p(**reactive_editable(:summary, multiline: true)) { @card.summary }
12
+ # h3(**mix(reactive_editable(:title), on(:rename, event: "input", debounce: 300))) { @card.title }
13
+ #
14
+ # The element is the caller's; this renders its attributes. The client
15
+ # then gives it what an input gets:
16
+ # * collect — its text, line breaks as "\n" (a single-line field folds
17
+ # them to spaces), posted under the (scope-aware) param name;
18
+ # * a morph while it is focused keeps its text and caret (attributes
19
+ # still morph); unfocused, the server's text wins;
20
+ # * paste and drop insert plain text; Enter runs on_enter: (a
21
+ # single-line field swallows it by default); Escape reverts;
22
+ # * reactive_dirty and reactive_persist treat it like an input.
23
+ #
24
+ # Plain text only — a rich editor (Lexxy, Trix) is collected by `.value`.
25
+ # The placeholder is CSS's to draw:
26
+ # [data-reactive-placeholder]:empty::before { content: attr(data-reactive-placeholder) }
27
+ module Editable
28
+ # on_enter: a Phlex::Reactive::JS chain (run on Enter, root-scoped like
29
+ # on_client) or :submit (request-submit the form the field is in).
30
+ # on_escape: :revert (restore the last server-rendered text and blur)
31
+ # or nil. spellcheck: false renders spellcheck="false".
32
+ def reactive_editable(name, multiline: false, placeholder: nil, on_enter: nil, on_escape: :revert,
33
+ spellcheck: true, **attrs)
34
+ raise ArgumentError, "reactive_editable needs a param name (e.g. reactive_editable(:title))" if name.to_s.empty?
35
+
36
+ data = { reactive_editable: multiline ? "multi" : "single" }
37
+ data[:reactive_editable_enter] = editable_enter(on_enter) unless on_enter.nil?
38
+ data[:reactive_editable_escape] = editable_escape(on_escape) unless on_escape.nil?
39
+ aria = { multiline: multiline ? "true" : "false" }
40
+ if placeholder
41
+ aria[:placeholder] = placeholder
42
+ data[:reactive_placeholder] = placeholder
43
+ end
44
+ binding = { name: scoped_field_name(name), contenteditable: "plaintext-only", role: "textbox", aria:, data: }
45
+ binding[:spellcheck] = "false" unless spellcheck
46
+ # Named by `reactive_dirty only:` → its own trackDirty descriptor, as
47
+ # reactive_field does (issue #184).
48
+ data[:action] = "input->reactive#trackDirty" if field_dirty_tracked?(name)
49
+ # An explicit scalar (name:, role:, …) REPLACES the binding's, as
50
+ # reactive_field's escape hatch does — mix would space-join two
51
+ # strings. data:/aria: hashes deep-merge.
52
+ mix(binding.reject { |key, _| attrs.key?(key) && !attrs[key].is_a?(Hash) }, attrs)
53
+ end
54
+
55
+ private
56
+
57
+ def editable_enter(on_enter)
58
+ return "submit" if on_enter == :submit
59
+
60
+ unless on_enter.is_a?(Phlex::Reactive::JS)
61
+ raise ArgumentError,
62
+ "reactive_editable on_enter: takes a Phlex::Reactive::JS chain (e.g. js.focus(\"#next\")) " \
63
+ "or :submit, got #{on_enter.inspect}"
64
+ end
65
+ raise ArgumentError, "reactive_editable on_enter: got a JS chain with no ops — a dead Enter" if on_enter.empty?
66
+
67
+ on_enter.ops.to_json
68
+ end
69
+
70
+ def editable_escape(on_escape)
71
+ return "revert" if on_escape == :revert
72
+
73
+ raise ArgumentError, "reactive_editable on_escape: takes :revert or nil, got #{on_escape.inspect}"
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end