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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f4b8ab0285b176f4fab5221687a3d3364e2311eaa4c08c091eef07763cf22a8e
4
- data.tar.gz: 9072c2d1b802438c7f39c694753308020d65e9a20e38c1ee4f730351b25de49d
3
+ metadata.gz: eeebc2783f571e3e3fcc0847318ebef76050954e0e56a99f627e8e5654467133
4
+ data.tar.gz: 974cc0de7e79babb8834bf41a10001e69a087bda3daf6a87122cae7654e128cb
5
5
  SHA512:
6
- metadata.gz: fc1c7ffa6b616154f5ce897f256fbd6d8be501948dc63295163b831b0578c55908c9187e4ac461d00dc2dd4d7481090672c654b28d30f9894a1ec65f079579d2
7
- data.tar.gz: f889193d4bc9bdb381446d93bfcf5ddf3ac7047b84250787a63f83c61844712ba1c511529792381511570740fff8f37e42c385d14fbecaad9809e52ad3064b35
6
+ metadata.gz: f95657cd231a4e711d6300fae52cbefacb42f34d5803f98073366710855a7c5906e795844800c55e8a6ee2b089224809bacb739c0c90f1a89c97c23851c2c7e7
7
+ data.tar.gz: 0b47dacc177e41d086e045ec842879358c68d4ec298615c1fcb6e23fc8cdb8b53ce5db96fa4fe2424b1fdaa381870fd29ce6f50b1ac70b7375790590e1eae793
data/CHANGELOG.md CHANGED
@@ -8,6 +8,86 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
8
8
 
9
9
  ### Added
10
10
 
