phlex-reactive 0.16.0 → 0.16.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 +247 -145
- data/README.md +85 -1
- data/app/controllers/phlex/reactive/actions_controller.rb +10 -0
- data/app/javascript/phlex/reactive/core.js +1 -0
- data/app/javascript/phlex/reactive/core.min.js +4 -4
- data/app/javascript/phlex/reactive/core.min.js.map +4 -4
- data/app/javascript/phlex/reactive/features/bindings.js +81 -4
- 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/drag.js +522 -0
- data/app/javascript/phlex/reactive/features/drag.min.js +4 -0
- data/app/javascript/phlex/reactive/features/drag.min.js.map +10 -0
- data/app/javascript/phlex/reactive/reactive_controller.js +2 -0
- data/app/javascript/phlex/reactive/reactive_controller.min.js +4 -4
- data/app/javascript/phlex/reactive/reactive_controller.min.js.map +7 -6
- data/app/javascript/phlex/reactive/runtime.js +57 -23
- data/lib/phlex/reactive/component/css_vars_binding.rb +118 -0
- data/lib/phlex/reactive/component/drag.rb +270 -0
- data/lib/phlex/reactive/component/helpers.rb +5 -1
- data/lib/phlex/reactive/engine.rb +6 -4
- data/lib/phlex/reactive/held_fields.rb +21 -1
- data/lib/phlex/reactive/response.rb +55 -4
- data/lib/phlex/reactive/test_helpers.rb +6 -0
- data/lib/phlex/reactive/version.rb +1 -1
- data/sig/phlex/reactive/response.rbs +3 -0
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 860b397593c4d0a2d05d2485345bfc78564e784942c8413db1b67fbbacea6b41
|
|
4
|
+
data.tar.gz: '059b1148284859d6a69afdd4f62693f4dbedb0eab41370af843e30a4f3d27e4f'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0500ccbf1d82ea1039cf078dbe62e67efd6da9c9a2ecbeaace46d7d316f4a6c62ef479ea8b1a81e263ad1f738c25d9aa64f0dc53fc7af7a1bbadd6dab49e5021
|
|
7
|
+
data.tar.gz: eae4009ae39fba74bc6219c4b90df31589bed31ed70a457c7f240075ffd50b84662a39af475d878bc339d2e538611e248a287e11a5bbdacd52cb47d1bf73d45e
|
data/CHANGELOG.md
CHANGED
|
@@ -6,7 +6,67 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **Draggable regions: move and resize a box, or move a point, inside a
|
|
12
|
+
frame, with the result in fields (#416).** `reactive_frame` marks the
|
|
13
|
+
coordinate space, `reactive_box(fields:, value:, aspect:, min:, action:,
|
|
14
|
+
busy:, messages:)` a box in it, `reactive_box_handle(edge)` an edge or corner
|
|
15
|
+
to resize from, and `reactive_point(fields:, value:, within:, …)` a point,
|
|
16
|
+
inside the frame or inside a box. The helpers render the position as
|
|
17
|
+
percentages from `value:`, so the first paint needs no JavaScript and a
|
|
18
|
+
re-render with new values moves the box. The box moves and resizes with a
|
|
19
|
+
mouse, a pen, a finger or the keyboard (arrows 1%, Shift 10%, Alt resizes,
|
|
20
|
+
Escape puts it back, each step announced in the words of `messages:`). It
|
|
21
|
+
stays inside the frame and never goes below `min:`. `aspect:` locks the
|
|
22
|
+
ratio (a Numeric, or a field such as a row of preset radios, which re-fits
|
|
23
|
+
the box when it changes). The fields hold fractions rounded to 4 decimals,
|
|
24
|
+
with `input` as the box moves (at most once per field a frame) and one
|
|
25
|
+
`change` on release. `action:` is sent once per release through the root's
|
|
26
|
+
pipeline, with `x`, `y`, `width`, `height`. Without it the binding is
|
|
27
|
+
client-only and works on a `ClientBindings` root. A morph that lands
|
|
28
|
+
mid-drag does not interrupt it. The helpers check their arguments' keys,
|
|
29
|
+
types and ranges at render (an undeclared action under `verbose_errors`).
|
|
30
|
+
Client: a new `drag` feature module (3,576 B gzipped): +2,789 B in the
|
|
31
|
+
default bundle, +51 B in the split core, which loads it only on a page with
|
|
32
|
+
a frame, a box or a point. Docs: a "Draggable regions" guide with a live demo.
|
|
33
|
+
|
|
34
|
+
- **Fields that feed CSS custom properties: `reactive_css_vars` (#417).** A
|
|
35
|
+
preview that follows a control (a slider that brightens an image, a select
|
|
36
|
+
that rotates it) needed app JavaScript or a request per input event.
|
|
37
|
+
`reactive_css_vars(to: "#sample", vars: { "--light" => "light",
|
|
38
|
+
"--turn" => { field: "turn", unit: "deg" } })` reads each field as
|
|
39
|
+
`reactive_show` does (a checkbox gives `1` or `0`, a radio group its checked
|
|
40
|
+
value), parses it as a finite number and writes `number + unit` on every
|
|
41
|
+
`to:` element with `style.setProperty`, on connect, on `input`/`change` and
|
|
42
|
+
after a morph, with no request. Anything that is not a number removes the
|
|
43
|
+
property, so the `var()` fallback applies: no text from a field reaches CSS.
|
|
44
|
+
Units come from a fixed list (`""`, `px`, `%`, `deg`, `turn`, `ms`, `s`,
|
|
45
|
+
`em`, `rem`, `fr`) and names match `--[a-zA-Z0-9-]+`, checked at render and in
|
|
46
|
+
the browser. `to:` resolves like a client op's (`:root`, inside the root,
|
|
47
|
+
`global: true`, an Array). It goes on the root or on any element inside it,
|
|
48
|
+
and works on a `ClientBindings` view. Client: +460 B gzipped in the default
|
|
49
|
+
bundle; +462 B in `features/bindings` and +16 B in the split core.
|
|
50
|
+
Docs: "Live CSS properties" on the client-ops page, with a demo.
|
|
51
|
+
|
|
52
|
+
### Fixed
|
|
53
|
+
|
|
54
|
+
- **A reply that refuses the posted values keeps the held marks (#413).**
|
|
55
|
+
Every reply to a root's own request released the fields a morph held
|
|
56
|
+
(#370), so after an action refused a save the user's next keystroke posted
|
|
57
|
+
no held names and went through over the other change. Return
|
|
58
|
+
`reply.morph.refused` (every mark) or `reply.morph.refused("title")` (only
|
|
59
|
+
those fields) and the client keeps the marks: the root's next action names
|
|
60
|
+
them in `reactive_held_fields` again, until the action stores the field or
|
|
61
|
+
the user takes the other value. The endpoint sends it as the
|
|
62
|
+
`X-Reactive-Refused` response header; the reply stays a 200, with no error
|
|
63
|
+
marker and no `reactive:error`. `run_reactive(…).refused?` and
|
|
64
|
+
`response.refused?` / `response.refused_fields` assert it in a unit test.
|
|
65
|
+
Client: +65 B gzipped in the default bundle, +60 B in the split core.
|
|
66
|
+
Docs: "Knowing what was held" on the architecture page, the `reply` table
|
|
67
|
+
in the README.
|
|
68
|
+
|
|
69
|
+
## [0.16.0] - 2026-10-16
|
|
10
70
|
|
|
11
71
|
### Upgrading from 0.15.x
|
|
12
72
|
|
|
@@ -103,8 +163,25 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
103
163
|
|
|
104
164
|
## [0.15.5] - 2026-10-15
|
|
105
165
|
|
|
106
|
-
|
|
107
|
-
|
|
166
|
+
### Fixed
|
|
167
|
+
|
|
168
|
+
- **A server trigger inside a client-only root reaches the component around it
|
|
169
|
+
(#410).** A `ClientBindings` component (a `reactive_filter` list, a
|
|
170
|
+
`reactive_show` wrapper, an `on_client` menu) renders a root with no token.
|
|
171
|
+
Rendered inside a server component, it swallowed that component's `on(...)`
|
|
172
|
+
triggers: Stimulus sends the action to the nearest root, which had no token,
|
|
173
|
+
and the outer root's field collection skipped every control under it (the
|
|
174
|
+
#15 rule). A tokenless client-only root is now transparent to server round
|
|
175
|
+
trips: `dispatch` forwards to the nearest token-bearing root above (skipping
|
|
176
|
+
further client-only roots, waking a dormant one), and that root's action
|
|
177
|
+
collects the controls under nested client-only roots. A nested server
|
|
178
|
+
component keeps its own fields and triggers, and client bindings stay on
|
|
179
|
+
their own root. With no token-bearing root above, nothing is sent, and
|
|
180
|
+
`verbose_errors`/debug logs one warning. Lazy and deferred shells are not
|
|
181
|
+
client-only. Client: +414 B gzipped in the default bundle, +415 B in the
|
|
182
|
+
split core.
|
|
183
|
+
|
|
184
|
+
## [0.15.4] - 2026-10-14
|
|
108
185
|
|
|
109
186
|
### Added
|
|
110
187
|
|
|
@@ -276,6 +353,10 @@ changelog sections, so this section spans the whole run to 0.15.5.
|
|
|
276
353
|
anything else raises at render. Client: +27 B gzipped in the default
|
|
277
354
|
bundle, +20 B in the persist module; the split core unchanged.
|
|
278
355
|
|
|
356
|
+
## [0.15.3] - 2026-10-13
|
|
357
|
+
|
|
358
|
+
### Added
|
|
359
|
+
|
|
279
360
|
- **An image fallback that recovers — `reactive_image_fallback(hide: true, show_alt: nil)` (#383).**
|
|
280
361
|
`img(**reactive_image_fallback)` marks an `<img>` whose `src` fails with
|
|
281
362
|
`data-reactive-image-state="error"` and hides it; `show_alt: "#bank-name"`
|
|
@@ -296,6 +377,91 @@ changelog sections, so this section spans the whole run to 0.15.5.
|
|
|
296
377
|
+550 B in the split core. Docs: "An image that falls back, and recovers" on
|
|
297
378
|
the client-ops example.
|
|
298
379
|
|
|
380
|
+
### Fixed
|
|
381
|
+
|
|
382
|
+
- **`reactive_media` waits for `take` characters before calling a key a miss
|
|
383
|
+
(#380).** A value shorter than `take:` sliced to a short key, missed the
|
|
384
|
+
pool and wrote `blank_label` while the user was still typing. It is now
|
|
385
|
+
*pending*: `blank_label` only names a full-length key that misses. New
|
|
386
|
+
options: `match:` (a Regexp, anchored to the whole key; a key it rejects is
|
|
387
|
+
pending too) and `pending: :clear` (default: clear the preview, empty the
|
|
388
|
+
label) or `:keep` (leave both alone). The preview carries
|
|
389
|
+
`data-reactive-media-state="pending|hit|miss"`, so a row can hide itself
|
|
390
|
+
with CSS. `match:` reaches the browser as a JS pattern: its `\A`/`\z`
|
|
391
|
+
anchors are dropped (a trailing `\Z` becomes `\n?`), and what JS reads
|
|
392
|
+
differently raises at render (options, escapes such as `\h`/`\p{…}`/`\s`/
|
|
393
|
+
`\u{…}`, POSIX and nested classes, `&&`, `(?i)`/`(?>…)`, characters above
|
|
394
|
+
U+FFFF). **Behaviour change:** with `take:`, a short value now leaves the
|
|
395
|
+
label empty instead of writing `blank_label`. Without `take:` or `match:`
|
|
396
|
+
nothing changes. Client: +160 B gzipped in the default bundle (+152 B on
|
|
397
|
+
top of #381/#382), +152 B in the bindings module, the split core unchanged. Docs: "Media
|
|
398
|
+
previews" on the client-ops example.
|
|
399
|
+
|
|
400
|
+
- **A pool `<img>` with an `alt` and no `src` keeps its name (#381).**
|
|
401
|
+
`js.copy_media` and `reactive_media` treated a source `<img>` without `src`
|
|
402
|
+
as a clear: the preview's `alt` was emptied and `label:` got `blank_label`,
|
|
403
|
+
so a known entry with no logo file lost its name. Such a source is now a hit
|
|
404
|
+
without an image: the target's `src` and `srcset` are removed, its `alt` is
|
|
405
|
+
set, and `label:` gets that `alt`. Only a pool miss, a selector that matches
|
|
406
|
+
nothing, or a source that is not an `<img>` clears. Client: +1 B gzipped in
|
|
407
|
+
the default bundle, −2 B in the split core; the bindings module is
|
|
408
|
+
unchanged.
|
|
409
|
+
|
|
410
|
+
- **`js.preview_file` follows an app's upload validation (#382).** A preview
|
|
411
|
+
ran once, on `change`, so a file the app then rejected (cleared with
|
|
412
|
+
`input.value = ""`, no new `change`) stayed on screen. Two pieces fix it.
|
|
413
|
+
`js.preview_file(…, valid: true)` treats an input whose `validity.valid` is
|
|
414
|
+
false (`setCustomValidity`, `required`) as empty: it never gets a preview,
|
|
415
|
+
and one it had is cleared and its URL revoked. And `reactive:file-changed`
|
|
416
|
+
is the documented event for "the app changed this input's files without a
|
|
417
|
+
`change` event": bind the op to it as well,
|
|
418
|
+
`mix(on_client(:change, op), on_client("reactive:file-changed", op))`, and
|
|
419
|
+
the app dispatches it after it clears or invalidates the field. That works
|
|
420
|
+
whichever `change` listener runs first. Without `valid:` the op behaves as
|
|
421
|
+
before. Client: +22 B gzipped in the default bundle, +34 B in the split
|
|
422
|
+
core (on top of #381). Docs: "Preview a chosen image" on the client-ops example.
|
|
423
|
+
|
|
424
|
+
### Performance
|
|
425
|
+
|
|
426
|
+
- **Guarded and persisted forms connect in linear time.** Each `warn_unsaved`
|
|
427
|
+
root added its own `beforeunload` / `turbo:before-visit` pair on `window`,
|
|
428
|
+
and each persisted root its own `turbo:submit-end` listener on `document`.
|
|
429
|
+
A browser scans a target's listeners on every add and remove, so connecting
|
|
430
|
+
and disconnecting N of them was quadratic. One shared listener per window or
|
|
431
|
+
document now serves them all, added with the first root and removed with
|
|
432
|
+
the last; behaviour is unchanged (live dirty count, one prompt per visit,
|
|
433
|
+
#298). Headless Chromium, insert + connect: 4,000 guarded roots ~306 ->
|
|
434
|
+
~227 ms, 4,000 persisted roots ~280 -> ~230 ms. +194 B gzipped in the
|
|
435
|
+
default bundle (110 B for the guard, 84 B for persist).
|
|
436
|
+
- **A record-backed token no longer builds a GlobalID URI.** For a record
|
|
437
|
+
whose `to_gid` is GlobalID's own and whose id is an Integer, the gid is
|
|
438
|
+
built directly as `gid://<app>/<Class>/<id>` (byte-identical, pinned by
|
|
439
|
+
`identity_gid_spec.rb`); anything else still calls `to_gid`. The token goes
|
|
440
|
+
from ~11.4 µs and 54 objects to ~5.2 µs and 20 (in-place A/B).
|
|
441
|
+
- **`rake bench:browser`.** Connect, disconnect and a heap soak for the
|
|
442
|
+
shipped client in headless Chromium. The happy-dom connect bench is
|
|
443
|
+
engine-relative and missed the quadratic above; this is the one to trust.
|
|
444
|
+
- **The doctor's authorization check is linear, not quadratic.** It rebuilt
|
|
445
|
+
the Inspector's whole component list (a Prism scan per action) once for
|
|
446
|
+
every component. It now reads it once: `doctor.checks` over the dummy app's
|
|
447
|
+
90 components drops from 1,337 ms to 55 ms, and an app with a few hundred
|
|
448
|
+
components no longer waits tens of seconds on `rake phlex_reactive:doctor`.
|
|
449
|
+
- **`on()` checks for removed keywords without building arrays.** Every
|
|
450
|
+
trigger ran `params.keys & REMOVED_ON_KWARGS.keys`. It now returns at once
|
|
451
|
+
when there are no extra keywords. `on(:increment)` allocates 5 objects
|
|
452
|
+
instead of 8; a 7-trigger component's `render_component` 118 instead of 136
|
|
453
|
+
(~5% faster in an in-place A/B, a method-level win that shows on broadcast
|
|
454
|
+
fan-out, not request latency).
|
|
455
|
+
- **The request bench measures production, not a file watcher.** The dummy
|
|
456
|
+
app's test env left code reloading on, so every request globbed `app/`:
|
|
457
|
+
`rake bench:request` read ~1.7-2.0 ms and 1,272 objects per state-backed
|
|
458
|
+
action, against ~0.30-0.35 ms and 824 objects with it off (same machine,
|
|
459
|
+
interleaved). Test-env only; the gem is unchanged.
|
|
460
|
+
|
|
461
|
+
## [0.15.2] - 2026-10-13
|
|
462
|
+
|
|
463
|
+
### Added
|
|
464
|
+
|
|
299
465
|
- **Media previews — `js.copy_media` and `reactive_media` (#373).**
|
|
300
466
|
`js.copy_media("#logo", from: "#logo-007")` copies `src`, `srcset` and `alt`
|
|
301
467
|
from an `<img>` the server rendered onto a preview `<img>`; the URL is read
|
|
@@ -385,6 +551,10 @@ changelog sections, so this section spans the whole run to 0.15.5.
|
|
|
385
551
|
Client: +251 B gzipped in the default bundle, +247 B in the split core.
|
|
386
552
|
Docs: "Keeping client state through a morph" on the client-ops example.
|
|
387
553
|
|
|
554
|
+
## [0.15.1] - 2026-10-12
|
|
555
|
+
|
|
556
|
+
### Added
|
|
557
|
+
|
|
388
558
|
- **Modal dialogs — `js.show_modal` / `js.close_dialog` and
|
|
389
559
|
`data-reactive-modal` (#365).** `js.show_modal("#picker")` calls
|
|
390
560
|
`HTMLDialogElement#showModal()`, so an `on_client` dialog gets the browser's
|
|
@@ -410,6 +580,10 @@ changelog sections, so this section spans the whole run to 0.15.5.
|
|
|
410
580
|
every other key alone (+134 B gzipped). `once:` raises on these keys. Existing
|
|
411
581
|
chords render unchanged.
|
|
412
582
|
|
|
583
|
+
## [0.15.0] - 2026-10-12
|
|
584
|
+
|
|
585
|
+
### Added
|
|
586
|
+
|
|
413
587
|
- **Presence — `reactive_presence`, `presence`, `js.presence` (#358).** A
|
|
414
588
|
roster component declares the presence stream it shows
|
|
415
589
|
(`reactive_presence :viewers, state: { selected: :integer }, ttl: 45`) and
|
|
@@ -453,6 +627,40 @@ changelog sections, so this section spans the whole run to 0.15.5.
|
|
|
453
627
|
true` without `shortcut:` behaves exactly as before (never prevented, no
|
|
454
628
|
editable guard).
|
|
455
629
|
|
|
630
|
+
### Fixed
|
|
631
|
+
|
|
632
|
+
- **A stream that renders into a connected root wires what its content needs
|
|
633
|
+
(#356).** A root read its feature markers only at connect and after a morph
|
|
634
|
+
of itself, so a `reactive_show` target, select-all/count group, filter,
|
|
635
|
+
nested list, dirty field or paste trigger that an `append`, `prepend`,
|
|
636
|
+
`update`, `before`, `after` or child `replace` brought into it stayed dead
|
|
637
|
+
until the root reconnected. Now, once such a stream has rendered, the root
|
|
638
|
+
re-runs the seeds of the features it has (a new show target takes the
|
|
639
|
+
field's current value) and connects any feature its new content needs, in
|
|
640
|
+
both clients; bindings and form also wire a binding whose gate reads true
|
|
641
|
+
only then. As after a morph, this covers a root with an action token or a
|
|
642
|
+
lazy shell; a tokenless, client-only root does not re-read. On the split
|
|
643
|
+
client the stream's render waits for the module the content needs, as it
|
|
644
|
+
already did for an incoming root, so the content lands wired; the wait is
|
|
645
|
+
capped at 1 s, after which Turbo renders and the root connects the module
|
|
646
|
+
when it arrives. A draft
|
|
647
|
+
restore no longer marks fields `data-reactive-dirty` on a root that never
|
|
648
|
+
opted into dirty tracking (one that only gates a paste trigger).
|
|
649
|
+
|
|
650
|
+
- **An editable is editable in an engine without `contenteditable="plaintext-only"`,
|
|
651
|
+
however it reaches the page (#355).** Such an engine reads the server's
|
|
652
|
+
`plaintext-only` as invalid, so the element cannot even be focused. The
|
|
653
|
+
bindings feature switched it to `"true"` only when it connected, or after a
|
|
654
|
+
morph of the root, which left an editable that a stream appended, a frame
|
|
655
|
+
loaded or an unfocused morph rewrote uneditable. The runtime now does the
|
|
656
|
+
switch with one observer, installed only in such an engine (elsewhere the
|
|
657
|
+
probe is all that runs). Bundle: default +157 B gzip, split core +241 B,
|
|
658
|
+
`features/bindings` -138 B.
|
|
659
|
+
|
|
660
|
+
## [0.14.4] - 2026-10-11
|
|
661
|
+
|
|
662
|
+
### Added
|
|
663
|
+
|
|
456
664
|
- **Sortable lists — `reactive_sortable`, `reactive_sortable_source`,
|
|
457
665
|
`reactive_sortable_item` (#350).** Rows drag to reorder a list with a mouse,
|
|
458
666
|
a finger (a 200 ms press) or the keyboard (Space picks up, arrows move,
|
|
@@ -490,104 +698,10 @@ changelog sections, so this section spans the whole run to 0.15.5.
|
|
|
490
698
|
client the stream that brings it starts the import). Bundle: default
|
|
491
699
|
+977 B gzip, split core +412 B, `features/bindings` +524 B.
|
|
492
700
|
|
|
493
|
-
|
|
494
|
-
payload qualifies (#346).** A binding can require top-level `event.detail`
|
|
495
|
-
keys to strictly equal JSON scalars, so `on_client("turbo:submit-end",
|
|
496
|
-
js.check_group("ids[]", false), detail: { success: true })` clears a bulk
|
|
497
|
-
selection after a successful submit and keeps it after a failed one. A
|
|
498
|
-
missing detail or key never matches; a miss runs no confirm and no
|
|
499
|
-
`preventDefault`. Each binding record carries its own filter, so two bindings
|
|
500
|
-
on one element can split success and failure. A non-scalar value, an empty
|
|
501
|
-
Hash, or `detail:` with `once:` raises at render. Records without `detail`
|
|
502
|
-
are byte-identical.
|
|
701
|
+
## [0.14.3] - 2026-10-07
|
|
503
702
|
|
|
504
703
|
### Fixed
|
|
505
704
|
|
|
506
|
-
- **A server trigger inside a client-only root reaches the component around it
|
|
507
|
-
(#410).** A `ClientBindings` component (a `reactive_filter` list, a
|
|
508
|
-
`reactive_show` wrapper, an `on_client` menu) renders a root with no token.
|
|
509
|
-
Rendered inside a server component, it swallowed that component's `on(...)`
|
|
510
|
-
triggers: Stimulus sends the action to the nearest root, which had no token,
|
|
511
|
-
and the outer root's field collection skipped every control under it (the
|
|
512
|
-
#15 rule). A tokenless client-only root is now transparent to server round
|
|
513
|
-
trips: `dispatch` forwards to the nearest token-bearing root above (skipping
|
|
514
|
-
further client-only roots, waking a dormant one), and that root's action
|
|
515
|
-
collects the controls under nested client-only roots. A nested server
|
|
516
|
-
component keeps its own fields and triggers, and client bindings stay on
|
|
517
|
-
their own root. With no token-bearing root above, nothing is sent, and
|
|
518
|
-
`verbose_errors`/debug logs one warning. Lazy and deferred shells are not
|
|
519
|
-
client-only. Client: +414 B gzipped in the default bundle, +415 B in the
|
|
520
|
-
split core.
|
|
521
|
-
|
|
522
|
-
- **`reactive_media` waits for `take` characters before calling a key a miss
|
|
523
|
-
(#380).** A value shorter than `take:` sliced to a short key, missed the
|
|
524
|
-
pool and wrote `blank_label` while the user was still typing. It is now
|
|
525
|
-
*pending*: `blank_label` only names a full-length key that misses. New
|
|
526
|
-
options: `match:` (a Regexp, anchored to the whole key; a key it rejects is
|
|
527
|
-
pending too) and `pending: :clear` (default: clear the preview, empty the
|
|
528
|
-
label) or `:keep` (leave both alone). The preview carries
|
|
529
|
-
`data-reactive-media-state="pending|hit|miss"`, so a row can hide itself
|
|
530
|
-
with CSS. `match:` reaches the browser as a JS pattern: its `\A`/`\z`
|
|
531
|
-
anchors are dropped (a trailing `\Z` becomes `\n?`), and what JS reads
|
|
532
|
-
differently raises at render (options, escapes such as `\h`/`\p{…}`/`\s`/
|
|
533
|
-
`\u{…}`, POSIX and nested classes, `&&`, `(?i)`/`(?>…)`, characters above
|
|
534
|
-
U+FFFF). **Behaviour change:** with `take:`, a short value now leaves the
|
|
535
|
-
label empty instead of writing `blank_label`. Without `take:` or `match:`
|
|
536
|
-
nothing changes. Client: +160 B gzipped in the default bundle (+152 B on
|
|
537
|
-
top of #381/#382), +152 B in the bindings module, the split core unchanged. Docs: "Media
|
|
538
|
-
previews" on the client-ops example.
|
|
539
|
-
|
|
540
|
-
- **A pool `<img>` with an `alt` and no `src` keeps its name (#381).**
|
|
541
|
-
`js.copy_media` and `reactive_media` treated a source `<img>` without `src`
|
|
542
|
-
as a clear: the preview's `alt` was emptied and `label:` got `blank_label`,
|
|
543
|
-
so a known entry with no logo file lost its name. Such a source is now a hit
|
|
544
|
-
without an image: the target's `src` and `srcset` are removed, its `alt` is
|
|
545
|
-
set, and `label:` gets that `alt`. Only a pool miss, a selector that matches
|
|
546
|
-
nothing, or a source that is not an `<img>` clears. Client: +1 B gzipped in
|
|
547
|
-
the default bundle, −2 B in the split core; the bindings module is
|
|
548
|
-
unchanged.
|
|
549
|
-
|
|
550
|
-
- **`js.preview_file` follows an app's upload validation (#382).** A preview
|
|
551
|
-
ran once, on `change`, so a file the app then rejected (cleared with
|
|
552
|
-
`input.value = ""`, no new `change`) stayed on screen. Two pieces fix it.
|
|
553
|
-
`js.preview_file(…, valid: true)` treats an input whose `validity.valid` is
|
|
554
|
-
false (`setCustomValidity`, `required`) as empty: it never gets a preview,
|
|
555
|
-
and one it had is cleared and its URL revoked. And `reactive:file-changed`
|
|
556
|
-
is the documented event for "the app changed this input's files without a
|
|
557
|
-
`change` event": bind the op to it as well,
|
|
558
|
-
`mix(on_client(:change, op), on_client("reactive:file-changed", op))`, and
|
|
559
|
-
the app dispatches it after it clears or invalidates the field. That works
|
|
560
|
-
whichever `change` listener runs first. Without `valid:` the op behaves as
|
|
561
|
-
before. Client: +22 B gzipped in the default bundle, +34 B in the split
|
|
562
|
-
core (on top of #381). Docs: "Preview a chosen image" on the client-ops example.
|
|
563
|
-
|
|
564
|
-
- **A stream that renders into a connected root wires what its content needs
|
|
565
|
-
(#356).** A root read its feature markers only at connect and after a morph
|
|
566
|
-
of itself, so a `reactive_show` target, select-all/count group, filter,
|
|
567
|
-
nested list, dirty field or paste trigger that an `append`, `prepend`,
|
|
568
|
-
`update`, `before`, `after` or child `replace` brought into it stayed dead
|
|
569
|
-
until the root reconnected. Now, once such a stream has rendered, the root
|
|
570
|
-
re-runs the seeds of the features it has (a new show target takes the
|
|
571
|
-
field's current value) and connects any feature its new content needs, in
|
|
572
|
-
both clients; bindings and form also wire a binding whose gate reads true
|
|
573
|
-
only then. As after a morph, this covers a root with an action token or a
|
|
574
|
-
lazy shell; a tokenless, client-only root does not re-read. On the split
|
|
575
|
-
client the stream's render waits for the module the content needs, as it
|
|
576
|
-
already did for an incoming root, so the content lands wired; the wait is
|
|
577
|
-
capped at 1 s, after which Turbo renders and the root connects the module
|
|
578
|
-
when it arrives. A draft
|
|
579
|
-
restore no longer marks fields `data-reactive-dirty` on a root that never
|
|
580
|
-
opted into dirty tracking (one that only gates a paste trigger).
|
|
581
|
-
- **An editable is editable in an engine without `contenteditable="plaintext-only"`,
|
|
582
|
-
however it reaches the page (#355).** Such an engine reads the server's
|
|
583
|
-
`plaintext-only` as invalid, so the element cannot even be focused. The
|
|
584
|
-
bindings feature switched it to `"true"` only when it connected, or after a
|
|
585
|
-
morph of the root, which left an editable that a stream appended, a frame
|
|
586
|
-
loaded or an unfocused morph rewrote uneditable. The runtime now does the
|
|
587
|
-
switch with one observer, installed only in such an engine (elsewhere the
|
|
588
|
-
probe is all that runs). Bundle: default +157 B gzip, split core +241 B,
|
|
589
|
-
`features/bindings` -138 B.
|
|
590
|
-
|
|
591
705
|
- **A `checked:`-only `reactive_show_targets` entry hides at zero boxes
|
|
592
706
|
(#348).** `"#bulk-bar" => { if: { "ids[]" => { checked: 1.. } } }` was left
|
|
593
707
|
visible once the owning root held no box of the group (the last ticked row
|
|
@@ -597,42 +711,20 @@ changelog sections, so this section spans the whole run to 0.15.5.
|
|
|
597
711
|
first paint already were. A target with any value term keeps the skip when
|
|
598
712
|
none of its fields is owned.
|
|
599
713
|
|
|
600
|
-
|
|
714
|
+
## [0.14.2] - 2026-10-07
|
|
601
715
|
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
built directly as `gid://<app>/<Class>/<id>` (byte-identical, pinned by
|
|
615
|
-
`identity_gid_spec.rb`); anything else still calls `to_gid`. The token goes
|
|
616
|
-
from ~11.4 µs and 54 objects to ~5.2 µs and 20 (in-place A/B).
|
|
617
|
-
- **`rake bench:browser`.** Connect, disconnect and a heap soak for the
|
|
618
|
-
shipped client in headless Chromium. The happy-dom connect bench is
|
|
619
|
-
engine-relative and missed the quadratic above; this is the one to trust.
|
|
620
|
-
- **The doctor's authorization check is linear, not quadratic.** It rebuilt
|
|
621
|
-
the Inspector's whole component list (a Prism scan per action) once for
|
|
622
|
-
every component. It now reads it once: `doctor.checks` over the dummy app's
|
|
623
|
-
90 components drops from 1,337 ms to 55 ms, and an app with a few hundred
|
|
624
|
-
components no longer waits tens of seconds on `rake phlex_reactive:doctor`.
|
|
625
|
-
- **`on()` checks for removed keywords without building arrays.** Every
|
|
626
|
-
trigger ran `params.keys & REMOVED_ON_KWARGS.keys`. It now returns at once
|
|
627
|
-
when there are no extra keywords. `on(:increment)` allocates 5 objects
|
|
628
|
-
instead of 8; a 7-trigger component's `render_component` 118 instead of 136
|
|
629
|
-
(~5% faster in an in-place A/B, a method-level win that shows on broadcast
|
|
630
|
-
fan-out, not request latency).
|
|
631
|
-
- **The request bench measures production, not a file watcher.** The dummy
|
|
632
|
-
app's test env left code reloading on, so every request globbed `app/`:
|
|
633
|
-
`rake bench:request` read ~1.7-2.0 ms and 1,272 objects per state-backed
|
|
634
|
-
action, against ~0.30-0.35 ms and 824 objects with it off (same machine,
|
|
635
|
-
interleaved). Test-env only; the gem is unchanged.
|
|
716
|
+
### Added
|
|
717
|
+
|
|
718
|
+
- **`on_client(…, detail: { success: true })` — run ops only when the event's
|
|
719
|
+
payload qualifies (#346).** A binding can require top-level `event.detail`
|
|
720
|
+
keys to strictly equal JSON scalars, so `on_client("turbo:submit-end",
|
|
721
|
+
js.check_group("ids[]", false), detail: { success: true })` clears a bulk
|
|
722
|
+
selection after a successful submit and keeps it after a failed one. A
|
|
723
|
+
missing detail or key never matches; a miss runs no confirm and no
|
|
724
|
+
`preventDefault`. Each binding record carries its own filter, so two bindings
|
|
725
|
+
on one element can split success and failure. A non-scalar value, an empty
|
|
726
|
+
Hash, or `detail:` with `once:` raises at render. Records without `detail`
|
|
727
|
+
are byte-identical.
|
|
636
728
|
|
|
637
729
|
## [0.14.1] - 2026-10-07
|
|
638
730
|
|
|
@@ -664,7 +756,7 @@ changelog sections, so this section spans the whole run to 0.15.5.
|
|
|
664
756
|
|
|
665
757
|
## [0.14.0] - 2026-10-07
|
|
666
758
|
|
|
667
|
-
This section also covers 0.
|
|
759
|
+
This section also covers 0.9.1 through 0.13.4, which were released without
|
|
668
760
|
changelog sections of their own.
|
|
669
761
|
|
|
670
762
|
### Added
|
|
@@ -2424,7 +2516,7 @@ changelog sections of their own.
|
|
|
2424
2516
|
(`spec/requests/deferred_latency_spec.rb`) pins that a 120 ms segment cost
|
|
2425
2517
|
moves OFF the actor's reply and onto the deferred leg; it never disappears.
|
|
2426
2518
|
|
|
2427
|
-
## [0.9.0] - 2026-07-
|
|
2519
|
+
## [0.9.0] - 2026-07-04
|
|
2428
2520
|
|
|
2429
2521
|
Consolidates everything since 0.2.6; tags 0.2.7–0.4.8 shipped without changelog
|
|
2430
2522
|
sections, so this section spans the whole run to 0.9.0. The jump from 0.4.8 to
|
|
@@ -3860,7 +3952,7 @@ room for dogfood fixes.
|
|
|
3860
3952
|
Outer flat fields and per-row reactive editing compose cleanly, with no
|
|
3861
3953
|
name-disjointness workarounds.
|
|
3862
3954
|
|
|
3863
|
-
## [0.2.6]
|
|
3955
|
+
## [0.2.6] - 2026-06-25
|
|
3864
3956
|
|
|
3865
3957
|
### Added
|
|
3866
3958
|
|
|
@@ -3887,7 +3979,7 @@ room for dogfood fixes.
|
|
|
3887
3979
|
for non-remove/redirect responses, and never double-prepends when the action
|
|
3888
3980
|
already included a self-targeted stream.
|
|
3889
3981
|
|
|
3890
|
-
## [0.2.5]
|
|
3982
|
+
## [0.2.5] - 2026-06-25
|
|
3891
3983
|
|
|
3892
3984
|
### Fixed
|
|
3893
3985
|
|
|
@@ -3903,7 +3995,7 @@ room for dogfood fixes.
|
|
|
3903
3995
|
bun unit test (`spec/javascript/reactive_controller.test.js`) that asserts the
|
|
3904
3996
|
synchronous call and fails on the pre-fix deferred code. Closes #11.
|
|
3905
3997
|
|
|
3906
|
-
## [0.2.4] - 2026-06-
|
|
3998
|
+
## [0.2.4] - 2026-06-25
|
|
3907
3999
|
|
|
3908
4000
|
### Fixed
|
|
3909
4001
|
|
|
@@ -3921,6 +4013,12 @@ room for dogfood fixes.
|
|
|
3921
4013
|
byte-identical to source by a guard spec, so the browser tests never validate
|
|
3922
4014
|
stale client code again. Closes #8.
|
|
3923
4015
|
|
|
4016
|
+
### Documentation
|
|
4017
|
+
|
|
4018
|
+
- Documented the combined record + state identity (the `{c, gid, s}` token
|
|
4019
|
+
shape): the README, `docs/architecture.md`, and `docs/security.md` no longer
|
|
4020
|
+
frame `reactive_record` and `reactive_state` as mutually exclusive. (#9)
|
|
4021
|
+
|
|
3924
4022
|
## [0.2.3] - 2026-06-24
|
|
3925
4023
|
|
|
3926
4024
|
### Fixed
|
|
@@ -3940,12 +4038,6 @@ room for dogfood fixes.
|
|
|
3940
4038
|
round trip (only a genuinely absent value falls back to the `initialize`
|
|
3941
4039
|
default). No API changes. Closes #6.
|
|
3942
4040
|
|
|
3943
|
-
### Documentation
|
|
3944
|
-
|
|
3945
|
-
- Documented the combined record + state identity (the `{c, gid, s}` token
|
|
3946
|
-
shape): the README, `docs/architecture.md`, and `docs/security.md` no longer
|
|
3947
|
-
frame `reactive_record` and `reactive_state` as mutually exclusive. (#9)
|
|
3948
|
-
|
|
3949
4041
|
## [0.2.2] - 2026-06-24
|
|
3950
4042
|
|
|
3951
4043
|
### Fixed
|
|
@@ -4022,7 +4114,17 @@ room for dogfood fixes.
|
|
|
4022
4114
|
scaffolds a reactive component (and an RSpec spec when the app uses RSpec),
|
|
4023
4115
|
state-backed by default or record-backed with `--record`.
|
|
4024
4116
|
|
|
4025
|
-
[Unreleased]: https://github.com/mhenrixon/phlex-reactive/compare/v0.
|
|
4117
|
+
[Unreleased]: https://github.com/mhenrixon/phlex-reactive/compare/v0.16.0...HEAD
|
|
4118
|
+
[0.16.0]: https://github.com/mhenrixon/phlex-reactive/compare/v0.15.5...v0.16.0
|
|
4119
|
+
[0.15.5]: https://github.com/mhenrixon/phlex-reactive/compare/v0.15.4...v0.15.5
|
|
4120
|
+
[0.15.4]: https://github.com/mhenrixon/phlex-reactive/compare/v0.15.3...v0.15.4
|
|
4121
|
+
[0.15.3]: https://github.com/mhenrixon/phlex-reactive/compare/v0.15.2...v0.15.3
|
|
4122
|
+
[0.15.2]: https://github.com/mhenrixon/phlex-reactive/compare/v0.15.1...v0.15.2
|
|
4123
|
+
[0.15.1]: https://github.com/mhenrixon/phlex-reactive/compare/v0.15.0...v0.15.1
|
|
4124
|
+
[0.15.0]: https://github.com/mhenrixon/phlex-reactive/compare/v0.14.4...v0.15.0
|
|
4125
|
+
[0.14.4]: https://github.com/mhenrixon/phlex-reactive/compare/v0.14.3...v0.14.4
|
|
4126
|
+
[0.14.3]: https://github.com/mhenrixon/phlex-reactive/compare/v0.14.2...v0.14.3
|
|
4127
|
+
[0.14.2]: https://github.com/mhenrixon/phlex-reactive/compare/v0.14.1...v0.14.2
|
|
4026
4128
|
[0.14.1]: https://github.com/mhenrixon/phlex-reactive/compare/v0.14.0...v0.14.1
|
|
4027
4129
|
[0.14.0]: https://github.com/mhenrixon/phlex-reactive/compare/v0.9.0...v0.14.0
|
|
4028
4130
|
[0.9.0]: https://github.com/mhenrixon/phlex-reactive/compare/v0.2.6...v0.9.0
|
data/README.md
CHANGED
|
@@ -299,9 +299,10 @@ imports one the first time a root on the page needs it:
|
|
|
299
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 |
|
|
300
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 |
|
|
301
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 |
|
|
302
|
-
| `phlex/reactive/features/bindings` (`reactive_show`, show targets, `reactive_on_complete`, `reactive_filter`, `reactive_tags`, nested rows, the conditional `confirm:`) |
|
|
302
|
+
| `phlex/reactive/features/bindings` (`reactive_show`, show targets, `reactive_on_complete`, `reactive_filter`, `reactive_tags`, `reactive_css_vars`, nested rows, the conditional `confirm:`) | 8.2 KB | inside the one file | fetched when a root declares one of them, or holds a trigger with a conditional confirm |
|
|
303
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
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 |
|
|
305
|
+
| `phlex/reactive/features/drag` (`reactive_box`, `reactive_point` draggable regions) | 3.5 KB | inside the one file | fetched when a root is or holds a frame, a box or a point |
|
|
305
306
|
| `phlex/reactive/features/countdown` (`reactive_countdown`) | 1.0 KB | inside the one file | fetched when a root is or holds a countdown |
|
|
306
307
|
| `phlex/reactive/features/hints` (`optimistic:`, `busy:`) | 1.0 KB | inside the one file | fetched when a root holds a trigger that declares a hint |
|
|
307
308
|
| `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 |
|
|
@@ -693,6 +694,7 @@ Use in controllers: `render turbo_stream: Counter.replace(counter)`.
|
|
|
693
694
|
| `reactive_media(from:, pool:, take:, match:, when:, pending:, label:, blank_label:)` | A preview `<img>` that **follows a field** (#373): the field's value (its first `take:` characters, or all of it) is a key into `pool`, whose values are **selectors** for `<img>` elements the server rendered — never URLs. A hit copies that image's `src`/`srcset`/`alt` like `js.copy_media` (a pool `<img>` with an `alt` and no `src` is a hit without an image, #381); a miss clears the preview. `label:` gets the `alt` (or `blank_label`) as `textContent`. Below `take:` characters, or while `match:` (a JavaScript-compatible Regexp over the whole key) rejects the key, the binding is **pending** (#380) — never `blank_label` (Regexp options or Ruby-only syntax raise at render); `when:` (same rules) tests the **whole field** before the pool lookup while the key stays the prefix, so `take: 3, when: /\A\d{3,24}\z/` keeps `007bad` pending (#390); `pending: :clear` (default) clears the preview and label, `:keep` leaves them. The preview's `data-reactive-media-state` reads `pending`/`hit`/`miss`, so CSS can hide its row. See [Media previews](https://phlex-reactive.zoolutions.llc/docs/example-client-ops). |
|
|
694
695
|
| `reactive_image_fallback(hide: true, show_alt: nil, wrap: nil)` | An `<img>` that **falls back and recovers** (#383): on `error` it gets `data-reactive-image-state="error"`; `hide:` (default `true`) also hides it, and `show_alt:`, independently, writes its `alt` into that node and shows it; `wrap:` (#392) hides the box around it (marked `data-reactive-wrap-hidden`, shown again only when it carries that mark, its text never written); `load` sets `"loaded"`. A later `src` (from `copy_media`, `reactive_media`, `preview_file`, a morph, or any `src` that loads) shows it again; removing the `src` is not an error. Composes with `reactive_media(…, **reactive_image_fallback)`. See [Client-only ops](https://phlex-reactive.zoolutions.llc/docs/example-client-ops). |
|
|
695
696
|
| `reactive_countdown(until:, format: :ms, waiting:, done:)` | A server **deadline counted down** on the client (#400): `until:` is an ISO8601 date-time with a zone (or a `Time`); the text is the time left from `Date.now` (`1:30`), as `textContent`, once a second and again when a hidden tab is shown. At zero (or already past on connect) it reads `0:00`, `data-reactive-countdown-state="done"`, `waiting:` hides, `done:` shows and `reactive:countdown-done` fires **once per deadline** (a morph that keeps `until:` does not fire again; a new `until:` restarts). The server renders the first text and visibility. No request, no frame reload. See [Client-only ops](https://phlex-reactive.zoolutions.llc/docs/example-client-ops). |
|
|
697
|
+
| `reactive_css_vars(to:, vars:, global: false)` | Fields that **feed CSS custom properties** on an element (#417), for a preview that follows a control with no request: `vars: { "--light" => "light", "--turn" => { field: "turn", unit: "deg" } }`. Each field is read as `reactive_show` reads one (a checkbox gives `1`/`0`, a radio group its checked value) and parsed as a **finite number**; the client writes `number + unit` with `style.setProperty`, and anything else (empty, text) removes the property so the `var()` fallback applies. No text from a field reaches CSS. Units: `""`, `px`, `%`, `deg`, `turn`, `ms`, `s`, `em`, `rem`, `fr`; names match `--[a-zA-Z0-9-]+`; a bad name or unit or a blank field raises at render. Runs on connect, on `input`/`change` and after a morph. `to:` resolves like a client op's (`:root`, inside the root, `global: true`, an Array). Render the same properties inline for the first paint. See [Client-only ops](https://phlex-reactive.zoolutions.llc/docs/example-client-ops). |
|
|
696
698
|
| `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). |
|
|
697
699
|
| `reactive_select_all("ids[]")` / `reactive_count("ids[]")` | A checkbox **group**'s header box (ticks/unticks every owned box; shows checked / indeterminate / unchecked) and its ticked count as text — re-synced on every change and when rows are appended or removed. `under: "a.b"` makes a nested header over the leaves whose `data-reactive-group` is in that path. See [Bulk selection](#bulk-selection-reactive_select_all-reactive_count-reactive_enable). |
|
|
698
700
|
| `reactive_group_targets("ids[]", count: "#c", enable: { "#b" => 1.. })` | Spread on the root that **owns** a checkbox group: drives a count and an enable on outside, id-allowlisted elements (a bulk bar the page renders). `reactive_group_target_attrs` gives that outside markup its first paint. See [A bulk bar outside the list's root](#a-bulk-bar-outside-the-lists-root-reactive_group_targets). |
|
|
@@ -1900,6 +1902,87 @@ list; a reply that morphs the list puts the rows in the server's order.
|
|
|
1900
1902
|
|
|
1901
1903
|
Full guide: [Sortable lists](https://phlex-reactive.zoolutions.llc/docs/sortable-lists).
|
|
1902
1904
|
|
|
1905
|
+
### Draggable regions (`reactive_box`, `reactive_point`)
|
|
1906
|
+
|
|
1907
|
+
Move and resize a box, or move a point, over an image, a chart or a diagram —
|
|
1908
|
+
with a mouse, a pen, a finger or the keyboard — and its fields always hold
|
|
1909
|
+
where it is, as fractions of the frame (0..1 on each axis). A crop box, a
|
|
1910
|
+
focal point, a hotspot, a selection: no Stimulus controller of your own.
|
|
1911
|
+
|
|
1912
|
+
```ruby
|
|
1913
|
+
class CropPicker < Phlex::HTML
|
|
1914
|
+
include Phlex::Reactive::Component
|
|
1915
|
+
|
|
1916
|
+
reactive_record :photo
|
|
1917
|
+
action :crop, params: { x: :float, y: :float, width: :float, height: :float }
|
|
1918
|
+
|
|
1919
|
+
def crop(x:, y:, width:, height:)
|
|
1920
|
+
authorize! @photo, :update?
|
|
1921
|
+
# The schema casts; it does not range-check. A forged box outside the frame changes nothing.
|
|
1922
|
+
return reply.morph unless inside?(x, width) && inside?(y, height)
|
|
1923
|
+
|
|
1924
|
+
@photo.update!(crop: { x:, y:, width:, height: })
|
|
1925
|
+
reply.morph
|
|
1926
|
+
end
|
|
1927
|
+
|
|
1928
|
+
def view_template
|
|
1929
|
+
div(**reactive_root) do
|
|
1930
|
+
div(**mix(reactive_frame, class: "relative")) do
|
|
1931
|
+
img(src: @photo.url, alt: "", class: "block w-full")
|
|
1932
|
+
div(**reactive_box(fields: { x: "crop[x]", y: "crop[y]", width: "crop[width]", height: "crop[height]" },
|
|
1933
|
+
value: @photo.crop, aspect: "aspect", min: 0.05, action: :crop)) do
|
|
1934
|
+
%i[nw n ne e se s sw w].each { span(**reactive_box_handle(it)) }
|
|
1935
|
+
span(**reactive_point(fields: { x: "focus[x]", y: "focus[y]" }, value: @photo.focus,
|
|
1936
|
+
within: "[data-reactive-box]"))
|
|
1937
|
+
end
|
|
1938
|
+
end
|
|
1939
|
+
# the fields: hidden inputs named crop[x] … focus[y], and the aspect radios
|
|
1940
|
+
end
|
|
1941
|
+
end
|
|
1942
|
+
|
|
1943
|
+
private
|
|
1944
|
+
|
|
1945
|
+
# The fields round to 4 decimals, so x + width may reach 1.0001.
|
|
1946
|
+
def inside?(at, size) = at.between?(0, 1) && size.positive? && at + size <= 1.0001
|
|
1947
|
+
end
|
|
1948
|
+
```
|
|
1949
|
+
|
|
1950
|
+
The helpers render the box's `left`/`top`/`width`/`height` (and the point's
|
|
1951
|
+
`left`/`top`) as percentages from `value:`, so the first paint is right with
|
|
1952
|
+
no JavaScript and a re-render with new values moves them. The frame must be
|
|
1953
|
+
positioned (`relative`, or `absolute` for an overlay); its padding box is the
|
|
1954
|
+
coordinate space.
|
|
1955
|
+
|
|
1956
|
+
- **Pointer**: a press on the box moves it, a press on a handle resizes from
|
|
1957
|
+
that edge or corner; the box stays inside the frame and never goes below
|
|
1958
|
+
`min:`. A drag starts after a 4 px move; `touch-action: none` keeps a finger
|
|
1959
|
+
drag from scrolling the page.
|
|
1960
|
+
- **Fields**: written as `set_value` writes them (the live `.value`, rounded to
|
|
1961
|
+
4 decimals, a bubbling `input` at most once per field per frame), so
|
|
1962
|
+
`reactive_show`, compute reducers and other bindings follow the box. One
|
|
1963
|
+
`change` fires on release.
|
|
1964
|
+
- **`aspect:`**: a Numeric (`16/9r`) or the name of a field holding `"16:9"`, a
|
|
1965
|
+
decimal or `""` (free), read at drag start. When the field changes (a preset
|
|
1966
|
+
radio), the box re-fits to the new ratio around its centre.
|
|
1967
|
+
- **`action:`** (optional): sent once on release through the root's pipeline,
|
|
1968
|
+
with `x`, `y`, `width`, `height` (a point: `x`, `y`). Without it the binding
|
|
1969
|
+
is client-only and works on a `ClientBindings` root. The `:float` schema casts
|
|
1970
|
+
but does not range-check: refuse values outside 0..1, a `width` or `height`
|
|
1971
|
+
of 0, and `x + width` or `y + height` past 1.0001 (the fields' rounding)
|
|
1972
|
+
before you store them, since `reactive_box` refuses to render such a box.
|
|
1973
|
+
- **Points**: `reactive_point` moves within the frame, or — placed inside a box
|
|
1974
|
+
with `within:` — within that box, its fractions of the box.
|
|
1975
|
+
- **Keyboard**: the box and the point are focusable; arrows move 1% (Shift:
|
|
1976
|
+
10%), Alt+arrows resize from the bottom-right corner or the focused handle,
|
|
1977
|
+
Escape puts it back. Each step is announced through a live region; translate
|
|
1978
|
+
the words with `messages:` (`{x}`, `{y}`, `{width}`, `{height}` as whole
|
|
1979
|
+
percents). Letting go of the key is the release.
|
|
1980
|
+
- **Styling**: `data-reactive-frame-active` (the frame) and
|
|
1981
|
+
`data-reactive-dragging` (the pressed box, handle or point). A morph that
|
|
1982
|
+
lands mid-drag does not interrupt it.
|
|
1983
|
+
|
|
1984
|
+
Full guide: [Draggable regions](https://phlex-reactive.zoolutions.llc/docs/draggable-regions).
|
|
1985
|
+
|
|
1903
1986
|
### Presence (`reactive_presence`)
|
|
1904
1987
|
|
|
1905
1988
|
Who is viewing a stream, one entry per browser tab, each tab with a small
|
|
@@ -2857,6 +2940,7 @@ def update(quantity:, price:) = (@item.update!(quantity:, price:); reply.streams
|
|
|
2857
2940
|
| `.also(target => content, …)` | **UPDATE** (inner HTML) companion elements by DOM id; `content` is a plain string (escaped) or a Phlex component. The argument *type* picks the action — pairs mean `update` |
|
|
2858
2941
|
| `.also(component, morph: false)` | **REPLACE** another Streamable component at its own `#id` (`morph: true` morphs in place). A component argument means `replace` |
|
|
2859
2942
|
| `.flash(level, content, target: …)` | append a flash; `content` is a plain string (escaped, wrapped in a level-carrying `<div>` — see [Flash levels](#flash-levels)) or a Phlex component (rendered verbatim; off-request — no Rails `flash`); target defaults to `Phlex::Reactive.flash_target` (`"flash"`) |
|
|
2943
|
+
| `.refused(*names)` | the reply **answers the posted values without storing them**: the client keeps the fields a morph held (`data-reactive-held`, #370), so the root's next action names them in `reactive_held_fields` again instead of saving over the other change. No names keeps every mark; names (the fields' `name` attributes) keep only those. Chain it on a morph (`reply.morph.refused`): a plain `replace` swaps the marked field out. Still a 200: no error marker, no `reactive:error` (#413) |
|
|
2860
2944
|
| `reply.remove` | remove the element (backed by `Streamable#to_stream_remove`) |
|
|
2861
2945
|
| `reply.redirect(url)` | client-side `Turbo.visit` (pass a `*_url`); rides a `reactive:visit` turbo-stream, not an HTTP 3xx |
|
|
2862
2946
|
| `reply.streams(*streams)` | **partial update** — emit exactly these streams (no full-self replace) + a tiny token-only refresh, so live inputs survive; for per-field grid editing (issue #30) |
|