phlex-reactive 0.14.4 → 0.15.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +95 -0
- data/README.md +211 -4
- data/app/controllers/phlex/reactive/actions_controller.rb +28 -18
- data/app/javascript/phlex/reactive/core.js +5 -5
- data/app/javascript/phlex/reactive/core.min.js +3 -3
- data/app/javascript/phlex/reactive/core.min.js.map +4 -4
- data/app/javascript/phlex/reactive/early.js +36 -9
- data/app/javascript/phlex/reactive/early.min.js +2 -2
- data/app/javascript/phlex/reactive/early.min.js.map +3 -3
- data/app/javascript/phlex/reactive/features/bindings.js +125 -140
- data/app/javascript/phlex/reactive/features/bindings.min.js +2 -2
- data/app/javascript/phlex/reactive/features/bindings.min.js.map +3 -3
- data/app/javascript/phlex/reactive/features/form.js +63 -41
- data/app/javascript/phlex/reactive/features/form.min.js +2 -2
- data/app/javascript/phlex/reactive/features/form.min.js.map +3 -3
- data/app/javascript/phlex/reactive/features/presence.js +205 -0
- data/app/javascript/phlex/reactive/features/presence.min.js +4 -0
- data/app/javascript/phlex/reactive/features/presence.min.js.map +10 -0
- data/app/javascript/phlex/reactive/inspect.js +5 -1
- data/app/javascript/phlex/reactive/inspect.min.js +2 -2
- data/app/javascript/phlex/reactive/inspect.min.js.map +3 -3
- data/app/javascript/phlex/reactive/reactive_controller.js +3 -1
- data/app/javascript/phlex/reactive/reactive_controller.min.js +3 -3
- data/app/javascript/phlex/reactive/reactive_controller.min.js.map +8 -7
- data/app/javascript/phlex/reactive/runtime.js +310 -14
- data/lib/phlex/reactive/component/dsl.rb +5 -0
- data/lib/phlex/reactive/component/helpers.rb +94 -8
- data/lib/phlex/reactive/component/presence_declaration.rb +118 -0
- data/lib/phlex/reactive/component/registry.rb +3 -1
- data/lib/phlex/reactive/component.rb +1 -0
- data/lib/phlex/reactive/defer.rb +5 -0
- data/lib/phlex/reactive/engine.rb +9 -4
- data/lib/phlex/reactive/js.rb +44 -0
- data/lib/phlex/reactive/presence/component_methods.rb +130 -0
- data/lib/phlex/reactive/presence/roster.rb +90 -0
- data/lib/phlex/reactive/presence/transport.rb +154 -0
- data/lib/phlex/reactive/presence.rb +162 -0
- data/lib/phlex/reactive/presence_render_job.rb +26 -0
- data/lib/phlex/reactive/shortcut.rb +118 -0
- data/lib/phlex/reactive/streamable.rb +12 -3
- data/lib/phlex/reactive/version.rb +1 -1
- data/lib/phlex/reactive.rb +10 -0
- metadata +11 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3b268544642595b7c8912db0dcbee175ed751fb73290d1bfcfec0c4c9627583b
|
|
4
|
+
data.tar.gz: 459a72f7b048c8db6010213e252d09c552eefb3296eb838bc36973d9ded2a48d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: dfcf217a1d1dd7fae8a79c5a4d146409a5528a13a0a7f6cadfa757ba2728c740e90fb3e95ed6957740b4219c9ea7ac5ad0bf3c5c719442671662a1f803b6a6e8
|
|
7
|
+
data.tar.gz: 6636ad3074cd8cba009c507730d901c38afc343b228e0780c4cf50b7404a6b0f1c8f61e75c3b5edeeb5f0fa1a56f5966ea15ed53a8db5ee4987fe320fde85dc5
|
data/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,74 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
8
8
|
|
|
9
9
|
### Added
|
|
10
10
|
|
|
11
|
+
- **Modal dialogs — `js.show_modal` / `js.close_dialog` and
|
|
12
|
+
`data-reactive-modal` (#365).** `js.show_modal("#picker")` calls
|
|
13
|
+
`HTMLDialogElement#showModal()`, so an `on_client` dialog gets the browser's
|
|
14
|
+
top layer, focus trap, `::backdrop`, Escape and focus restored to the opener;
|
|
15
|
+
`js.close_dialog("#picker")` calls `close()`. A dialog already open
|
|
16
|
+
non-modally is reopened as a modal; a non-dialog target is skipped. A
|
|
17
|
+
`dialog(open: true, data: { reactive_modal: true })` the server renders (a
|
|
18
|
+
full page, a reply, a broadcast) is made modal at connect, after a morph and
|
|
19
|
+
after a stream that brings a dialog; a morph that renders it without `open`
|
|
20
|
+
closes it with `close()`, so the page behind is interactive again. Both ops
|
|
21
|
+
are actor-only, refused by `broadcast_to(js:)` like `focus`. Client: +499 B
|
|
22
|
+
gzipped in the default bundle, +425 B in the split core. Docs: "Modal
|
|
23
|
+
dialogs" on the client-ops example.
|
|
24
|
+
|
|
25
|
+
- **`shortcut: "delete"` / `"backspace"` (#366).** `on(:remove_selected,
|
|
26
|
+
shortcut: "delete")`, `"backspace"` and `"mod+backspace"` are accepted, on
|
|
27
|
+
`on(...)` and `on_client(:keydown, ...)`. Stimulus's default key map lacks
|
|
28
|
+
these keys (a `keydown.delete` filter throws "unknown key filter" at
|
|
29
|
+
keypress), so such a shortcut renders a bare `keydown@window` descriptor and
|
|
30
|
+
the client matches the chord itself, with the usual rules: prevented, this
|
|
31
|
+
platform's `mod`, skipped while typing unless `in_editable: true`.
|
|
32
|
+
`phlex/reactive/early` queues and prevents the chord before connect and leaves
|
|
33
|
+
every other key alone (+134 B gzipped). `once:` raises on these keys. Existing
|
|
34
|
+
chords render unchanged.
|
|
35
|
+
|
|
36
|
+
- **Presence — `reactive_presence`, `presence`, `js.presence` (#358).** A
|
|
37
|
+
roster component declares the presence stream it shows
|
|
38
|
+
(`reactive_presence :viewers, state: { selected: :integer }, ttl: 45`) and
|
|
39
|
+
reads it with `presence`: one peer per browser tab (`seat`, `member_id`,
|
|
40
|
+
`metadata`, `state`), grouped by person with `members`, filtered with
|
|
41
|
+
`where(selected: id)`. Tabs join on SSE connect, leave on disconnect and
|
|
42
|
+
expire after `ttl:` seconds of silence; each change re-renders the roster for
|
|
43
|
+
every viewer (one coalesced morph). Each tab's declared state is set with
|
|
44
|
+
`js.presence(**state)` (through the generated `presence_set` action, typed,
|
|
45
|
+
default-deny, and exempt from `verify_authorized` unless the component defines
|
|
46
|
+
`authorize_presence_state!`) or `presence.set(**state)` from an action. The
|
|
47
|
+
client marks this tab's element `data-reactive-presence-me` and the same
|
|
48
|
+
person's other elements `data-reactive-presence-same-member`, and fires
|
|
49
|
+
`reactive:presence` with the seats that joined and left. A new `presence`
|
|
50
|
+
feature module (1.3 KB gzipped): the default client bundles it (+992 B), the
|
|
51
|
+
split client fetches it only for a page with a presence root (+100 B in the
|
|
52
|
+
core). Needs pgbus 0.18.1 or later; without it a presence root raises
|
|
53
|
+
`Phlex::Reactive::ConfigurationError` at render. Create the table with
|
|
54
|
+
`rails g pgbus:add_presence` (or upgrade it with
|
|
55
|
+
`rails g pgbus:add_presence_seats`), return each viewer's identity from
|
|
56
|
+
`config.streams_authorize` (with none, pgbus joins the connection to no
|
|
57
|
+
roster), and run an ActiveJob adapter (joins, leaves and expiries render in
|
|
58
|
+
`PresenceRenderJob`). A closed tab leaves within about a second.
|
|
59
|
+
`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".
|
|
60
|
+
|
|
61
|
+
- **`shortcut:` on `on()` and `on_client(:keydown, …)` — page-level keyboard
|
|
62
|
+
shortcuts (#357).** `on(:undo, shortcut: "mod+z")` binds a window keydown
|
|
63
|
+
where `mod` is ⌘ on Apple platforms and Ctrl elsewhere (rendered as a meta
|
|
64
|
+
twin and a ctrl twin; the client runs its platform's, the other is a no-op).
|
|
65
|
+
The shortcut does nothing while focus is in an `input`, `textarea`, `select`
|
|
66
|
+
or anything `contenteditable`, so native text undo keeps working
|
|
67
|
+
(`shortcut: { keys: "esc", in_editable: true }` opts in), and a matching
|
|
68
|
+
chord is `preventDefault`-ed before any `confirm:` dialog, also when pressed
|
|
69
|
+
before the controller connects (`phlex/reactive/early`). `shortcut:` with
|
|
70
|
+
`event:`, `window:` or `outside:`, a key outside Stimulus's default key map,
|
|
71
|
+
or an `on_client` event other than `:keydown` raises `ArgumentError` at
|
|
72
|
+
render. The inspector shows `keydown@window shortcut=mod+z`. `early.min.js`
|
|
73
|
+
grows 195 B gzipped (1,491 B); the default client 177 B.
|
|
74
|
+
**Compatibility:** `shortcut` is now a reserved keyword on `on(...)` and no
|
|
75
|
+
longer passes through as an action param. `event: "keydown.meta+z", window:
|
|
76
|
+
true` without `shortcut:` behaves exactly as before (never prevented, no
|
|
77
|
+
editable guard).
|
|
78
|
+
|
|
11
79
|
- **Sortable lists — `reactive_sortable`, `reactive_sortable_source`,
|
|
12
80
|
`reactive_sortable_item` (#350).** Rows drag to reorder a list with a mouse,
|
|
13
81
|
a finger (a 200 ms press) or the keyboard (Space picks up, arrows move,
|
|
@@ -58,6 +126,33 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
58
126
|
|
|
59
127
|
### Fixed
|
|
60
128
|
|
|
129
|
+
- **A stream that renders into a connected root wires what its content needs
|
|
130
|
+
(#356).** A root read its feature markers only at connect and after a morph
|
|
131
|
+
of itself, so a `reactive_show` target, select-all/count group, filter,
|
|
132
|
+
nested list, dirty field or paste trigger that an `append`, `prepend`,
|
|
133
|
+
`update`, `before`, `after` or child `replace` brought into it stayed dead
|
|
134
|
+
until the root reconnected. Now, once such a stream has rendered, the root
|
|
135
|
+
re-runs the seeds of the features it has (a new show target takes the
|
|
136
|
+
field's current value) and connects any feature its new content needs, in
|
|
137
|
+
both clients; bindings and form also wire a binding whose gate reads true
|
|
138
|
+
only then. As after a morph, this covers a root with an action token or a
|
|
139
|
+
lazy shell; a tokenless, client-only root does not re-read. On the split
|
|
140
|
+
client the stream's render waits for the module the content needs, as it
|
|
141
|
+
already did for an incoming root, so the content lands wired; the wait is
|
|
142
|
+
capped at 1 s, after which Turbo renders and the root connects the module
|
|
143
|
+
when it arrives. A draft
|
|
144
|
+
restore no longer marks fields `data-reactive-dirty` on a root that never
|
|
145
|
+
opted into dirty tracking (one that only gates a paste trigger).
|
|
146
|
+
- **An editable is editable in an engine without `contenteditable="plaintext-only"`,
|
|
147
|
+
however it reaches the page (#355).** Such an engine reads the server's
|
|
148
|
+
`plaintext-only` as invalid, so the element cannot even be focused. The
|
|
149
|
+
bindings feature switched it to `"true"` only when it connected, or after a
|
|
150
|
+
morph of the root, which left an editable that a stream appended, a frame
|
|
151
|
+
loaded or an unfocused morph rewrote uneditable. The runtime now does the
|
|
152
|
+
switch with one observer, installed only in such an engine (elsewhere the
|
|
153
|
+
probe is all that runs). Bundle: default +157 B gzip, split core +241 B,
|
|
154
|
+
`features/bindings` -138 B.
|
|
155
|
+
|
|
61
156
|
- **A `checked:`-only `reactive_show_targets` entry hides at zero boxes
|
|
62
157
|
(#348).** `"#bulk-bar" => { if: { "ids[]" => { checked: 1.. } } }` was left
|
|
63
158
|
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.
|
|
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
|
|
@@ -679,7 +684,7 @@ Use in controllers: `render turbo_stream: Counter.replace(counter)`.
|
|
|
679
684
|
| `busy_on(:save)` | Mark any element so it carries `data-reactive-busy` **only while `save` is in flight** — a spinner styled with pure CSS, zero Ruby. See [Loading states](#declarative-loading-states-loading--disable_with). |
|
|
680
685
|
| `on(:action, once: true)` | Fire at most once, then unbind (Stimulus's native `:once`). |
|
|
681
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). |
|
|
682
|
-
| `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. |
|
|
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`, `show_modal`/`close_dialog` (a real modal `<dialog>`), `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. |
|
|
683
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)) { … }`. |
|
|
684
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. |
|
|
685
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). |
|
|
@@ -936,6 +941,54 @@ div(**mix(reactive_root, on(:track, event: "scroll", window: true, throttle: 500
|
|
|
936
941
|
These four (like `debounce:`/`confirm:`/`listnav:`) are **reserved keyword
|
|
937
942
|
names** on `on(...)` — no longer usable as free action params.
|
|
938
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:`) other than `delete` and `backspace`. On
|
|
976
|
+
`on_client` the event must be bare `:keydown` or `"keydown"`.
|
|
977
|
+
|
|
978
|
+
`delete` and `backspace` are matched by the library rather than Stimulus (its
|
|
979
|
+
default key map lacks them, and a `keydown.delete` filter makes Stimulus throw
|
|
980
|
+
at keypress): the shortcut binds a bare `keydown@window` and the client matches
|
|
981
|
+
the chord, with every rule above — `on(:remove_selected, shortcut: "delete")`
|
|
982
|
+
never fires while typing in a field. They cannot take `once:` (the first key
|
|
983
|
+
pressed of any kind would spend the bare binding).
|
|
984
|
+
`shortcut` is now a **reserved keyword** on `on(...)`.
|
|
985
|
+
|
|
986
|
+
`once: true` on a shortcut unbinds the Stimulus descriptor after its first
|
|
987
|
+
firing, and a press inside a field counts: the guard leaves the chord to the
|
|
988
|
+
browser, but the `:once` listener is already spent. Keep `once:` off a
|
|
989
|
+
shortcut that can be pressed while the user types, or pair it with
|
|
990
|
+
`in_editable: true`.
|
|
991
|
+
|
|
939
992
|
### Optimistic visual hints (`optimistic:`)
|
|
940
993
|
|
|
941
994
|
Every reactive action waits a full round trip for its visual change — and it's
|
|
@@ -1212,6 +1265,15 @@ button(**on_client(:click, js
|
|
|
1212
1265
|
- **`focus(to)`** focuses the first match; **`focus_first(to)`** focuses the first
|
|
1213
1266
|
focusable descendant of the match (e.g. the first menuitem inside an opened
|
|
1214
1267
|
menu).
|
|
1268
|
+
- **`show_modal(to)`** / **`close_dialog(to)`** (#365) call
|
|
1269
|
+
`HTMLDialogElement#showModal()` / `#close()`: the top layer, a focus trap,
|
|
1270
|
+
`::backdrop`, Escape to close and focus back on the opener — all the
|
|
1271
|
+
browser's own. A dialog already open non-modally is reopened as a modal; a
|
|
1272
|
+
target that is not a `<dialog>` is skipped. A dialog the server renders open
|
|
1273
|
+
with `data: { reactive_modal: true }` (a full page, a reply, a broadcast) is
|
|
1274
|
+
made modal as soon as the client sees it, and closed with `close()` when a
|
|
1275
|
+
morph renders it without `open`. **Actor-only like focus**: refused in
|
|
1276
|
+
`broadcast_to(js:)` — render the dialog open or closed, with the marker, instead.
|
|
1215
1277
|
- **`text(to, value)`** sets the target's `textContent` (stringified; `nil`
|
|
1216
1278
|
clears) — **XSS-safe by construction**, never `innerHTML`, strictly less
|
|
1217
1279
|
powerful than `set_attr`. With `global: true` it is the **cross-root text
|
|
@@ -1405,6 +1467,11 @@ free as an ordinary action-param name (`on(:switch, key: "pgbus")` still passes
|
|
|
1405
1467
|
> trigger to its own element (the field saves on Enter; a Cancel button — or the
|
|
1406
1468
|
> field's own blur — handles Escape), as above.
|
|
1407
1469
|
|
|
1470
|
+
A page-level chord (undo on ⌘Z/Ctrl+Z, save on `mod+s`) is a `shortcut:`
|
|
1471
|
+
rather than `event:` with `window: true`: it resolves `mod` per platform,
|
|
1472
|
+
leaves fields the user is typing in alone and prevents the chord (see
|
|
1473
|
+
*Page-level shortcuts* above).
|
|
1474
|
+
|
|
1408
1475
|
### Value-conditional visibility (`reactive_show`)
|
|
1409
1476
|
|
|
1410
1477
|
`on_client` covers the *unconditional* client-only interactions; the last gap
|
|
@@ -1698,6 +1765,146 @@ list; a reply that morphs the list puts the rows in the server's order.
|
|
|
1698
1765
|
|
|
1699
1766
|
Full guide: [Sortable lists](https://phlex-reactive.zoolutions.llc/docs/sortable-lists).
|
|
1700
1767
|
|
|
1768
|
+
### Presence (`reactive_presence`)
|
|
1769
|
+
|
|
1770
|
+
Who is viewing a stream, one entry per browser tab, each tab with a small
|
|
1771
|
+
declared state (the item it has selected), on a roster component that
|
|
1772
|
+
re-renders itself in every viewer's tab when a tab joins, leaves, goes silent
|
|
1773
|
+
or changes its state. No controller code, no Stimulus controller.
|
|
1774
|
+
|
|
1775
|
+
```ruby
|
|
1776
|
+
class Boards::Viewers < Phlex::HTML
|
|
1777
|
+
include Phlex::Reactive::Component
|
|
1778
|
+
|
|
1779
|
+
reactive_record :board
|
|
1780
|
+
reactive_presence :viewers, state: { selected: :integer }, ttl: 45
|
|
1781
|
+
|
|
1782
|
+
def initialize(board:) = @board = board
|
|
1783
|
+
|
|
1784
|
+
def view_template
|
|
1785
|
+
div(**reactive_root) do
|
|
1786
|
+
presence.members.each do |member| # one per person
|
|
1787
|
+
span(**reactive_presence_member(member)) { member.metadata["name"] }
|
|
1788
|
+
end
|
|
1789
|
+
@board.cards.each do |card|
|
|
1790
|
+
li(**on_client(:click, js.presence(selected: card.id))) do
|
|
1791
|
+
plain card.title
|
|
1792
|
+
presence.where(selected: card.id).each do |peer| # who is on this card
|
|
1793
|
+
span(**reactive_presence_peer(peer), class: "dot")
|
|
1794
|
+
end
|
|
1795
|
+
end
|
|
1796
|
+
end
|
|
1797
|
+
end
|
|
1798
|
+
end
|
|
1799
|
+
end
|
|
1800
|
+
```
|
|
1801
|
+
|
|
1802
|
+
- **The stream** is `[record, :presence, name]` (a record-less roster passes
|
|
1803
|
+
`key: ["lobby"]`), named `"<16-char digest of the record (or the key: value)>:presence:<name>"`,
|
|
1804
|
+
so any model name and id fit pgbus's stream-name budget (41 chars with the
|
|
1805
|
+
default queue prefix). That leaves 15 characters for the presence name; a
|
|
1806
|
+
longer one raises `Phlex::Reactive::ConfigurationError` at render. The root
|
|
1807
|
+
subscribes to it by itself: the client mints a
|
|
1808
|
+
`<pgbus-stream-source>` from the root's signed src, so the page must load the
|
|
1809
|
+
pgbus client element (`import "pgbus/stream_source_element"`, which any
|
|
1810
|
+
`pgbus_stream_from` on the page already does).
|
|
1811
|
+
- **`presence`** is the roster as this render reads it: `peers` (one per tab:
|
|
1812
|
+
`seat`, `member_id`, `metadata`, `state`, `joined_at`), `members` (grouped by
|
|
1813
|
+
person: `id`, `metadata`, `seats`, `state`), `where(**state)`, `count`,
|
|
1814
|
+
`empty?`, and in an action `me` and `set(**state)`.
|
|
1815
|
+
- **State** is declared like action params; undeclared keys are dropped.
|
|
1816
|
+
`js.presence(selected: 7)` sends it from the browser through the generated
|
|
1817
|
+
`presence_set` action; `presence.set(selected: 7)` writes it from an action of
|
|
1818
|
+
your own. Either way only this tab's row changes and the other viewers get a
|
|
1819
|
+
morph. The generated action replies `reply.morph` for the actor; an action of
|
|
1820
|
+
your own must return `reply.morph` itself.
|
|
1821
|
+
- **A state-backed roster** (`reactive_state`): its state is stored on each
|
|
1822
|
+
seat row in `pgbus_presence_members` (in the note the re-render reads), and
|
|
1823
|
+
the roster renders as one piece of markup under one id for every viewer. So
|
|
1824
|
+
its `reactive_state` must be the same for every viewer; per-viewer state
|
|
1825
|
+
belongs in presence state (`state:`).
|
|
1826
|
+
- **Authorization**: `presence_set` skips `verify_authorized`. The signed token
|
|
1827
|
+
proves the actor rendered this root, and the write is typed, ephemeral, and
|
|
1828
|
+
lands only on the actor's own row, keyed by its SSE connection id, which
|
|
1829
|
+
only that tab knows. To check the value (the card must belong to the board),
|
|
1830
|
+
define `authorize_presence_state!(**state)` and raise; it runs first. That hook
|
|
1831
|
+
guards only the generated `presence_set`: an action of your own that calls
|
|
1832
|
+
`presence.set` authorizes its state itself. The client also sends `presence_set` with no state on every connect (the
|
|
1833
|
+
announce): that writes no value, so the hook is not called for it.
|
|
1834
|
+
- **`presence.me`** is the acting tab, and during an action it stays the actor
|
|
1835
|
+
while the roster renders for the other viewers too. Never branch the shared
|
|
1836
|
+
output on it; the client's `-me` marks are per tab. (Likewise
|
|
1837
|
+
`reactive_connection_id` is the first `<pgbus-stream-source>` on the page, so
|
|
1838
|
+
one loaded later, ahead of it in the DOM, can change it; presence writes use
|
|
1839
|
+
their own connection and are unaffected.)
|
|
1840
|
+
- **"Me"**: the roster renders once for everyone, so the client marks this
|
|
1841
|
+
tab's element `data-reactive-presence-me` and the same person's other
|
|
1842
|
+
elements `data-reactive-presence-same-member` after every morph. Style them.
|
|
1843
|
+
Both need this tab's `reactive_presence_peer` element on the roster: a roster
|
|
1844
|
+
of members only has none, so nothing is marked.
|
|
1845
|
+
- **Events**: `reactive:presence` fires on the root with
|
|
1846
|
+
`{ joined: [seat…], left: [seat…], me: seat }` when a morph changes who is
|
|
1847
|
+
there; a replayed frame that changes nothing fires nothing. The root names
|
|
1848
|
+
the seats its render saw (`data-reactive-presence-roster`), so this works for
|
|
1849
|
+
a roster that renders people, not tabs.
|
|
1850
|
+
- **Leaving**: a closed tab leaves within about a second (pgbus watches each
|
|
1851
|
+
SSE socket for the browser closing it). A tab that crashes or loses its
|
|
1852
|
+
network, so nothing closes the socket cleanly, leaves on the next failed
|
|
1853
|
+
heartbeat write (`streams_heartbeat_interval`, 15 s by default) or expires
|
|
1854
|
+
after `ttl:` seconds. A reconnect keeps the tab's seat id and re-sends its
|
|
1855
|
+
state, but pgbus drops the row on the disconnect, so unless the reconnect
|
|
1856
|
+
lands within one coalesce window (150 ms) peers see that seat leave and
|
|
1857
|
+
join again (`reactive:presence` reports both). A row that expires while its
|
|
1858
|
+
socket stays open (the tab missed its beats) is re-joined by the heartbeat
|
|
1859
|
+
without its state, until the tab's next `js.presence`. `ttl:` must be above the
|
|
1860
|
+
transport's heartbeat interval, or a live tab would expire between two
|
|
1861
|
+
beats: `reactive_presence` raises otherwise.
|
|
1862
|
+
- **Transport**: needs pgbus 0.18.1 or later (presence keyed by SSE
|
|
1863
|
+
connection, `pgbus:connected` only after the tab's seat joined,
|
|
1864
|
+
`config.streams_authorize`). Create the table with
|
|
1865
|
+
`rails g pgbus:add_presence`, or upgrade an older one with
|
|
1866
|
+
`rails g pgbus:add_presence_seats`. `ttl:` (45 by default) is written into
|
|
1867
|
+
pgbus's `streams_presence_ttl` under the stream's pattern. Action Cable has
|
|
1868
|
+
no connection identity: rendering a presence root without it raises
|
|
1869
|
+
`Phlex::Reactive::ConfigurationError`. Every other component is unaffected.
|
|
1870
|
+
- **Identity**: pgbus takes each tab's person from what
|
|
1871
|
+
`config.streams_authorize` returns for the stream. `nil` or `false` answers
|
|
1872
|
+
403; `true` authorizes with no identity, and pgbus joins such a connection
|
|
1873
|
+
to no roster (the tab then warns in the console that it never appeared).
|
|
1874
|
+
Per-record authorization already happens at render: the signed src is
|
|
1875
|
+
minted only into a roster root your app chose to render for this user.
|
|
1876
|
+
`streams_authorize` is the revocation layer (the signed name proves pgbus
|
|
1877
|
+
minted it, not that this viewer may still read it: a removed member keeps a
|
|
1878
|
+
working name until the secret rotates). For a strict per-record check at
|
|
1879
|
+
connect time, compare `stream_name` with the presence key of each record the
|
|
1880
|
+
user may view (or give a record-less roster a `key:` with a prefix you can
|
|
1881
|
+
parse):
|
|
1882
|
+
|
|
1883
|
+
```ruby
|
|
1884
|
+
# config/initializers/pgbus.rb
|
|
1885
|
+
Pgbus.configure do |config|
|
|
1886
|
+
config.streams_authorize = ->(env, stream_name) {
|
|
1887
|
+
user = env["warden"]&.user
|
|
1888
|
+
next unless user&.active?
|
|
1889
|
+
next unless user.may_stream?(stream_name) # keep your app's own check for every stream
|
|
1890
|
+
|
|
1891
|
+
if stream_name.end_with?(":presence:viewers")
|
|
1892
|
+
next unless user.boards.any? { Boards::Viewers.reactive_presence_stream_name_for(it) == stream_name }
|
|
1893
|
+
end
|
|
1894
|
+
{ id: user.id, metadata: { name: user.name } }
|
|
1895
|
+
}
|
|
1896
|
+
end
|
|
1897
|
+
```
|
|
1898
|
+
|
|
1899
|
+
A presence stream's name is a digest (`"<16 hex>:presence:<name>"`), so the
|
|
1900
|
+
record cannot be read back from it: `reactive_presence_stream_name_for`
|
|
1901
|
+
computes it from the record. A User works too (pgbus reads its `#id`); shape
|
|
1902
|
+
the member from it with
|
|
1903
|
+
`config.streams_presence_member = ->(user) { { id: user.id, metadata: { name: user.name } } }`.
|
|
1904
|
+
- **Jobs**: a join, a leave and an expiry re-render the roster in
|
|
1905
|
+
`Phlex::Reactive::PresenceRenderJob` (on `defer_job_queue`), so the app needs
|
|
1906
|
+
an ActiveJob adapter that runs jobs. A state change renders in the action.
|
|
1907
|
+
|
|
1701
1908
|
### Client-only drafts (`reactive_persist`)
|
|
1702
1909
|
|
|
1703
1910
|
"Don't make me start over": a public application form, a multi-step wizard,
|
|
@@ -2858,8 +3065,8 @@ Notifications::Badge.broadcast_to(user, :alerts,
|
|
|
2858
3065
|
```
|
|
2859
3066
|
|
|
2860
3067
|
`broadcast_to` with `js:` **refuses the actor-only ops** (`focus`/`focus_first`/
|
|
2861
|
-
`submit`/`paste_into`/`persist_state`/`persist_clear`/`check_group` raise
|
|
2862
|
-
`ArgumentError`): broadcasting focus would steal it in every subscriber's tab, a
|
|
3068
|
+
`show_modal`/`close_dialog`/`submit`/`paste_into`/`persist_state`/`persist_clear`/`check_group` raise
|
|
3069
|
+
`ArgumentError`): broadcasting focus (or a modal) would steal it in every subscriber's tab, a
|
|
2863
3070
|
broadcast submit would force-submit every subscriber's form, and a broadcast
|
|
2864
3071
|
clipboard read, draft write or selection change would be hostile — these
|
|
2865
3072
|
belong to the actor's own reply or gesture. Everything else is a fair broadcast
|
|
@@ -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
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
|
@@ -27,6 +27,7 @@ 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
29
|
registerReactiveFeatureLoader("sortable", () => import("phlex/reactive/features/sortable"))
|
|
30
|
+
registerReactiveFeatureLoader("presence", () => import("phlex/reactive/features/presence"))
|
|
30
31
|
registerReactiveFeatureLoader("effects", () => import("phlex/reactive/features/effects"))
|
|
31
32
|
registerReactiveFeatureLoader("hints", () => import("phlex/reactive/features/hints"))
|
|
32
33
|
registerReactiveFeatureLoader("devtools", () => import("phlex/reactive/features/devtools"))
|
|
@@ -146,7 +147,9 @@ onReactiveStreamWithoutEffects((event, loadedEffects) => {
|
|
|
146
147
|
|
|
147
148
|
// The reactive roots a stream is about to put on the page: the roots in its
|
|
148
149
|
// template (the template's own root element included), and the content
|
|
149
|
-
// itself
|
|
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.
|
|
150
153
|
function incomingRoots(streamEl) {
|
|
151
154
|
const content = streamEl?.querySelector?.("template")?.content
|
|
152
155
|
if (!content) return []
|
|
@@ -154,10 +157,7 @@ function incomingRoots(streamEl) {
|
|
|
154
157
|
for (const child of content.children ?? []) {
|
|
155
158
|
if (child.matches?.('[data-controller~="reactive"]') && !roots.includes(child)) roots.unshift(child)
|
|
156
159
|
}
|
|
157
|
-
|
|
158
|
-
// root loads the bindings module when the editable is first focused, so
|
|
159
|
-
// start the import now — the module is then here before anything is typed.
|
|
160
|
-
if (content.querySelector?.("[data-reactive-editable]")) roots.push(content)
|
|
160
|
+
roots.push(content)
|
|
161
161
|
return roots
|
|
162
162
|
}
|
|
163
163
|
|