11
+ - **Presence — `reactive_presence`, `presence`, `js.presence` (#358).** A
12
+ roster component declares the presence stream it shows
13
+ (`reactive_presence :viewers, state: { selected: :integer }, ttl: 45`) and
14
+ reads it with `presence`: one peer per browser tab (`seat`, `member_id`,
15
+ `metadata`, `state`), grouped by person with `members`, filtered with
16
+ `where(selected: id)`. Tabs join on SSE connect, leave on disconnect and
17
+ expire after `ttl:` seconds of silence; each change re-renders the roster for
18
+ every viewer (one coalesced morph). Each tab's declared state is set with
19
+ `js.presence(**state)` (through the generated `presence_set` action, typed,
20
+ default-deny, and exempt from `verify_authorized` unless the component defines
21
+ `authorize_presence_state!`) or `presence.set(**state)` from an action. The
22
+ client marks this tab's element `data-reactive-presence-me` and the same
23
+ person's other elements `data-reactive-presence-same-member`, and fires
24
+ `reactive:presence` with the seats that joined and left. A new `presence`
25
+ feature module (1.3 KB gzipped): the default client bundles it (+992 B), the
26
+ split client fetches it only for a page with a presence root (+100 B in the
27
+ core). Needs pgbus 0.18.1 or later; without it a presence root raises
28
+ `Phlex::Reactive::ConfigurationError` at render. Create the table with
29
+ `rails g pgbus:add_presence` (or upgrade it with
30
+ `rails g pgbus:add_presence_seats`), return each viewer's identity from
31
+ `config.streams_authorize` (with none, pgbus joins the connection to no
32
+ roster), and run an ActiveJob adapter (joins, leaves and expiries render in
33
+ `PresenceRenderJob`). A closed tab leaves within about a second.
34
+ `ttl:` must be above pgbus's `streams_heartbeat_interval` (15 s by default). `js.presence` is refused on a broadcast, like `focus`. `reactive_presence_stream_name_for(record)` on the class gives a record's stream name, for a per-record check in `streams_authorize`. Docs: "Presence".
35
+
36
+ - **`shortcut:` on `on()` and `on_client(:keydown, …)` — page-level keyboard
37
+ shortcuts (#357).** `on(:undo, shortcut: "mod+z")` binds a window keydown
38
+ where `mod` is ⌘ on Apple platforms and Ctrl elsewhere (rendered as a meta
39
+ twin and a ctrl twin; the client runs its platform's, the other is a no-op).
40
+ The shortcut does nothing while focus is in an `input`, `textarea`, `select`
41
+ or anything `contenteditable`, so native text undo keeps working
42
+ (`shortcut: { keys: "esc", in_editable: true }` opts in), and a matching
43
+ chord is `preventDefault`-ed before any `confirm:` dialog, also when pressed
44
+ before the controller connects (`phlex/reactive/early`). `shortcut:` with
45
+ `event:`, `window:` or `outside:`, a key outside Stimulus's default key map,
46
+ or an `on_client` event other than `:keydown` raises `ArgumentError` at
47
+ render. The inspector shows `keydown@window shortcut=mod+z`. `early.min.js`
48
+ grows 195 B gzipped (1,491 B); the default client 177 B.
49
+ **Compatibility:** `shortcut` is now a reserved keyword on `on(...)` and no
50
+ longer passes through as an action param. `event: "keydown.meta+z", window:
51
+ true` without `shortcut:` behaves exactly as before (never prevented, no
52
+ editable guard).
53
+
54
+ - **Sortable lists — `reactive_sortable`, `reactive_sortable_source`,
55
+ `reactive_sortable_item` (#350).** Rows drag to reorder a list with a mouse,
56
+ a finger (a 200 ms press) or the keyboard (Space picks up, arrows move,
57
+ Space drops, Escape cancels, each step announced in the list's own
58
+ translatable words), and one declared action receives `item`, `position`,
59
+ `after` and `from` through the normal action pipeline (declare the ones you
60
+ want in `params:`; undeclared ones are dropped). The row moves at once
61
+ and goes back when the action fails or its reply does not render the list.
62
+ Sortables of one `group:` exchange rows (`name:` and static `params:` for a
63
+ kanban), and a `reactive_sortable_source` list's rows are copied in, routed
64
+ to their own action with `from:`. `handle:`, `disabled:`, `busy:` and the
65
+ `data-reactive-sortable-active` / `-dragging` / `-placeholder` styling hooks
66
+ round it out. The client is a new `sortable` feature module, about 3.1 KB
67
+ gzipped: the default client bundles it, and the split client fetches it only
68
+ for a page with a sortable. `core.proceed` now resolves with the reply's
69
+ HTML on success, so a feature can tell an applied reply from a failure. The
70
+ Project board demo drags its cards. Docs: "Sortable lists".
71
+
72
+ - **`reactive_editable(:title)` — a contenteditable element as a first-class,
73
+ plain-text field (#351).** The helper renders `name` (scope-aware),
74
+ `contenteditable="plaintext-only"`, `role="textbox"`, `aria-multiline`,
75
+ `aria-placeholder` and `data-reactive-placeholder` onto your own element. Its
76
+ value is its text with line breaks as `\n` (folded to spaces when
77
+ single-line). While it is focused a `reply.morph` or broadcast morph keeps its
78
+ text and caret and still applies its attributes; unfocused, the server's text
79
+ wins, and `data-reactive-morph-value` opts out. Paste and drop insert plain
80
+ text (a handler, and a downgrade to `contenteditable="true"`, where the engine
81
+ lacks `plaintext-only`); Enter runs `on_enter:` (a `js` chain or `:submit`)
82
+ and is swallowed in a single-line field; Escape reverts to the last
83
+ server-rendered text (`on_escape: :revert`). `reactive_dirty` diffs it against
84
+ that text and `reactive_persist` drafts it with its line breaks. Bare named
85
+ `[contenteditable]` elements keep the `textContent` reading. An editable that a
86
+ stream appends into a root already on the page gets its keys and paste from
87
+ its first focus (the root loads the bindings feature then, and on the split
88
+ client the stream that brings it starts the import). Bundle: default
89
+ +977 B gzip, split core +412 B, `features/bindings` +524 B.
90
+
11
91
  - **`on_client(…, detail: { success: true })` — run ops only when the event's
12
92
  payload qualifies (#346).** A binding can require top-level `event.detail`
13
93
  keys to strictly equal JSON scalars, so `on_client("turbo:submit-end",
@@ -21,6 +101,33 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
21
101
 
22
102
  ### Fixed
23
103
 
104
+ - **A stream that renders into a connected root wires what its content needs
105
+ (#356).** A root read its feature markers only at connect and after a morph
106
+ of itself, so a `reactive_show` target, select-all/count group, filter,
107
+ nested list, dirty field or paste trigger that an `append`, `prepend`,
108
+ `update`, `before`, `after` or child `replace` brought into it stayed dead
109
+ until the root reconnected. Now, once such a stream has rendered, the root
110
+ re-runs the seeds of the features it has (a new show target takes the
111
+ field's current value) and connects any feature its new content needs, in
112
+ both clients; bindings and form also wire a binding whose gate reads true
113
+ only then. As after a morph, this covers a root with an action token or a
114
+ lazy shell; a tokenless, client-only root does not re-read. On the split
115
+ client the stream's render waits for the module the content needs, as it
116
+ already did for an incoming root, so the content lands wired; the wait is
117
+ capped at 1 s, after which Turbo renders and the root connects the module
118
+ when it arrives. A draft
119
+ restore no longer marks fields `data-reactive-dirty` on a root that never
120
+ opted into dirty tracking (one that only gates a paste trigger).
121
+ - **An editable is editable in an engine without `contenteditable="plaintext-only"`,
122
+ however it reaches the page (#355).** Such an engine reads the server's
123
+ `plaintext-only` as invalid, so the element cannot even be focused. The
124
+ bindings feature switched it to `"true"` only when it connected, or after a
125
+ morph of the root, which left an editable that a stream appended, a frame
126
+ loaded or an unfocused morph rewrote uneditable. The runtime now does the
127
+ switch with one observer, installed only in such an engine (elsewhere the
128
+ probe is all that runs). Bundle: default +157 B gzip, split core +241 B,
129
+ `features/bindings` -138 B.
130
+
24
131
  - **A `checked:`-only `reactive_show_targets` entry hides at zero boxes
25
132
  (#348).** `"#bulk-bar" => { if: { "ids[]" => { checked: 1.. } } }` was left
26
133
  visible once the owning root held no box of the group (the last ticked row
data/README.md CHANGED
@@ -118,7 +118,7 @@ instead, import `phlex/reactive/early` eagerly (the engine pins it with
118
118
  import "phlex/reactive/early"
119
119
  ```
120
120
 
121
- It gzips to under 1.3 KB and has no Stimulus import. A trigger (`on(...)` or
121
+ It gzips to under 1.5 KB and has no Stimulus import. A trigger (`on(...)` or
122
122
  `on_client(...)`) that fires before the controller connects — a click while a
123
123
  lazily loaded controller is still downloading, a custom event dispatched as soon
124
124
  as the page is interactive — is queued, its native default is stopped exactly
@@ -162,6 +162,11 @@ Good to know:
162
162
  root until it disconnects — keep its trigger element stable across replies
163
163
  (a reply that morphs the root but swaps that element leaves the hotkey
164
164
  dead until the next disconnect), or bind the hotkey without `once:`.
165
+ - A `shortcut:` (below) is the exception to "never prevented": the chord the
166
+ page claims is prevented before connect too (so `mod+s` cannot open the
167
+ browser's save dialog in the first moments), and a chord pressed inside a
168
+ field the user is typing in, or the other platform's `mod` twin, is neither
169
+ queued nor prevented.
165
170
  - Not captured, so still lost before connect: `outside:` triggers (an outside
166
171
  click before connect has nothing to close, and recording it would wake a
167
172
  dormant dropdown on every click); key filters beyond Stimulus's default key
@@ -288,14 +293,15 @@ imports one the first time a root on the page needs it:
288
293
 
289
294
  | Module | Gzipped | Default client | Split client |
290
295
  |---|---:|---|---|
291
- | `phlex/reactive/reactive_controller` | 26.1 KB | loaded with your controllers | not loaded |
292
- | `phlex/reactive/core` | 12.4 KB | not loaded (it is inside the file above) | loaded with your controllers |
296
+ | `phlex/reactive/reactive_controller` | 28.9 KB | loaded with your controllers | not loaded |
297
+ | `phlex/reactive/core` | 13.5 KB | not loaded (it is inside the file above) | loaded with your controllers |
293
298
  | `phlex/reactive/features/persist` (`reactive_persist` drafts) | 3.2 KB | inside the one file | fetched when a root declares `reactive_persist`, or a `persist_state` / `persist_clear` op runs |
294
299
  | `phlex/reactive/features/defer` (`reply.defer`, `reactive_lazy`) | 2.8 KB | inside the one file | fetched when a root is a `reactive_lazy` shell, a morph turns one into a shell, or a `reply.defer` arrives |
295
300
  | `phlex/reactive/features/form` (dirty tracking, `warn_unsaved`, the paste-trigger gate) | 1.0 KB | inside the one file | fetched when a root tracks dirty fields or holds a `paste_into` trigger |
296
301
  | `phlex/reactive/features/effects` (`reactive_effects`, `dismiss_after`) | 1.7 KB | inside the one file | fetched when a root declares an effect, or when the first stream that needs it arrives: one with an effect, or with a dismissing flash |
297
302
  | `phlex/reactive/features/bindings` (`reactive_show`, show targets, `reactive_on_complete`, `reactive_filter`, `reactive_tags`, nested rows, the conditional `confirm:`) | 5.4 KB | inside the one file | fetched when a root declares one of them, or holds a trigger with a conditional confirm |
298
303
  | `phlex/reactive/features/compute` (`reactive_compute`, `reactive_text`) | 2.1 KB | inside the one file | fetched when a root carries a compute binding |
304
+ | `phlex/reactive/features/sortable` (`reactive_sortable` drag and drop) | 3.1 KB | inside the one file | fetched when a root is or holds a sortable or source list |
299
305
  | `phlex/reactive/features/hints` (`optimistic:`, `busy:`) | 1.0 KB | inside the one file | fetched when a root holds a trigger that declares a hint |
300
306
  | `phlex/reactive/features/devtools` (the latency simulator, the zero-target warnings, the debug trace) | 1.6 KB | inside the one file | fetched with the core on a page that carries `<meta name="phlex-reactive-env" content="development">` or while a delay is stored for the tab; at connect for a root in debug mode; otherwise the first time a verbose root has something to warn about. In production with none of these, never |
301
307
  | `phlex/reactive/early` | 1.2 KB | on every page, if you import it | the same |
@@ -680,6 +686,7 @@ Use in controllers: `render turbo_stream: Counter.replace(counter)`.
680
686
  | `on_client(:click, js.toggle("#menu"))` | **Client-only** trigger: applies declared DOM ops with ZERO round trip — no token, no POST, ever. Takes the same `window:`/`once:`/`outside:` modifiers, plus `detail:` (run only when `event.detail` carries the listed values). See [Client-only ops](#client-only-ops-on_client--js--zero-round-trips). |
681
687
  | `js` | The immutable op builder behind `on_client`: `show`/`hide`/`toggle` (the `hidden` attribute, with an optional `transition:` and `expanded:`), `add_class`/`remove_class`/`toggle_class`, `set_attr`/`remove_attr`/`toggle_attr` (allowlisted names; `toggle_attr` also flips between two values), `focus`/`focus_first`, `text` (set `textContent` — XSS-safe), `dispatch`, `submit` (requestSubmit the target's own form, optionally through a `submitter:`), `paste_into` (read the clipboard into a field, gesture-gated), `check_group` (tick or untick a checkbox group, then re-sync its bindings), and `persist_state`/`persist_clear` (the `reactive_persist` draft) — chainable. |
682
688
  | `reactive_field(:param, **attrs)` | The attribute hash that binds a control to an action param (no magic `name:`) — spread onto any control: `input(**reactive_field(:value, value: @record.name))`, `select(**reactive_field(:status)) { … }`. |
689
+ | `reactive_editable(:name, multiline: false, placeholder: nil, on_enter: nil, on_escape: :revert, spellcheck: true)` | A plain-text **editable element** as a field (issue #351): `h3(**reactive_editable(:title)) { @card.title }`. Its text travels under the scope-aware `name`: with `multiline: true` its line breaks are sent as `\n`; a single-line field (the default) folds them to spaces; a morph keeps the focused element's text and caret (attributes still morph); paste is plain text; Enter runs `on_enter:` (a single-line field swallows it); Escape reverts. `reactive_dirty` and `reactive_persist` treat it like an input. See the [Editable text](https://phlex-reactive.zoolutions.llc/docs/editable-text) page. |
683
690
  | `reactive_text(:name, initial)` | Mirror a compute output (or a declared input) into a **text node** — a live preview heading, a character counter, `"Hello, {name}"` — via `textContent` (XSS-safe). The text sibling of `reactive_field`; carries no `name`, so it's never POSTed. See [Client-side computes](#client-side-computes-reactive_compute--reactive_text). |
684
691
  | `reactive_show(if:/if_any:/unless:)` | **Value-conditional visibility** (the `x-show`/`data-show` case): spread onto the element to show/hide — it toggles `hidden` from the fields' **current values**, client-only, zero round trip. One conditions language: a **Hash is an AND**, an **Array is membership**, a **Range is a threshold**, `if_any:` is OR-of-AND, `unless:` negates. `reactive_values` computes first paint; `disable:` disables a hidden section's controls. See [Value-conditional visibility](#value-conditional-visibility-reactive_show). |
685
692
  | `reactive_enable(if:/if_any:/unless:)` | The sibling of `reactive_show` that flips the element's **own `disabled`** (a button, a fieldset) instead of `hidden` — same conditions, same `reactive_values` first paint. `button(**reactive_enable(if: { "ids[]" => { checked: 1.. } }))`. See [Bulk selection](#bulk-selection-reactive_select_all-reactive_count-reactive_enable). |
@@ -934,6 +941,46 @@ div(**mix(reactive_root, on(:track, event: "scroll", window: true, throttle: 500
934
941
  These four (like `debounce:`/`confirm:`/`listnav:`) are **reserved keyword
935
942
  names** on `on(...)` — no longer usable as free action params.
936
943
 
944
+ **Page-level shortcuts — `shortcut:`.** Undo, redo, save and a command palette
945
+ bind with one keyword on `on(...)` or `on_client(:keydown, …)`:
946
+
947
+ ```ruby
948
+ div(**mix(reactive_root, on_client(:keydown, js.toggle("#palette"), shortcut: "mod+k"))) do
949
+ span(**on(:undo, shortcut: "mod+z")) # one reactive action per element
950
+ span(**on(:redo, shortcut: "mod+shift+z"))
951
+ textarea(name: "notes")
952
+ end
953
+ ```
954
+
955
+ A shortcut is a window `keydown` binding with three rules a plain
956
+ `event: "keydown.meta+z", window: true` does not have:
957
+
958
+ - **`mod` is ⌘ on Apple platforms and Ctrl elsewhere.** The helper renders a
959
+ meta twin and a ctrl twin; the client runs the one for its platform (from
960
+ `navigator.userAgentData.platform`, else `navigator.platform`), and the other
961
+ is a complete no-op, so Ctrl+Z on a Mac stays the browser's. A chord without
962
+ `mod` (`"esc"`, `"alt+n"`) renders one descriptor.
963
+ - **Typing is left alone.** With focus in an `input`, `textarea`, `select` or
964
+ anything `contenteditable` (including `reactive_editable`), the shortcut does
965
+ nothing and the browser's own handling (text undo) runs. Opt in with the Hash
966
+ form: `shortcut: { keys: "esc", in_editable: true }`.
967
+ - **The chord is prevented.** A matching shortcut calls `preventDefault`
968
+ synchronously, before any `confirm:` dialog, so `mod+s` does not also open
969
+ the save dialog. (Other window bindings are still never prevented.)
970
+
971
+ `shortcut:` decides the event and the window binding: passing it with
972
+ `event:`, `window:` or `outside:` raises `ArgumentError`, as does a key outside
973
+ Stimulus's default key map (a letter, a digit, `enter`, `tab`, `esc`, `space`,
974
+ the arrows, `home`, `end`, `page_up`, `page_down`; bind a custom `keyMappings`
975
+ key with `event:` and `window:`). On `on_client` the event must be `:keydown`.
976
+ `shortcut` is now a **reserved keyword** on `on(...)`.
977
+
978
+ `once: true` on a shortcut unbinds the Stimulus descriptor after its first
979
+ firing, and a press inside a field counts: the guard leaves the chord to the
980
+ browser, but the `:once` listener is already spent. Keep `once:` off a
981
+ shortcut that can be pressed while the user types, or pair it with
982
+ `in_editable: true`.
983
+
937
984
  ### Optimistic visual hints (`optimistic:`)
938
985
 
939
986
  Every reactive action waits a full round trip for its visual change — and it's
@@ -1139,8 +1186,9 @@ string is legacy and ignored), and `turbo:before-visit` **does not fire on
1139
1186
  restoration visits** (Back/Forward) — a documented Turbo gap, not a
1140
1187
  phlex-reactive one.
1141
1188
 
1142
- **Out of scope (v1):** rich-text / `contenteditable` editors have no `default*`
1143
- baseline and are **not** tracked. Combining `reactive_field` with your own
1189
+ **Out of scope (v1):** rich-text editors (Lexxy, Trix) and bare
1190
+ `contenteditable` elements have no `default*` baseline and are **not** tracked;
1191
+ a `reactive_editable` is (against the text of its last server render, #351). Combining `reactive_field` with your own
1144
1192
  `data:`/`on(...)` still needs `mix(...)` (a bare merge clobbers the `data-action`
1145
1193
  the descriptor rides on — the same rule as everywhere else).
1146
1194
 
@@ -1402,6 +1450,11 @@ free as an ordinary action-param name (`on(:switch, key: "pgbus")` still passes
1402
1450
  > trigger to its own element (the field saves on Enter; a Cancel button — or the
1403
1451
  > field's own blur — handles Escape), as above.
1404
1452
 
1453
+ A page-level chord (undo on ⌘Z/Ctrl+Z, save on `mod+s`) is a `shortcut:`
1454
+ rather than `event:` with `window: true`: it resolves `mod` per platform,
1455
+ leaves fields the user is typing in alone and prevents the chord (see
1456
+ *Page-level shortcuts* above).
1457
+
1405
1458
  ### Value-conditional visibility (`reactive_show`)
1406
1459
 
1407
1460
  `on_client` covers the *unconditional* client-only interactions; the last gap
@@ -1635,6 +1688,206 @@ button(id: "bulk-archive", **reactive_group_target_attrs("ids[]", :enable, 1..))
1635
1688
  `(group, :enable, condition)` is `{ disabled: }` (or `{}` when
1636
1689
  `reactive_values` does not cover the group, and the client seeds it).
1637
1690
 
1691
+ ### Sortable lists (`reactive_sortable`)
1692
+
1693
+ Drag rows to reorder a list — with a mouse, a finger or the keyboard — and one
1694
+ declared action receives the row and where it landed. No Stimulus controller,
1695
+ no endpoint of your own: the drop goes through the action pipeline (signed
1696
+ token, request queue, `busy:`, the reply).
1697
+
1698
+ ```ruby
1699
+ class TaskList < Phlex::HTML
1700
+ include Phlex::Reactive::Component
1701
+
1702
+ reactive_record :list
1703
+ action :move, params: { item: :integer, position: :integer }
1704
+
1705
+ def move(item:, position:)
1706
+ authorize! @list, :update?
1707
+ # insert_at is acts_as_list's; any reorder that honours `position` works.
1708
+ @list.tasks.find(item).insert_at(position + 1)
1709
+ reply.morph
1710
+ end
1711
+
1712
+ def view_template
1713
+ ul(**mix(reactive_root, reactive_sortable(:move, handle: "[data-drag-handle]"))) do
1714
+ @list.tasks.each do |task|
1715
+ li(id: dom_id(task), **reactive_sortable_item(task.id)) do
1716
+ span(data: { drag_handle: true }) { "⠿" }
1717
+ plain task.title
1718
+ end
1719
+ end
1720
+ end
1721
+ end
1722
+ end
1723
+ ```
1724
+
1725
+ On drop the action gets `item` (the row's key), `position` (its 0-based index
1726
+ after the drop), `after` (the key of the row it now follows, `nil` when first)
1727
+ and `from` (`"self"` for a reorder) — declare the ones you want. The row moves
1728
+ at once and goes back if the action fails or its reply does not render the
1729
+ list; a reply that morphs the list puts the rows in the server's order.
1730
+
1731
+ - **Drag in from another list**: `reactive_sortable_source(group:, name:)` rows
1732
+ are copied into any sortable of the same group (the source needs no token —
1733
+ it works on a `ClientBindings` root). Route a drag-in to its own action with
1734
+ `from: { templates: :insert }`. Source rows are pointer-only: pass
1735
+ `tabindex: -1` (via `mix`) so they stay out of the tab order.
1736
+ - **Between lists** (a kanban): sortables of one `group:` exchange rows; `name:`
1737
+ is what a row dragged out reports as `from`, and static `params:` (e.g.
1738
+ `{ lane: "doing" }`) tell the action which list it landed in.
1739
+ - **Keyboard**: a row is focusable; Space/Enter picks it up, the arrows move it
1740
+ (← → into the previous/next list of the group), Space/Enter drops it,
1741
+ Escape puts it back. Each step is announced through a live region; translate
1742
+ the words with `messages:` (`{position}`, `{count}`).
1743
+ - **Styling**: `data-reactive-sortable-active` (the list), `-dragging` (the
1744
+ row) and `-placeholder` (the gap) are yours to style.
1745
+ - **Guards**: `disabled: true` on the list or a row, and a request in flight on
1746
+ the root, refuse a drag. A drag starts after a 4 px move, or a 200 ms touch
1747
+ press, so clicks, taps and scrolling still work.
1748
+
1749
+ Full guide: [Sortable lists](https://phlex-reactive.zoolutions.llc/docs/sortable-lists).
1750
+
1751
+ ### Presence (`reactive_presence`)
1752
+
1753
+ Who is viewing a stream, one entry per browser tab, each tab with a small
1754
+ declared state (the item it has selected), on a roster component that
1755
+ re-renders itself in every viewer's tab when a tab joins, leaves, goes silent
1756
+ or changes its state. No controller code, no Stimulus controller.
1757
+
1758
+ ```ruby
1759
+ class Boards::Viewers < Phlex::HTML
1760
+ include Phlex::Reactive::Component
1761
+
1762
+ reactive_record :board
1763
+ reactive_presence :viewers, state: { selected: :integer }, ttl: 45
1764
+
1765
+ def initialize(board:) = @board = board
1766
+
1767
+ def view_template
1768
+ div(**reactive_root) do
1769
+ presence.members.each do |member| # one per person
1770
+ span(**reactive_presence_member(member)) { member.metadata["name"] }
1771
+ end
1772
+ @board.cards.each do |card|
1773
+ li(**on_client(:click, js.presence(selected: card.id))) do
1774
+ plain card.title
1775
+ presence.where(selected: card.id).each do |peer| # who is on this card
1776
+ span(**reactive_presence_peer(peer), class: "dot")
1777
+ end
1778
+ end
1779
+ end
1780
+ end
1781
+ end
1782
+ end
1783
+ ```
1784
+
1785
+ - **The stream** is `[record, :presence, name]` (a record-less roster passes
1786
+ `key: ["lobby"]`), named `"<16-char digest of the record (or the key: value)>:presence:<name>"`,
1787
+ so any model name and id fit pgbus's stream-name budget (41 chars with the
1788
+ default queue prefix). That leaves 15 characters for the presence name; a
1789
+ longer one raises `Phlex::Reactive::ConfigurationError` at render. The root
1790
+ subscribes to it by itself: the client mints a
1791
+ `<pgbus-stream-source>` from the root's signed src, so the page must load the
1792
+ pgbus client element (`import "pgbus/stream_source_element"`, which any
1793
+ `pgbus_stream_from` on the page already does).
1794
+ - **`presence`** is the roster as this render reads it: `peers` (one per tab:
1795
+ `seat`, `member_id`, `metadata`, `state`, `joined_at`), `members` (grouped by
1796
+ person: `id`, `metadata`, `seats`, `state`), `where(**state)`, `count`,
1797
+ `empty?`, and in an action `me` and `set(**state)`.
1798
+ - **State** is declared like action params; undeclared keys are dropped.
1799
+ `js.presence(selected: 7)` sends it from the browser through the generated
1800
+ `presence_set` action; `presence.set(selected: 7)` writes it from an action of
1801
+ your own. Either way only this tab's row changes and the other viewers get a
1802
+ morph. The generated action replies `reply.morph` for the actor; an action of
1803
+ your own must return `reply.morph` itself.
1804
+ - **A state-backed roster** (`reactive_state`): its state is stored on each
1805
+ seat row in `pgbus_presence_members` (in the note the re-render reads), and
1806
+ the roster renders as one piece of markup under one id for every viewer. So
1807
+ its `reactive_state` must be the same for every viewer; per-viewer state
1808
+ belongs in presence state (`state:`).
1809
+ - **Authorization**: `presence_set` skips `verify_authorized`. The signed token
1810
+ proves the actor rendered this root, and the write is typed, ephemeral, and
1811
+ lands only on the actor's own row, keyed by its SSE connection id, which
1812
+ only that tab knows. To check the value (the card must belong to the board),
1813
+ define `authorize_presence_state!(**state)` and raise; it runs first. That hook
1814
+ guards only the generated `presence_set`: an action of your own that calls
1815
+ `presence.set` authorizes its state itself. The client also sends `presence_set` with no state on every connect (the
1816
+ announce): that writes no value, so the hook is not called for it.
1817
+ - **`presence.me`** is the acting tab, and during an action it stays the actor
1818
+ while the roster renders for the other viewers too. Never branch the shared
1819
+ output on it; the client's `-me` marks are per tab. (Likewise
1820
+ `reactive_connection_id` is the first `<pgbus-stream-source>` on the page, so
1821
+ one loaded later, ahead of it in the DOM, can change it; presence writes use
1822
+ their own connection and are unaffected.)
1823
+ - **"Me"**: the roster renders once for everyone, so the client marks this
1824
+ tab's element `data-reactive-presence-me` and the same person's other
1825
+ elements `data-reactive-presence-same-member` after every morph. Style them.
1826
+ Both need this tab's `reactive_presence_peer` element on the roster: a roster
1827
+ of members only has none, so nothing is marked.
1828
+ - **Events**: `reactive:presence` fires on the root with
1829
+ `{ joined: [seat…], left: [seat…], me: seat }` when a morph changes who is
1830
+ there; a replayed frame that changes nothing fires nothing. The root names
1831
+ the seats its render saw (`data-reactive-presence-roster`), so this works for
1832
+ a roster that renders people, not tabs.
1833
+ - **Leaving**: a closed tab leaves within about a second (pgbus watches each
1834
+ SSE socket for the browser closing it). A tab that crashes or loses its
1835
+ network, so nothing closes the socket cleanly, leaves on the next failed
1836
+ heartbeat write (`streams_heartbeat_interval`, 15 s by default) or expires
1837
+ after `ttl:` seconds. A reconnect keeps the tab's seat id and re-sends its
1838
+ state, but pgbus drops the row on the disconnect, so unless the reconnect
1839
+ lands within one coalesce window (150 ms) peers see that seat leave and
1840
+ join again (`reactive:presence` reports both). A row that expires while its
1841
+ socket stays open (the tab missed its beats) is re-joined by the heartbeat
1842
+ without its state, until the tab's next `js.presence`. `ttl:` must be above the
1843
+ transport's heartbeat interval, or a live tab would expire between two
1844
+ beats: `reactive_presence` raises otherwise.
1845
+ - **Transport**: needs pgbus 0.18.1 or later (presence keyed by SSE
1846
+ connection, `pgbus:connected` only after the tab's seat joined,
1847
+ `config.streams_authorize`). Create the table with
1848
+ `rails g pgbus:add_presence`, or upgrade an older one with
1849
+ `rails g pgbus:add_presence_seats`. `ttl:` (45 by default) is written into
1850
+ pgbus's `streams_presence_ttl` under the stream's pattern. Action Cable has
1851
+ no connection identity: rendering a presence root without it raises
1852
+ `Phlex::Reactive::ConfigurationError`. Every other component is unaffected.
1853
+ - **Identity**: pgbus takes each tab's person from what
1854
+ `config.streams_authorize` returns for the stream. `nil` or `false` answers
1855
+ 403; `true` authorizes with no identity, and pgbus joins such a connection
1856
+ to no roster (the tab then warns in the console that it never appeared).
1857
+ Per-record authorization already happens at render: the signed src is
1858
+ minted only into a roster root your app chose to render for this user.
1859
+ `streams_authorize` is the revocation layer (the signed name proves pgbus
1860
+ minted it, not that this viewer may still read it: a removed member keeps a
1861
+ working name until the secret rotates). For a strict per-record check at
1862
+ connect time, compare `stream_name` with the presence key of each record the
1863
+ user may view (or give a record-less roster a `key:` with a prefix you can
1864
+ parse):
1865
+
1866
+ ```ruby
1867
+ # config/initializers/pgbus.rb
1868
+ Pgbus.configure do |config|
1869
+ config.streams_authorize = ->(env, stream_name) {
1870
+ user = env["warden"]&.user
1871
+ next unless user&.active?
1872
+ next unless user.may_stream?(stream_name) # keep your app's own check for every stream
1873
+
1874
+ if stream_name.end_with?(":presence:viewers")
1875
+ next unless user.boards.any? { Boards::Viewers.reactive_presence_stream_name_for(it) == stream_name }
1876
+ end
1877
+ { id: user.id, metadata: { name: user.name } }
1878
+ }
1879
+ end
1880
+ ```
1881
+
1882
+ A presence stream's name is a digest (`"<16 hex>:presence:<name>"`), so the
1883
+ record cannot be read back from it: `reactive_presence_stream_name_for`
1884
+ computes it from the record. A User works too (pgbus reads its `#id`); shape
1885
+ the member from it with
1886
+ `config.streams_presence_member = ->(user) { { id: user.id, metadata: { name: user.name } } }`.
1887
+ - **Jobs**: a join, a leave and an expiry re-render the roster in
1888
+ `Phlex::Reactive::PresenceRenderJob` (on `defer_job_queue`), so the app needs
1889
+ an ActiveJob adapter that runs jobs. A state change renders in the action.
1890
+
1638
1891
  ### Client-only drafts (`reactive_persist`)
1639
1892
 
1640
1893
  "Don't make me start over": a public application form, a multi-step wizard,
@@ -347,24 +347,34 @@ module Phlex
347
347
  # Phlex::Reactive::Response survives to response_streams.
348
348
  def run_action(component, action_def, coerced)
349
349
  Phlex::Reactive.with_connection_id(request.headers["X-Pgbus-Connection"]) do
350
- with_around_actions(component, action_def, coerced) do
351
- transaction_wrapper do
352
- # verify_authorized (issue #168): open the tracking window, run the
353
- # action, then verify INSIDE the transaction so an unverified action
354
- # rolls its mutation back (fail-closed). with_tracking is a bare
355
- # yield's-worth of overhead; verify! is a no-op when the feature is
356
- # off / the action is skipped / something marked. The interceptor
357
- # (create_action's instrument!) marks the window on a real
358
- # authorization call.
359
- Phlex::Reactive::Authorization.with_tracking do
360
- result = if coerced.any?
361
- component.public_send(action_def.name, **coerced)
362
- else
363
- component.public_send(action_def.name)
364
- end
365
- Phlex::Reactive::Authorization.verify!(component.class, action_def)
366
- result
367
- end
350
+ # Issue #358: a presence root also sends its presence connection, the
351
+ # row a presence write lands on.
352
+ Phlex::Reactive::Presence.with_connection_id(request.headers[Phlex::Reactive::Presence::HEADER]) do
353
+ run_action_wrapped(component, action_def, coerced)
354
+ end
355
+ end
356
+ end
357
+
358
+ # The action inside the around_action stack, the transaction and the
359
+ # verify_authorized window (see run_action).
360
+ def run_action_wrapped(component, action_def, coerced)
361
+ with_around_actions(component, action_def, coerced) do
362
+ transaction_wrapper do
363
+ # verify_authorized (issue #168): open the tracking window, run the
364
+ # action, then verify INSIDE the transaction so an unverified action
365
+ # rolls its mutation back (fail-closed). with_tracking is a bare
366
+ # yield's-worth of overhead; verify! is a no-op when the feature is
367
+ # off / the action is skipped / something marked. The interceptor
368
+ # (create_action's instrument!) marks the window on a real
369
+ # authorization call.
370
+ Phlex::Reactive::Authorization.with_tracking do
371
+ result = if coerced.any?
372
+ component.public_send(action_def.name, **coerced)
373
+ else
374
+ component.public_send(action_def.name)
375
+ end
376
+ Phlex::Reactive::Authorization.verify!(component.class, action_def)
377
+ result
368
378
  end
369
379
  end
370
380
  end
@@ -26,6 +26,8 @@ registerReactiveFeatureLoader("defer", () => import("phlex/reactive/features/def
26
26
  registerReactiveFeatureLoader("form", () => import("phlex/reactive/features/form"))
27
27
  registerReactiveFeatureLoader("bindings", () => import("phlex/reactive/features/bindings"))
28
28
  registerReactiveFeatureLoader("compute", () => import("phlex/reactive/features/compute"), waitingForEdit)
29
+ registerReactiveFeatureLoader("sortable", () => import("phlex/reactive/features/sortable"))
30
+ registerReactiveFeatureLoader("presence", () => import("phlex/reactive/features/presence"))
29
31
  registerReactiveFeatureLoader("effects", () => import("phlex/reactive/features/effects"))
30
32
  registerReactiveFeatureLoader("hints", () => import("phlex/reactive/features/hints"))
31
33
  registerReactiveFeatureLoader("devtools", () => import("phlex/reactive/features/devtools"))
@@ -144,7 +146,10 @@ onReactiveStreamWithoutEffects((event, loadedEffects) => {
144
146
  })
145
147
 
146
148
  // The reactive roots a stream is about to put on the page: the roots in its
147
- // template (the template's own root element included).
149
+ // template (the template's own root element included), and the content
150
+ // itself — read as part of the root already on the page it renders into,
151
+ // which re-reads its markers once the render has run (issue #356; an editable
152
+ // it brings, #351). Held for the module, the new content lands wired.
148
153
  function incomingRoots(streamEl) {
149
154
  const content = streamEl?.querySelector?.("template")?.content
150
155
  if (!content) return []
@@ -152,6 +157,7 @@ function incomingRoots(streamEl) {
152
157
  for (const child of content.children ?? []) {
153
158
  if (child.matches?.('[data-controller~="reactive"]') && !roots.includes(child)) roots.unshift(child)
154
159
  }
160
+ roots.push(content)
155
161
  return roots
156
162
  }
157
163