phlex-reactive 0.11.7 → 0.12.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 +55 -0
- data/README.md +69 -3
- data/app/assets/stylesheets/phlex/reactive/effects.css +106 -0
- data/app/javascript/phlex/reactive/reactive_controller.js +308 -3
- data/app/javascript/phlex/reactive/reactive_controller.min.js +2 -2
- data/app/javascript/phlex/reactive/reactive_controller.min.js.map +3 -3
- data/lib/phlex/reactive/component/dsl.rb +66 -0
- data/lib/phlex/reactive/component/helpers.rb +24 -2
- data/lib/phlex/reactive/component/registry.rb +2 -0
- data/lib/phlex/reactive/effects.rb +169 -0
- data/lib/phlex/reactive/engine.rb +5 -0
- data/lib/phlex/reactive/reply.rb +20 -15
- data/lib/phlex/reactive/response.rb +28 -19
- data/lib/phlex/reactive/streamable.rb +69 -32
- data/lib/phlex/reactive/version.rb +1 -1
- data/lib/phlex/reactive.rb +30 -2
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 27841384eb24e3e9d66f404b495705b2db4c30fec683ad21ae0f2b2c258c6a00
|
|
4
|
+
data.tar.gz: 14990c96dd4dfafefd56794531cc116f8a17a9bc6aa76d8caf50679fb95abc1b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c36de342a6909b7cbd04f58a9d6da9b77497acfe5f6777c200932a54af5251872ec91b2a5d624f14f1cb5b3faed8fe60e5a169e4363cb6d32bf7d24418fd88ac
|
|
7
|
+
data.tar.gz: 45c811958371300f558d57f41e8ead04e607cc07cd43a9915aa2534fa15b650d1c284ca92b871734b755791b730fdb325f7e28e7f8372616aff383887873f340
|
data/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,61 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
8
8
|
|
|
9
9
|
### Added
|
|
10
10
|
|
|
11
|
+
- **Project board — the kanban flagship demo (#216).** A live three-lane board
|
|
12
|
+
on the docs site (`/docs/example-project-board`, `/demos/project-board`)
|
|
13
|
+
composing the toolkit end to end: each card is its own nested reactive root
|
|
14
|
+
(record + signed style state) whose move is ONE reply — the row's remove
|
|
15
|
+
(its exit effect animates before the element leaves), a fresh-token append
|
|
16
|
+
into the new lane (wearing its enter effect), and a `reactive:js` text-op
|
|
17
|
+
stream repainting all three count badges — with the same delta broadcast to
|
|
18
|
+
peers, actor echo excluded. Inline rename morphs in place, archive is
|
|
19
|
+
confirm-gated + optimistic, per-lane composers flash on a blank title, and
|
|
20
|
+
the effect-style picker (including `random`) rides signed state to drive
|
|
21
|
+
per-call `effect:` overrides. Peer count badges stay live everywhere via
|
|
22
|
+
`broadcast_to(js:)` — closing the stale-badge gap the team-inbox flagship
|
|
23
|
+
documents — and empty lanes are pure CSS `:empty`, so the 0↔1 boundary
|
|
24
|
+
needs no bookkeeping on either delivery path. Docs-app only; no gem code
|
|
25
|
+
changes.
|
|
26
|
+
|
|
27
|
+
- **Reactive effects — animate enter/exit/update on every stream render (#215).**
|
|
28
|
+
Opt-in at three levels, most specific wins: `Phlex::Reactive.effects = true`
|
|
29
|
+
(or a `{ enter:/exit:/update: }` hash) is the global switch AND the app-wide
|
|
30
|
+
default set; `reactive_effects enter: :slide, update: false` refines per
|
|
31
|
+
component (`false` opts out entirely; declaring on a component also works
|
|
32
|
+
standalone with no global switch); `effect:` on every stream builder, reply
|
|
33
|
+
verb, and `broadcast_to` overrides one stream (`effect: false` suppresses a
|
|
34
|
+
declared effect for that call). Five built-ins ship as an engine stylesheet —
|
|
35
|
+
`stylesheet_link_tag "phlex/reactive/effects"` — fade/slide/scale/highlight/
|
|
36
|
+
shake, all inside `prefers-reduced-motion: no-preference` and tunable via
|
|
37
|
+
`--reactive-fx-*` custom properties; `:random` picks a built-in per
|
|
38
|
+
application; custom effects take the `{ during:, from:, to: }` class-legs
|
|
39
|
+
vocabulary (issue #186), Tailwind-friendly and stylesheet-free. On the wire
|
|
40
|
+
the resolved hooks ride the component root as `data-reactive-effect-<hook>`
|
|
41
|
+
attributes (omitted entirely when off — byte-identical to previous releases)
|
|
42
|
+
and per-call overrides ride the `<turbo-stream>` element itself; ONE
|
|
43
|
+
document-level `turbo:before-stream-render` interceptor animates replies AND
|
|
44
|
+
broadcasts identically on Action Cable and pgbus. `remove` is special: the
|
|
45
|
+
exit animation runs BEFORE the element leaves the DOM, awaited via
|
|
46
|
+
`animationend`/`transitionend` with a computed-duration fallback hard-capped
|
|
47
|
+
at 1s — and a zero computed duration (no effects CSS loaded) skips the wait
|
|
48
|
+
entirely, so a missing stylesheet can never freeze a removal. Unknown names
|
|
49
|
+
raise at class load server-side and warn-and-skip client-side (two-sided
|
|
50
|
+
default-deny). Zero additional allocations per render with effects on (the
|
|
51
|
+
resolved fragment is a per-class memo keyed on the config + registry
|
|
52
|
+
generations); the render/token hot paths hold flat with effects off.
|
|
53
|
+
|
|
54
|
+
- **Confirm on the draft-row remove — `reactive_nested_remove(confirm:)` (#218).**
|
|
55
|
+
`reactive_nested_remove` now accepts the SAME `confirm:` the other triggers
|
|
56
|
+
(`on`/`on_client`) do — a static message String, or the conditional Hash form
|
|
57
|
+
(`{ when: …, message: }` / `{ predicate: …, message: }`, #179) — routed through
|
|
58
|
+
the shared `apply_confirm!` and gated client-side behind the same overridable
|
|
59
|
+
`confirmResolver` (the styled-modal seam, #52/#55/#178). On accept it does the
|
|
60
|
+
existing remove (a draft row leaves the DOM; a persisted row is `_destroy`-marked
|
|
61
|
+
+ hidden) and the existing JSON/attributes re-sync; on cancel, nothing. Per-row
|
|
62
|
+
messages come for free — the confirm is a per-render string. Works in BOTH wire
|
|
63
|
+
modes (`:attributes` and `as: :json`). No confirm: → the immediate-remove fast
|
|
64
|
+
path, unchanged.
|
|
65
|
+
|
|
11
66
|
- **Fill-then-add for draft nested rows — `reactive_nested_add(:assoc, from:, clear:)` (#208).**
|
|
12
67
|
The draft-rows primitive's add is *inline-edit* — it clones the template and
|
|
13
68
|
focuses the new row's first field, so you type INTO the row. A common
|
data/README.md
CHANGED
|
@@ -332,11 +332,13 @@ The [inline edit example](https://phlex-reactive.zoolutions.llc/docs/example-inl
|
|
|
332
332
|
| [Client-only ops](https://phlex-reactive.zoolutions.llc/docs/example-client-ops) | `on_client` tabs / outside-close menu / accessible drawer — zero fetches, zero custom JS |
|
|
333
333
|
| [Failure surface](https://phlex-reactive.zoolutions.llc/docs/example-failure) | `error_flash` + `data-reactive-error` + `dismiss_after:` — what you get for free when an action fails |
|
|
334
334
|
| [Team inbox (flagship)](https://phlex-reactive.zoolutions.llc/docs/example-team-inbox) | The whole toolkit in one UI: collection rows, optimistic archive that **reverts on failure**, cross-tab broadcast, an `on_client` kebab, error flashes |
|
|
335
|
+
| [Project board (flagship)](https://phlex-reactive.zoolutions.llc/docs/example-project-board) | The kanban: cards move across lanes with **enter/exit effects** (per-visitor style picker incl. `random`), live count badges in every tab via `broadcast_to(js:)`, nested reactive rows with inline rename, confirm-gated archive |
|
|
335
336
|
|
|
336
337
|
Every page renders its **real** reactive component inline (source read straight
|
|
337
338
|
off the file), so the demo and the code can never drift. The
|
|
338
|
-
[Team inbox](https://phlex-reactive.zoolutions.llc/docs/example-team-inbox)
|
|
339
|
-
|
|
339
|
+
[Team inbox](https://phlex-reactive.zoolutions.llc/docs/example-team-inbox) and the
|
|
340
|
+
[Project board](https://phlex-reactive.zoolutions.llc/docs/example-project-board) are the
|
|
341
|
+
flagships — every feature composed into believable UIs.
|
|
340
342
|
|
|
341
343
|
---
|
|
342
344
|
|
|
@@ -397,7 +399,7 @@ Use in controllers: `render turbo_stream: Counter.replace(counter)`.
|
|
|
397
399
|
| `reactive_dirty` / `reactive_dirty warn_unsaved: true` / `reactive_dirty only: %i[...]` | **Dirty tracking**, declared once at the class level, against the DOM's own `defaultValue`/`defaultChecked`/`defaultSelected` — no client state. Marks changed fields + the root `data-reactive-dirty`; `warn_unsaved:` arms a `beforeunload`/`turbo:before-visit` guard; `only:` scopes tracking to named fields. Style with `[data-reactive-dirty]`. See [Dirty-field tracking](#dirty-field-tracking-reactive_dirty). |
|
|
398
400
|
| `nested_update!(:assoc, attrs)` | Map a nested param onto `<assoc>_attributes` with id preservation; update the record. |
|
|
399
401
|
| `reactive_nested_list(:assoc, as: :attributes \| :json)` / `reactive_nested_template(:assoc)` / `reactive_nested_row` | **Draft nested-attribute rows** (the "new parent + child rows" form): the container rows land in, the `<template>` holding ONE row's markup, and the row wrapper marker — all **client-only** form state, keyed by association (several collections per root). `as: :json` (default `:attributes`) serializes the rows into ONE hidden JSON field instead of posting `accepts_nested_attributes_for` names — for an app whose controller `JSON.parse`s a serialized param. See [Draft rows for a new parent](#draft-rows-for-a-new-parent-reactive_nested_). |
|
|
400
|
-
| `reactive_nested_add(:assoc, from:, clear:)` / `reactive_nested_remove` | The row triggers, client-only (zero round trips): add clones the template and renumbers its placeholder index; remove deletes a draft row from the DOM, or `_destroy`-marks + hides a persisted row (a hidden `[_destroy]` input present). **Fill-then-add**: `from: { row_field => "#source-selector" }` seeds the new row from add controls that live OUTSIDE it (a preset select, a typeahead), and `clear: true` resets them — composes with `as: :attributes` AND `as: :json`. See [Draft rows for a new parent](#draft-rows-for-a-new-parent-reactive_nested_). |
|
|
402
|
+
| `reactive_nested_add(:assoc, from:, clear:)` / `reactive_nested_remove(confirm:)` | The row triggers, client-only (zero round trips): add clones the template and renumbers its placeholder index; remove deletes a draft row from the DOM, or `_destroy`-marks + hides a persisted row (a hidden `[_destroy]` input present). **Fill-then-add**: `from: { row_field => "#source-selector" }` seeds the new row from add controls that live OUTSIDE it (a preset select, a typeahead), and `clear: true` resets them — composes with `as: :attributes` AND `as: :json`. **Confirm on remove**: `reactive_nested_remove(confirm: "Really delete this row?")` gates the remove behind the same overridable `confirmResolver` as `on`/`on_client` (a per-row string, or the conditional `{ when:, message: }` Hash). See [Draft rows for a new parent](#draft-rows-for-a-new-parent-reactive_nested_). |
|
|
401
403
|
| `nested_field_name(:assoc, :field, index: nil)` | The Rails `accepts_nested_attributes_for` wire name for one row field — `order[line_items_attributes][NEW_ROW][quantity]` (the template placeholder) by default, a real index when given. Scope-aware under `reactive_scope`. |
|
|
402
404
|
| `reactive_collection :name, item:, container:, count:, empty:, size:` | Declare an add/remove-row list once; actions call `reply.append`/`prepend`/`remove`. See [Reactive collections](#reactive-collections-addremove-rows--count--empty-state). |
|
|
403
405
|
| `reply.replace` / `.morph` / `.update` / `.remove` / `.redirect(url)` / `.with(*)` / `.js(ops)` | Return from an action to control the reply (flash, remove, redirect, multi-stream, server-pushed client ops). See [Controlling the action's reply](#reply--controlling-the-actions-reply). |
|
|
@@ -2133,6 +2135,66 @@ end
|
|
|
2133
2135
|
`reactive_collection` is the per-actor add/remove + count + empty-state wrapper,
|
|
2134
2136
|
not a replacement for the broadcast.
|
|
2135
2137
|
|
|
2138
|
+
### Effects — animate enter/exit/update (opt-in)
|
|
2139
|
+
|
|
2140
|
+
Reactive updates land instantly and invisibly: a removed row pops out of
|
|
2141
|
+
existence, an appended row pops in, a cross-tab change gives no cue. Effects
|
|
2142
|
+
make the reactivity *visible* — rows fade/slide in and out, updates flash —
|
|
2143
|
+
with zero app JS. Strictly **opt-in at three levels**, most specific wins:
|
|
2144
|
+
|
|
2145
|
+
```ruby
|
|
2146
|
+
# 1. GLOBAL — setting it is the opt-in AND the app-wide default set:
|
|
2147
|
+
Phlex::Reactive.effects = true # { enter: :fade, exit: :fade, update: :highlight }
|
|
2148
|
+
Phlex::Reactive.effects = { enter: :slide, exit: :fade, update: :highlight }
|
|
2149
|
+
|
|
2150
|
+
# 2. PER COMPONENT — refine or opt out (works standalone too; declaring on a
|
|
2151
|
+
# component opts it in even without the global switch):
|
|
2152
|
+
class Notifications::Row < ApplicationComponent
|
|
2153
|
+
reactive_effects enter: :slide, exit: :fade # built-ins (shipped CSS)
|
|
2154
|
+
# reactive_effects update: false # disable one hook
|
|
2155
|
+
# reactive_effects false # opt this component out entirely
|
|
2156
|
+
# reactive_effects enter: :random # a random built-in per application
|
|
2157
|
+
end
|
|
2158
|
+
|
|
2159
|
+
# 3. PER CALL — the escape hatch for one stream:
|
|
2160
|
+
reply.remove(effect: :shake) # a dramatic one-off exit
|
|
2161
|
+
reply.append(item, to: :items, effect: :scale) # this row only
|
|
2162
|
+
Item.replace(@todo, effect: false) # suppress a declared effect once
|
|
2163
|
+
Row.broadcast_to(@list, :todos, append: todo, target: "rows", effect: :slide)
|
|
2164
|
+
```
|
|
2165
|
+
|
|
2166
|
+
The hooks map to stream actions: **enter** (`append`/`prepend`) animates the
|
|
2167
|
+
arriving element, **exit** (`remove`) runs *before* the element leaves the DOM
|
|
2168
|
+
(the removal waits for the animation, capped at 1s so a missing stylesheet can
|
|
2169
|
+
never wedge it), **update** (`replace`/`update`, plain or morph) flashes the
|
|
2170
|
+
fresh render. Effects fire for the actor's own reply AND for broadcasts alike
|
|
2171
|
+
— if a debounced-save grid flashes too much, declare `reactive_effects
|
|
2172
|
+
update: false` on that component.
|
|
2173
|
+
|
|
2174
|
+
Link the shipped stylesheet once (five built-ins — `:fade`, `:slide`,
|
|
2175
|
+
`:scale`, `:highlight`, `:shake` — wrapped in `prefers-reduced-motion:
|
|
2176
|
+
no-preference`, tunable via `--reactive-fx-*` CSS custom properties):
|
|
2177
|
+
|
|
2178
|
+
```erb
|
|
2179
|
+
<%= stylesheet_link_tag "phlex/reactive/effects" %>
|
|
2180
|
+
```
|
|
2181
|
+
|
|
2182
|
+
Custom effects skip the stylesheet entirely: pass named class legs (the same
|
|
2183
|
+
`{ during:, from:, to: }` vocabulary `js.toggle(transition:)` uses), perfect
|
|
2184
|
+
for Tailwind utilities:
|
|
2185
|
+
|
|
2186
|
+
```ruby
|
|
2187
|
+
reactive_effects enter: { during: %w[transition-all duration-300],
|
|
2188
|
+
from: %w[opacity-0 translate-y-2],
|
|
2189
|
+
to: %w[opacity-100 translate-y-0] }
|
|
2190
|
+
```
|
|
2191
|
+
|
|
2192
|
+
Unknown effect names raise at class load (server) and warn-and-skip on the
|
|
2193
|
+
client (default-deny, two-sided). With effects off (the default) the wire is
|
|
2194
|
+
byte-identical to previous releases; with them on, the resolved hooks ride the
|
|
2195
|
+
component root as `data-reactive-effect-*` attributes — identity, never state,
|
|
2196
|
+
and identical on Action Cable and pgbus.
|
|
2197
|
+
|
|
2136
2198
|
### Configuration (`config/initializers/phlex_reactive.rb`)
|
|
2137
2199
|
|
|
2138
2200
|
```ruby
|
|
@@ -2180,6 +2242,10 @@ Phlex::Reactive.error_flash = ->(kind) do
|
|
|
2180
2242
|
end
|
|
2181
2243
|
end
|
|
2182
2244
|
|
|
2245
|
+
# Effects (issue #215): opt in globally + set the app-wide default hooks; see
|
|
2246
|
+
# "Effects" above. Off (nil) by default — byte-identical wire when off.
|
|
2247
|
+
Phlex::Reactive.effects = { enter: :fade, exit: :fade, update: :highlight }
|
|
2248
|
+
|
|
2183
2249
|
# Turnkey APM integration. Names each action Component#action in AppSignal/Sentry/
|
|
2184
2250
|
# Datadog and reports action-body crashes with component/action tags. SDK is
|
|
2185
2251
|
# runtime-detected (no gem dependency); a custom object responding to
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/* phlex-reactive effects (issue #215) — the shipped built-in animations.
|
|
2
|
+
*
|
|
3
|
+
* Opt in from your layout:
|
|
4
|
+
*
|
|
5
|
+
* <%= stylesheet_link_tag "phlex/reactive/effects" %>
|
|
6
|
+
*
|
|
7
|
+
* The client applies these classes transiently around stream renders:
|
|
8
|
+
* reactive-fx--<name>-<hook>, where <name> is fade/slide/scale/highlight/
|
|
9
|
+
* shake and <hook> is enter (append/prepend), exit (remove), or update
|
|
10
|
+
* (replace/update). Every class is removed again on animationend, so nothing
|
|
11
|
+
* here leaks into steady-state styling.
|
|
12
|
+
*
|
|
13
|
+
* Everything is wrapped in prefers-reduced-motion: no-preference — the client
|
|
14
|
+
* ALSO skips effects entirely under reduced motion (defense in depth; a zero
|
|
15
|
+
* computed duration short-circuits the exit wait, so removals never stall).
|
|
16
|
+
*
|
|
17
|
+
* Tune globally (or per component root) by overriding the custom properties:
|
|
18
|
+
*
|
|
19
|
+
* :root { --reactive-fx-duration: 200ms; --reactive-fx-highlight-color: #bae6fd80; }
|
|
20
|
+
*
|
|
21
|
+
* Keep durations comfortably under 1s — the client hard-caps any effect wait
|
|
22
|
+
* at 1000ms (an exit past the cap is removed mid-animation).
|
|
23
|
+
*/
|
|
24
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
25
|
+
:root {
|
|
26
|
+
--reactive-fx-duration: 300ms;
|
|
27
|
+
--reactive-fx-highlight-duration: 700ms;
|
|
28
|
+
--reactive-fx-highlight-color: rgba(250, 204, 21, 0.5);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/* fade — opacity in/out. The update variant re-fades the fresh render in. */
|
|
32
|
+
@keyframes reactive-fx-fade-in {
|
|
33
|
+
from { opacity: 0; }
|
|
34
|
+
to { opacity: 1; }
|
|
35
|
+
}
|
|
36
|
+
@keyframes reactive-fx-fade-out {
|
|
37
|
+
from { opacity: 1; }
|
|
38
|
+
to { opacity: 0; }
|
|
39
|
+
}
|
|
40
|
+
.reactive-fx--fade-enter,
|
|
41
|
+
.reactive-fx--fade-update {
|
|
42
|
+
animation: reactive-fx-fade-in var(--reactive-fx-duration) ease-out both;
|
|
43
|
+
}
|
|
44
|
+
.reactive-fx--fade-exit {
|
|
45
|
+
animation: reactive-fx-fade-out var(--reactive-fx-duration) ease-in both;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/* slide — a short vertical travel + fade. */
|
|
49
|
+
@keyframes reactive-fx-slide-in {
|
|
50
|
+
from { opacity: 0; transform: translateY(0.5rem); }
|
|
51
|
+
to { opacity: 1; transform: none; }
|
|
52
|
+
}
|
|
53
|
+
@keyframes reactive-fx-slide-out {
|
|
54
|
+
from { opacity: 1; transform: none; }
|
|
55
|
+
to { opacity: 0; transform: translateY(0.5rem); }
|
|
56
|
+
}
|
|
57
|
+
.reactive-fx--slide-enter,
|
|
58
|
+
.reactive-fx--slide-update {
|
|
59
|
+
animation: reactive-fx-slide-in var(--reactive-fx-duration) ease-out both;
|
|
60
|
+
}
|
|
61
|
+
.reactive-fx--slide-exit {
|
|
62
|
+
animation: reactive-fx-slide-out var(--reactive-fx-duration) ease-in both;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/* scale — grow in / shrink out around the element's center. */
|
|
66
|
+
@keyframes reactive-fx-scale-in {
|
|
67
|
+
from { opacity: 0; transform: scale(0.92); }
|
|
68
|
+
to { opacity: 1; transform: none; }
|
|
69
|
+
}
|
|
70
|
+
@keyframes reactive-fx-scale-out {
|
|
71
|
+
from { opacity: 1; transform: none; }
|
|
72
|
+
to { opacity: 0; transform: scale(0.92); }
|
|
73
|
+
}
|
|
74
|
+
.reactive-fx--scale-enter,
|
|
75
|
+
.reactive-fx--scale-update {
|
|
76
|
+
animation: reactive-fx-scale-in var(--reactive-fx-duration) ease-out both;
|
|
77
|
+
}
|
|
78
|
+
.reactive-fx--scale-exit {
|
|
79
|
+
animation: reactive-fx-scale-out var(--reactive-fx-duration) ease-in both;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/* highlight — the classic background flash that fades to the element's own
|
|
83
|
+
* background. One animation for every hook: flash on arrival, on change,
|
|
84
|
+
* and as a last flare before a removal. */
|
|
85
|
+
@keyframes reactive-fx-highlight {
|
|
86
|
+
from { background-color: var(--reactive-fx-highlight-color); }
|
|
87
|
+
}
|
|
88
|
+
.reactive-fx--highlight-enter,
|
|
89
|
+
.reactive-fx--highlight-update,
|
|
90
|
+
.reactive-fx--highlight-exit {
|
|
91
|
+
animation: reactive-fx-highlight var(--reactive-fx-highlight-duration) ease-out both;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/* shake — a quick horizontal oscillation (attention / destructive cue). */
|
|
95
|
+
@keyframes reactive-fx-shake {
|
|
96
|
+
10%, 90% { transform: translateX(-1px); }
|
|
97
|
+
20%, 80% { transform: translateX(2px); }
|
|
98
|
+
30%, 50%, 70% { transform: translateX(-3px); }
|
|
99
|
+
40%, 60% { transform: translateX(3px); }
|
|
100
|
+
}
|
|
101
|
+
.reactive-fx--shake-enter,
|
|
102
|
+
.reactive-fx--shake-update,
|
|
103
|
+
.reactive-fx--shake-exit {
|
|
104
|
+
animation: reactive-fx-shake 400ms cubic-bezier(0.36, 0.07, 0.19, 0.97) both;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -490,6 +490,284 @@ export function __resetReactiveDismissForTest() {
|
|
|
490
490
|
dismissRegistered = false
|
|
491
491
|
}
|
|
492
492
|
|
|
493
|
+
// Reactive effects (issue #215): animate ENTER (append/prepend), EXIT (remove)
|
|
494
|
+
// and UPDATE (replace/update, plain or morph) when a <turbo-stream> renders.
|
|
495
|
+
// Document-level and render-wrapping like the dismiss hook above, so ONE
|
|
496
|
+
// interceptor covers both delivery paths (a reply and a broadcast). Strictly
|
|
497
|
+
// data-driven and default-deny: the per-call data-reactive-effect on the
|
|
498
|
+
// stream element wins ("off" suppresses), else the carrier element's
|
|
499
|
+
// data-reactive-effect-<hook> — the DOM target for exit/update, the INCOMING
|
|
500
|
+
// template root for enter. No attribute → no work; unknown names and
|
|
501
|
+
// malformed legs warn + skip (a newer or forged attr must never break the
|
|
502
|
+
// page). prefers-reduced-motion disables everything (the shipped CSS is also
|
|
503
|
+
// media-wrapped — defense in depth).
|
|
504
|
+
//
|
|
505
|
+
// Timing:
|
|
506
|
+
// * exit — the animation runs BEFORE Turbo's render (the removal), awaited
|
|
507
|
+
// via animationend/transitionend with a timeout fallback; a ZERO computed
|
|
508
|
+
// duration (no effects CSS loaded, reduced-motion CSS gate) skips the wait
|
|
509
|
+
// entirely, so a missing stylesheet can never freeze a removal.
|
|
510
|
+
// * enter/update — Turbo renders first, then the effect class is applied to
|
|
511
|
+
// the inserted/updated element(s) and removed on settle (fire-and-forget).
|
|
512
|
+
// Re-applying an update effect restarts it (class off → reflow → on).
|
|
513
|
+
//
|
|
514
|
+
// A named effect maps to the shipped CSS class reactive-fx--<name>-<hook>
|
|
515
|
+
// (app/assets/stylesheets/phlex/reactive/effects.css); "random" picks a
|
|
516
|
+
// built-in per application; a "["-prefixed value is a custom
|
|
517
|
+
// [during, from, to] class-legs triple (the #96/#186 vocabulary), run with
|
|
518
|
+
// runTransition's add → frame → swap → settle choreography.
|
|
519
|
+
const EFFECT_HOOKS = Object.freeze({
|
|
520
|
+
append: "enter",
|
|
521
|
+
prepend: "enter",
|
|
522
|
+
replace: "update",
|
|
523
|
+
update: "update",
|
|
524
|
+
remove: "exit",
|
|
525
|
+
})
|
|
526
|
+
const EFFECT_BUILT_INS = Object.freeze(["fade", "slide", "scale", "highlight", "shake"])
|
|
527
|
+
// Marks an incoming template root so the post-render scan finds the inserted
|
|
528
|
+
// CLONE (Turbo clones template content on render — attrs ride the clone).
|
|
529
|
+
const EFFECT_PENDING_ATTR = "data-reactive-fx-pending"
|
|
530
|
+
// The hard ceiling on any effect wait — an exit's removal is delayed at most
|
|
531
|
+
// this long even if animationend/transitionend never fire.
|
|
532
|
+
const EFFECT_SETTLE_FALLBACK_MS = 1000
|
|
533
|
+
|
|
534
|
+
let effectsRegistered = false
|
|
535
|
+
export function registerReactiveEffects() {
|
|
536
|
+
if (effectsRegistered) return
|
|
537
|
+
if (typeof document === "undefined" || typeof document.addEventListener !== "function") return
|
|
538
|
+
effectsRegistered = true
|
|
539
|
+
document.addEventListener("turbo:before-stream-render", wrapStreamRenderForEffects)
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
export function __resetReactiveEffectsForTest() {
|
|
543
|
+
effectsRegistered = false
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// Wrap event.detail.render (the dismiss-hook pattern) when this stream both
|
|
547
|
+
// maps to a hook AND resolves to an effect. Resolution happens HERE, before
|
|
548
|
+
// the render, because exit must read the target while it is still in the DOM
|
|
549
|
+
// and enter must read (and mark) the template content before Turbo clones it.
|
|
550
|
+
function wrapStreamRenderForEffects(event) {
|
|
551
|
+
const detail = event.detail
|
|
552
|
+
const original = detail?.render
|
|
553
|
+
if (typeof original !== "function" || original.__reactiveEffectsWrapped) return
|
|
554
|
+
const streamEl = detail?.newStream ?? event.target
|
|
555
|
+
const hook = EFFECT_HOOKS[streamEl?.getAttribute?.("action")]
|
|
556
|
+
if (!hook || effectsReducedMotion()) return
|
|
557
|
+
const effect = resolveStreamEffect(streamEl, hook)
|
|
558
|
+
if (!effect) return
|
|
559
|
+
|
|
560
|
+
const wrapped =
|
|
561
|
+
hook === "exit"
|
|
562
|
+
? async (el) => {
|
|
563
|
+
await runExitEffect(effectTarget(streamEl), effect)
|
|
564
|
+
await original(el)
|
|
565
|
+
}
|
|
566
|
+
: async (el) => {
|
|
567
|
+
const container = hook === "enter" ? markIncomingRoots(streamEl) : null
|
|
568
|
+
await original(el)
|
|
569
|
+
if (hook === "enter") animateMarkedRoots(container, effect)
|
|
570
|
+
else runEnterOrUpdateEffect(effectTarget(streamEl), effect)
|
|
571
|
+
}
|
|
572
|
+
wrapped.__reactiveEffectsWrapped = true
|
|
573
|
+
detail.render = wrapped
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// The effect for this stream: per-call data-reactive-effect first ("off" →
|
|
577
|
+
// none), else the carrier's declared data-reactive-effect-<hook>.
|
|
578
|
+
function resolveStreamEffect(streamEl, hook) {
|
|
579
|
+
const perCall = streamEl.getAttribute?.("data-reactive-effect")
|
|
580
|
+
if (perCall === "off") return null
|
|
581
|
+
if (perCall) return parseEffect(perCall, hook)
|
|
582
|
+
const carrier = hook === "enter" ? incomingEffectRoot(streamEl) : effectTarget(streamEl)
|
|
583
|
+
const declared = carrier?.getAttribute?.(`data-reactive-effect-${hook}`)
|
|
584
|
+
return declared ? parseEffect(declared, hook) : null
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// The stream's CURRENT DOM target (re-queried at use, so a post-replace call
|
|
588
|
+
// sees the freshly-swapped element). Our builders always emit `target` —
|
|
589
|
+
// multi-`targets` streams are not ours and pass through unanimated.
|
|
590
|
+
function effectTarget(streamEl) {
|
|
591
|
+
const target = streamEl.getAttribute?.("target")
|
|
592
|
+
return target ? (document.getElementById?.(target) ?? null) : null
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// The incoming content's root element (an append/prepend's arriving
|
|
596
|
+
// component) — the carrier of a declared enter effect.
|
|
597
|
+
function incomingEffectRoot(streamEl) {
|
|
598
|
+
return streamEl.querySelector?.("template")?.content?.firstElementChild ?? null
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// A wire value → an executable effect: { className } for a shipped built-in
|
|
602
|
+
// ("random" picks one per application), { legs } for a custom triple. null +
|
|
603
|
+
// console.warn for anything else (default-deny).
|
|
604
|
+
function parseEffect(value, hook) {
|
|
605
|
+
if (value.startsWith("[")) {
|
|
606
|
+
let legs = null
|
|
607
|
+
try {
|
|
608
|
+
const parsed = JSON.parse(value)
|
|
609
|
+
if (Array.isArray(parsed) && parsed.length === 3) legs = parsed.map(String)
|
|
610
|
+
} catch {
|
|
611
|
+
// malformed JSON → the shared warn below
|
|
612
|
+
}
|
|
613
|
+
if (legs) return { legs }
|
|
614
|
+
console.warn(`[phlex-reactive] malformed effect legs ${JSON.stringify(value)} — skipped`)
|
|
615
|
+
return null
|
|
616
|
+
}
|
|
617
|
+
const name =
|
|
618
|
+
value === "random" ? EFFECT_BUILT_INS[Math.floor(Math.random() * EFFECT_BUILT_INS.length)] : value
|
|
619
|
+
if (!EFFECT_BUILT_INS.includes(name)) {
|
|
620
|
+
console.warn(`[phlex-reactive] unknown effect ${JSON.stringify(value)} — skipped`)
|
|
621
|
+
return null
|
|
622
|
+
}
|
|
623
|
+
return { className: `reactive-fx--${name}-${hook}` }
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function effectsReducedMotion() {
|
|
627
|
+
try {
|
|
628
|
+
return typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches
|
|
629
|
+
} catch {
|
|
630
|
+
return false
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// Stamp each incoming template root with the pending marker (Turbo's render
|
|
635
|
+
// clones the content, so the marker rides the inserted clone) and return the
|
|
636
|
+
// container the post-render scan searches. Pre-render on purpose.
|
|
637
|
+
function markIncomingRoots(streamEl) {
|
|
638
|
+
const content = streamEl.querySelector?.("template")?.content
|
|
639
|
+
if (!content) return null
|
|
640
|
+
for (const child of Array.from(content.children ?? [])) child.setAttribute?.(EFFECT_PENDING_ATTR, "")
|
|
641
|
+
return effectTarget(streamEl)
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// Post-render: find the just-inserted clones by their marker, unmark, animate.
|
|
645
|
+
function animateMarkedRoots(container, effect) {
|
|
646
|
+
if (typeof container?.querySelectorAll !== "function") return
|
|
647
|
+
for (const el of Array.from(container.querySelectorAll(`[${EFFECT_PENDING_ATTR}]`))) {
|
|
648
|
+
el.removeAttribute(EFFECT_PENDING_ATTR)
|
|
649
|
+
runEnterOrUpdateEffect(el, effect)
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// EXIT: animate on the still-present element, resolve when settled, and only
|
|
654
|
+
// then does the wrapper run Turbo's removal. Zero computed duration (no
|
|
655
|
+
// effects CSS) resolves immediately — never a dead 1s freeze.
|
|
656
|
+
async function runExitEffect(el, effect) {
|
|
657
|
+
if (!el?.classList) return
|
|
658
|
+
if (effect.legs) {
|
|
659
|
+
await runLegsEffect(el, effect.legs)
|
|
660
|
+
return
|
|
661
|
+
}
|
|
662
|
+
el.classList.add(effect.className)
|
|
663
|
+
const duration = effectDurationMs(el)
|
|
664
|
+
if (duration <= 0) {
|
|
665
|
+
el.classList.remove(effect.className)
|
|
666
|
+
return
|
|
667
|
+
}
|
|
668
|
+
await effectSettled(el, duration)
|
|
669
|
+
el.classList.remove(effect.className)
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// ENTER/UPDATE: fire-and-forget after the render. A re-applied class is
|
|
673
|
+
// removed + reflowed first so rapid successive updates restart the flash; the
|
|
674
|
+
// per-element token keeps an older settle from clearing a newer application.
|
|
675
|
+
function runEnterOrUpdateEffect(el, effect) {
|
|
676
|
+
if (!el?.classList) return
|
|
677
|
+
if (effect.legs) {
|
|
678
|
+
runLegsEffect(el, effect.legs)
|
|
679
|
+
return
|
|
680
|
+
}
|
|
681
|
+
if (el.classList.contains(effect.className)) {
|
|
682
|
+
el.classList.remove(effect.className)
|
|
683
|
+
void el.offsetWidth // force a reflow so re-adding restarts the animation
|
|
684
|
+
}
|
|
685
|
+
el.classList.add(effect.className)
|
|
686
|
+
const duration = effectDurationMs(el)
|
|
687
|
+
if (duration <= 0) {
|
|
688
|
+
el.classList.remove(effect.className)
|
|
689
|
+
return
|
|
690
|
+
}
|
|
691
|
+
const token = (el.__reactiveFxToken = (el.__reactiveFxToken ?? 0) + 1)
|
|
692
|
+
effectSettled(el, duration).then(() => {
|
|
693
|
+
if (el.__reactiveFxToken === token) el.classList.remove(effect.className)
|
|
694
|
+
})
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// Custom class legs — runTransition's choreography (add during+from, swap
|
|
698
|
+
// from→to on the next frame, settle, clean up), promise-shaped so an exit can
|
|
699
|
+
// await it. Class lists are space-separated (the #96/#186 wire).
|
|
700
|
+
//
|
|
701
|
+
// Rapid re-application on the same element RESTARTS, mirroring the named
|
|
702
|
+
// path's token guard: each run takes the per-element token, clears any
|
|
703
|
+
// earlier run's leg classes, and a superseded run stops touching the element
|
|
704
|
+
// the moment a newer run owns it — so a stale settle can never strip classes
|
|
705
|
+
// mid-animation or double-swap the legs. A superseded EXIT run resolves
|
|
706
|
+
// early, which only lets Turbo's removal proceed sooner (never later).
|
|
707
|
+
async function runLegsEffect(el, legs) {
|
|
708
|
+
const [during, from, to] = legs.map(splitEffectClasses)
|
|
709
|
+
const token = (el.__reactiveFxToken = (el.__reactiveFxToken ?? 0) + 1)
|
|
710
|
+
el.classList.remove(...during, ...from, ...to)
|
|
711
|
+
el.classList.add(...during, ...from)
|
|
712
|
+
await effectNextFrame()
|
|
713
|
+
if (el.__reactiveFxToken !== token) return
|
|
714
|
+
el.classList.remove(...from)
|
|
715
|
+
el.classList.add(...to)
|
|
716
|
+
const duration = effectDurationMs(el)
|
|
717
|
+
if (duration > 0) await effectSettled(el, duration)
|
|
718
|
+
if (el.__reactiveFxToken !== token) return
|
|
719
|
+
el.classList.remove(...during, ...to)
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function splitEffectClasses(list) {
|
|
723
|
+
return String(list ?? "")
|
|
724
|
+
.split(/\s+/)
|
|
725
|
+
.filter(Boolean)
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// The longest computed animation/transition (duration + delay, comma lists
|
|
729
|
+
// included) in ms, capped at the hard fallback. 0 when getComputedStyle is
|
|
730
|
+
// unavailable or nothing animates — callers skip the wait entirely.
|
|
731
|
+
function effectDurationMs(el) {
|
|
732
|
+
if (typeof getComputedStyle !== "function") return 0
|
|
733
|
+
try {
|
|
734
|
+
const style = getComputedStyle(el)
|
|
735
|
+
const longest = (value) =>
|
|
736
|
+
String(value ?? "")
|
|
737
|
+
.split(",")
|
|
738
|
+
.reduce((max, part) => Math.max(max, parseFloat(part) || 0), 0)
|
|
739
|
+
const animation = longest(style.animationDuration) + longest(style.animationDelay)
|
|
740
|
+
const transition = longest(style.transitionDuration) + longest(style.transitionDelay)
|
|
741
|
+
return Math.min(Math.max(animation, transition) * 1000, EFFECT_SETTLE_FALLBACK_MS)
|
|
742
|
+
} catch {
|
|
743
|
+
return 0
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// Resolve on animationend/transitionend — whichever fires first — with a
|
|
748
|
+
// timeout slightly past the computed duration, so a canceled animation (a
|
|
749
|
+
// display:none ancestor, an interrupted transition) can't hang an exit.
|
|
750
|
+
function effectSettled(el, durationMs) {
|
|
751
|
+
return new Promise((resolve) => {
|
|
752
|
+
let done = false
|
|
753
|
+
const settle = () => {
|
|
754
|
+
if (done) return
|
|
755
|
+
done = true
|
|
756
|
+
resolve()
|
|
757
|
+
}
|
|
758
|
+
el.addEventListener?.("animationend", settle, { once: true })
|
|
759
|
+
el.addEventListener?.("transitionend", settle, { once: true })
|
|
760
|
+
setTimeout(settle, Math.min(durationMs + 50, EFFECT_SETTLE_FALLBACK_MS))
|
|
761
|
+
})
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
function effectNextFrame() {
|
|
765
|
+
return new Promise((resolve) => {
|
|
766
|
+
if (typeof requestAnimationFrame === "function") requestAnimationFrame(() => resolve())
|
|
767
|
+
else setTimeout(resolve, 16)
|
|
768
|
+
})
|
|
769
|
+
}
|
|
770
|
+
|
|
493
771
|
// Offline CSS hook (issue #101). Mirror data-reactive-offline on
|
|
494
772
|
// document.documentElement from navigator.onLine, kept in sync by the window
|
|
495
773
|
// online/offline events — so an app can dim a save button or show a banner with
|
|
@@ -666,6 +944,7 @@ export function registerReactiveActions() {
|
|
|
666
944
|
registerReactiveJs()
|
|
667
945
|
registerReactiveDefer()
|
|
668
946
|
registerReactiveDismiss()
|
|
947
|
+
registerReactiveEffects()
|
|
669
948
|
registerReactiveOffline()
|
|
670
949
|
attachLatencyHandle()
|
|
671
950
|
}
|
|
@@ -1967,6 +2246,35 @@ export default class extends Controller {
|
|
|
1967
2246
|
// inside ANOTHER collection's row (the issue #15 closest-form posture).
|
|
1968
2247
|
if (row.closest?.('[data-controller~="reactive"]') !== this.element) return
|
|
1969
2248
|
|
|
2249
|
+
// Confirm gate (issue #218): reactive_nested_remove(confirm:) emits the SAME
|
|
2250
|
+
// data-reactive-confirm[-when]-param the other triggers do (nestedRemove reads
|
|
2251
|
+
// params via getAttribute, not event.params, so pull them off the trigger),
|
|
2252
|
+
// routed through the SAME #effectiveConfirmMessage + confirmResolver seam. A
|
|
2253
|
+
// static string always shows; a conditional Hash fires only when it matches,
|
|
2254
|
+
// else null. No confirm attr → null → the immediate-remove fast path.
|
|
2255
|
+
const confirm = trigger?.getAttribute?.("data-reactive-confirm-param")
|
|
2256
|
+
const confirmWhen = trigger?.getAttribute?.("data-reactive-confirm-when-param")
|
|
2257
|
+
const message = this.#effectiveConfirmMessage(confirm, confirmWhen)
|
|
2258
|
+
if (!message) return this.#removeNestedRow(row)
|
|
2259
|
+
|
|
2260
|
+
// Gate through the overridable confirmResolver (issues #52/#55/#178) — a
|
|
2261
|
+
// themed dialog set with setConfirmResolver covers this trigger too. Call the
|
|
2262
|
+
// resolver INSIDE the chain so even a SYNCHRONOUS override throw is a cancel
|
|
2263
|
+
// (like a dismissed dialog), and remove ONLY on a truthy resolution.
|
|
2264
|
+
return Promise.resolve()
|
|
2265
|
+
.then(() => confirmResolver(message))
|
|
2266
|
+
.catch(() => false)
|
|
2267
|
+
.then((ok) => {
|
|
2268
|
+
if (ok) this.#removeNestedRow(row)
|
|
2269
|
+
})
|
|
2270
|
+
}
|
|
2271
|
+
|
|
2272
|
+
// The remove itself, shared by the confirmed and no-confirm paths. Draft rows
|
|
2273
|
+
// leave the DOM; a persisted row (a hidden [_destroy] input present) is marked
|
|
2274
|
+
// "1" + hidden instead (set-value + dispatch contract, #183), so Rails destroys
|
|
2275
|
+
// it on save. Then re-sync every owned JSON-mode list (#208) — an absent row
|
|
2276
|
+
// IS the removal; a form without a JSON list iterates an empty set and exits.
|
|
2277
|
+
#removeNestedRow(row) {
|
|
1970
2278
|
const destroy = [...(row.querySelectorAll?.('input[name$="[_destroy]"]') ?? [])][0]
|
|
1971
2279
|
if (destroy) {
|
|
1972
2280
|
destroy.value = "1"
|
|
@@ -1978,9 +2286,6 @@ export default class extends Controller {
|
|
|
1978
2286
|
row.parentNode?.removeChild?.(row)
|
|
1979
2287
|
}
|
|
1980
2288
|
|
|
1981
|
-
// JSON mode (issue #208): the removed (or _destroy-hidden) row must leave
|
|
1982
|
-
// the serialized array too — an absent row IS the removal. Re-sync every
|
|
1983
|
-
// owned JSON-mode list; a form without one iterates an empty set and exits.
|
|
1984
2289
|
this.#syncAllNestedJson()
|
|
1985
2290
|
}
|
|
1986
2291
|
|