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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +107 -0
- data/README.md +258 -5
- data/app/controllers/phlex/reactive/actions_controller.rb +28 -18
- data/app/javascript/phlex/reactive/core.js +7 -1
- data/app/javascript/phlex/reactive/core.min.js +6 -2
- data/app/javascript/phlex/reactive/core.min.js.map +4 -4
- data/app/javascript/phlex/reactive/early.js +28 -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 +188 -107
- 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 +75 -46
- 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/persist.js +12 -3
- data/app/javascript/phlex/reactive/features/persist.min.js +2 -2
- data/app/javascript/phlex/reactive/features/persist.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/features/sortable.js +463 -0
- data/app/javascript/phlex/reactive/features/sortable.min.js +4 -0
- data/app/javascript/phlex/reactive/features/sortable.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 +5 -1
- data/app/javascript/phlex/reactive/reactive_controller.min.js +6 -2
- data/app/javascript/phlex/reactive/reactive_controller.min.js.map +10 -8
- data/app/javascript/phlex/reactive/runtime.js +286 -18
- data/lib/phlex/reactive/component/dsl.rb +5 -0
- data/lib/phlex/reactive/component/editable.rb +78 -0
- data/lib/phlex/reactive/component/helpers.rb +81 -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/sortable.rb +212 -0
- 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 +24 -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 +105 -0
- data/lib/phlex/reactive/streamable.rb +7 -3
- data/lib/phlex/reactive/version.rb +1 -1
- data/lib/phlex/reactive.rb +10 -0
- metadata +16 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: eeebc2783f571e3e3fcc0847318ebef76050954e0e56a99f627e8e5654467133
|
|
4
|
+
data.tar.gz: 974cc0de7e79babb8834bf41a10001e69a087bda3daf6a87122cae7654e128cb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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` |
|
|
292
|
-
| `phlex/reactive/core` |
|
|
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
|
|
1143
|
-
baseline and are **not** tracked
|
|
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
|
-
|
|
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
|
|
@@ -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
|
|