phlex-reactive 0.15.2 → 0.15.4
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 +269 -2
- data/README.md +92 -13
- data/app/javascript/phlex/reactive/core.js +1 -0
- data/app/javascript/phlex/reactive/core.min.js +6 -6
- data/app/javascript/phlex/reactive/core.min.js.map +4 -4
- data/app/javascript/phlex/reactive/features/bindings.js +153 -21
- 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/countdown.js +140 -0
- data/app/javascript/phlex/reactive/features/countdown.min.js +4 -0
- data/app/javascript/phlex/reactive/features/countdown.min.js.map +10 -0
- data/app/javascript/phlex/reactive/features/form.js +54 -22
- 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 +41 -8
- 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/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 +9 -8
- data/app/javascript/phlex/reactive/runtime.js +313 -32
- data/lib/phlex/reactive/component/countdown_binding.rb +80 -0
- data/lib/phlex/reactive/component/field_references.rb +130 -0
- data/lib/phlex/reactive/component/helpers.rb +81 -19
- data/lib/phlex/reactive/component/identity.rb +34 -1
- data/lib/phlex/reactive/component/image_fallback_binding.rb +67 -0
- data/lib/phlex/reactive/component/media_binding.rb +30 -4
- data/lib/phlex/reactive/component/media_pattern.rb +79 -0
- data/lib/phlex/reactive/component/selection.rb +24 -2
- data/lib/phlex/reactive/component/show_options.rb +91 -0
- data/lib/phlex/reactive/doctor.rb +20 -8
- data/lib/phlex/reactive/engine.rb +2 -2
- data/lib/phlex/reactive/js.rb +58 -5
- data/lib/phlex/reactive/show_conditions/compare_terms.rb +142 -0
- data/lib/phlex/reactive/show_conditions.rb +39 -6
- data/lib/phlex/reactive/streamable.rb +6 -3
- data/lib/phlex/reactive/version.rb +1 -1
- metadata +10 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bd638899f7bcd09bffdd26692f13bf1b8e22ec2e3ceadf614e337edd03459ed5
|
|
4
|
+
data.tar.gz: fefc53fbdf9f3e36405a2665c23fff709230405e944107e0d0ed946ee31de392
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d9bbd2c39abf405afd5e68ce8ad231a62c75a9af578db96315bb716184107846a5d2fe031a58f968a2bf62fa25b9aeb9f82937276f8170324a9e43c1cc1f853d
|
|
7
|
+
data.tar.gz: 4981aaaa21ceba8bef392dae7d7beaf433ed8a0ac81de38931198a8ef1e0a3e75ed243592aefbf27965f81b593d2a3398404a2486759a3da073466a2928a04f9
|
data/CHANGELOG.md
CHANGED
|
@@ -8,12 +8,200 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
8
8
|
|
|
9
9
|
### Added
|
|
10
10
|
|
|
11
|
+
- **A server deadline counted down on the client — `reactive_countdown(until:, format: :ms, waiting:, done:)` (#400).**
|
|
12
|
+
`span(**reactive_countdown(until: deadline.iso8601, waiting: "#wait", done: "#done")) { "2:00" }`
|
|
13
|
+
puts the deadline on the element; the client paints the time left from
|
|
14
|
+
`Date.now` at connect and on each second boundary (unpadded minutes, a colon,
|
|
15
|
+
padded seconds; `textContent` only), and re-reads it when a hidden tab is
|
|
16
|
+
shown, so a woken tab jumps to the real value. At zero, or with `until:`
|
|
17
|
+
already past on connect (no timer starts), the text is `0:00`,
|
|
18
|
+
`data-reactive-countdown-state` is `done`, `waiting:` hides, `done:` shows and
|
|
19
|
+
the element dispatches `reactive:countdown-done` once per deadline: a morph
|
|
20
|
+
or replace that keeps `until:` repaints the done state without a second
|
|
21
|
+
event, one that changes it counts down again ("once" is remembered per
|
|
22
|
+
root, countdown id or position, and deadline for the life of the document). Timers are cleared on
|
|
23
|
+
disconnect. No request and no frame reload: the event is the hook. The server
|
|
24
|
+
renders the initial text and visibility. `format:` other than `:ms`, an
|
|
25
|
+
`until:` that is not an ISO8601 date-time with a zone (or a `Time`), and a
|
|
26
|
+
`waiting:`/`done:` that is not a non-blank selector (or is `@root`) raise at
|
|
27
|
+
render. Client: a new `countdown` feature module (966 B gzipped); +653 B in
|
|
28
|
+
the default bundle, +31 B in the split core. Docs: "Counting down to a server
|
|
29
|
+
deadline" on the client-ops example.
|
|
30
|
+
|
|
31
|
+
- **`js.blur(to, global: false)` (#399).** Blurs `document.activeElement` when
|
|
32
|
+
it is the target or sits inside it, so
|
|
33
|
+
`on_client("keydown.esc", js.blur("#menu"))` ends a menu's `:focus-within`;
|
|
34
|
+
focus elsewhere is left alone, and a missing target is skipped (warned
|
|
35
|
+
under `verbose_errors`/debug). Actor-only like `focus`: `broadcast_to(js:)`
|
|
36
|
+
refuses it. Client: +41 B gzipped in the default bundle, +39 B in the
|
|
37
|
+
split core.
|
|
38
|
+
|
|
39
|
+
- **`on_client` takes `debounce:` and `throttle:` like `on` (#393).**
|
|
40
|
+
`input(**on_client(:input, js.submit("#search"), debounce: 300))` runs the
|
|
41
|
+
ops once, 300 ms after the last `input` of a burst; `throttle: 250` runs on
|
|
42
|
+
the first event and drops the rest until 250 ms have passed. Non-negative
|
|
43
|
+
Integer milliseconds; both at once raise at render with `on`'s message, and
|
|
44
|
+
a negative or non-Integer value raises too. The wait rides on the binding
|
|
45
|
+
record, so mixed bindings keep their own, and the client runs it on the
|
|
46
|
+
timers `on` uses (a blur flushes a pending debounce, except on a
|
|
47
|
+
window-bound binding; disconnect clears it). `once:` still means one run,
|
|
48
|
+
after the wait. `on` is unchanged. Client: +150 B gzipped in the default
|
|
49
|
+
bundle, +127 B in the split core. Docs: "Waiting: debounce: and throttle:"
|
|
50
|
+
on the client-ops example.
|
|
51
|
+
|
|
52
|
+
- **`reactive_image_fallback(wrap:)` hides the box around a broken image
|
|
53
|
+
(#392).** `img(**reactive_image_fallback(hide: false, wrap: "#badge"))` hides
|
|
54
|
+
`#badge` (a selector inside the root, like `show_alt:`) on `error` and marks
|
|
55
|
+
the hide `data-reactive-wrap-hidden`, the mark `preview_file`'s `wrap:` uses.
|
|
56
|
+
Recovery (any later `src` that loads, `copy_media`, `reactive_media`,
|
|
57
|
+
`preview_file`) shows it again only when it carries that mark, so a wrapper
|
|
58
|
+
the author hid stays hidden; a morph that keeps the broken `src` hides it
|
|
59
|
+
again. The wrapper may hold the `img`: its text is never written. `hide:
|
|
60
|
+
true` without `wrap:` still hides only the `img`. A `wrap:` that matches
|
|
61
|
+
nothing warns and is skipped (the `img` fallback still runs); a `wrap:` that
|
|
62
|
+
is not a non-blank String, or is `@root`, raises at render. Client: +45 B
|
|
63
|
+
gzipped in the default bundle, +51 B in the split core.
|
|
64
|
+
|
|
65
|
+
- **`js.preview_file` hides its frame and refuses a file by size or type —
|
|
66
|
+
`wrap:`, `max_bytes:`, `accept:` (#391).** `wrap: "#photo-frame"` shows that
|
|
67
|
+
element when an image is painted and hides it when the preview clears. The
|
|
68
|
+
hide is marked `data-reactive-wrap-hidden`; only a wrapper carrying the mark
|
|
69
|
+
is shown again, so a wrapper the author hid stays hidden (render the mark
|
|
70
|
+
with `hidden` to start hidden). The wrapper's text is never written. A
|
|
71
|
+
`wrap:` that is not on the page warns and is skipped. `max_bytes:` (a
|
|
72
|
+
positive Integer) refuses a larger file before `URL.createObjectURL` runs;
|
|
73
|
+
`accept: true` reads the input's `accept` attribute (extensions, MIME types,
|
|
74
|
+
`type/*`), a String is the list for this op, and omitted keeps the `image/*`
|
|
75
|
+
rule. Only an image is painted: a listed non-image (a PDF) clears the preview
|
|
76
|
+
and hides `wrap:` without a rejection. A refused file clears the `img` and the name, hides `wrap:`, and
|
|
77
|
+
dispatches `reactive:file-rejected` (bubbling) on the input with
|
|
78
|
+
`detail.reason` `"type"` or `"size"` and `detail.file`. A non-image file
|
|
79
|
+
under today's rule now dispatches it too. The op never clears `input.files`
|
|
80
|
+
and never calls `setCustomValidity`. The op still carries no URL, and
|
|
81
|
+
`broadcast_to(js:)` still refuses it. Client: +351 B gzipped in the default
|
|
82
|
+
bundle, +336 B in the split core.
|
|
83
|
+
|
|
84
|
+
- **Clear, focus and scroll when `reactive_show` changes visibility — `clear:`, `focus:`, `scroll:` (#394).**
|
|
85
|
+
`div(**reactive_show(if: { kind: "other" }, clear: :notes, focus: "#notes", scroll: true))`.
|
|
86
|
+
When the section goes from visible to hidden, each `clear:` field (one
|
|
87
|
+
owned, scope-aware name or an Array) gets `js.set_value`'s write — `.value`
|
|
88
|
+
becomes `""` and one bubbling `input` is dispatched per field that changed;
|
|
89
|
+
a checkbox or radio is unchecked. (A file input is rejected at render.) When it goes from
|
|
90
|
+
hidden to visible, the first element inside the root matching `focus:` is
|
|
91
|
+
focused and `scroll: true` calls `scrollIntoView` on the section. None of it
|
|
92
|
+
runs on first paint, so a value the server rendered into a hidden section
|
|
93
|
+
stays. The previous visibility is kept on the root's controller (absent on
|
|
94
|
+
connect, kept by a morph — so a `morph: true` reply that flips the predicate
|
|
95
|
+
runs it, while a plain replace reconnects the root and counts as first
|
|
96
|
+
paint). The work runs after the show pass, so the `input`
|
|
97
|
+
a clear dispatches re-syncs from fresh values. A `clear:` name the render
|
|
98
|
+
never produced, or one that names a file input, raises at render; so does a
|
|
99
|
+
`focus:` that is not a non-empty String or a `scroll:` that is not a
|
|
100
|
+
boolean. Without these keywords, a binding behaves as before (`hidden`, and
|
|
101
|
+
`disable:` if given). The
|
|
102
|
+
feature handle gains `setValue` (the runtime's `set_value` write). Client:
|
|
103
|
+
+282 B gzipped in the default bundle, +10 B in the split core.
|
|
104
|
+
|
|
105
|
+
- **A digit format, and a hint that waits for blur — `{ digits: 10, starts_with: "9" }`, `after: :blur` (#396).**
|
|
106
|
+
`digits:` (a non-negative Integer or an Integer Range, counted in
|
|
107
|
+
codepoints) holds while every character is ASCII `0-9` and the count fits;
|
|
108
|
+
`starts_with:` adds a prefix that must itself be a String of digits, so the
|
|
109
|
+
term cannot become a pattern. One wire term carries `digits_eq` (or
|
|
110
|
+
`digits_gte`/`digits_lte`/`digits_lt`) plus `prefix`, and `"negate" => true`
|
|
111
|
+
under `unless:`. `reactive_show(…, after: :blur)` keeps the section hidden
|
|
112
|
+
until the condition's one field has fired `blur` once since connect
|
|
113
|
+
(`after: :code` names it when the condition has several); after that, every
|
|
114
|
+
`input` updates it at once. The touched flag lives on the root's controller:
|
|
115
|
+
a morph keeps it, a full navigation drops it. First paint is hidden unless an
|
|
116
|
+
explicit `hidden:` says otherwise. A
|
|
117
|
+
`starts_with:` that is not a String of digits, a malformed `digits:`, an
|
|
118
|
+
`after:` that is not a field of the condition, or one the render never
|
|
119
|
+
produced, raises at render. Client: +232 B gzipped in the default bundle.
|
|
120
|
+
Docs: "A digit format, and only after blur" on the client-ops example.
|
|
121
|
+
|
|
122
|
+
- **Compare two fields in a show condition — `{ same: :email, case: :fold }` (#395).**
|
|
123
|
+
`button(**reactive_enable(if: { confirm_email: { same: :email, case: :fold } }))`
|
|
124
|
+
enables the button only while the two values are equal (an empty pair is
|
|
125
|
+
equal); `reactive_show(unless: { … })` shows a hint while they differ. Both
|
|
126
|
+
sides are read as strings; `case: :fold` lowercases ASCII `A-Z` only, with
|
|
127
|
+
the same explicit replace in Ruby and the client, so a non-ASCII case
|
|
128
|
+
difference stays unequal. The wire term is
|
|
129
|
+
`{ "field", "same", "fold"? }`, plus `"negate" => true` under `unless:`. The
|
|
130
|
+
`same:` name is scope-aware and must name a field the render produces: the
|
|
131
|
+
check runs when the outermost Phlex render finishes and raises
|
|
132
|
+
`ArgumentError` for an unknown name. A `case:` other than `:fold` raises at
|
|
133
|
+
render. First paint needs both fields in `reactive_values`. Works in
|
|
134
|
+
`reactive_show`, `reactive_enable`, the `"#id" => { if: … }` form of
|
|
135
|
+
`reactive_show_targets`, `reactive_on_complete` and a conditional
|
|
136
|
+
`confirm:` (only the first two check the name at render). Client: +103 B gzipped
|
|
137
|
+
in the default bundle. Docs: "Compare two fields" on the client-ops example.
|
|
138
|
+
|
|
139
|
+
- **Nested checkbox groups — `reactive_select_all(group, under: "a.b")` (#398).**
|
|
140
|
+
A header with `under: "library"` owns the boxes of the name whose
|
|
141
|
+
`data-reactive-group` is `library` or below it (`library.books`; whole
|
|
142
|
+
segments, so never `libraryx`). It ticks or unticks them, dispatching
|
|
143
|
+
`input` + `change` on each one it flips, and is checked / indeterminate /
|
|
144
|
+
unchecked from them, so a parent follows its nested headers. Every leaf still
|
|
145
|
+
posts under the one name; headers have no `name` and never post;
|
|
146
|
+
`reactive_count` and `reactive_enable` keep counting the leaves. A nested
|
|
147
|
+
reactive root's boxes stay that root's own (#15). `reactive_select_all`
|
|
148
|
+
without `under:` renders the same attrs as before and still flips every box
|
|
149
|
+
of the name. A blank `under:`, or one with an empty or space-padded segment, raises at
|
|
150
|
+
render. Client: +76 B gzipped in the default bundle (+66 B in
|
|
151
|
+
`features/bindings`, the split core unchanged). Docs: "Bulk selection" on
|
|
152
|
+
the client-ops example; demo at `/nested_selection` in the dummy app.
|
|
153
|
+
|
|
154
|
+
- **`reactive_media(when:)` tests the whole field (#390).** `when:` is a
|
|
155
|
+
Regexp the client tests against the whole field value before the pool
|
|
156
|
+
lookup, while the pool key stays the `take:` prefix: with `take: 3,
|
|
157
|
+
when: /\A\d{3,24}\z/`, `007bad` is pending (no pool lookup, never
|
|
158
|
+
`blank_label`: the default clears the preview and label, `pending: :keep`
|
|
159
|
+
leaves them as they are; either way `data-reactive-media-state="pending"`) and
|
|
160
|
+
`007123` hits `"007"`. It compiles with the same rules as `match:` (a
|
|
161
|
+
refused regexp raises at render, naming `when:`). `match:` still tests the
|
|
162
|
+
key only; omit `when:` and nothing changes. Client: +25 B gzipped default
|
|
163
|
+
bundle, +22 B bindings module, core unchanged.
|
|
164
|
+
|
|
165
|
+
- **A tab-only draft — `reactive_persist(storage: :session)` (#397).** The
|
|
166
|
+
draft lives in `sessionStorage` instead of `localStorage`: a reload in the
|
|
167
|
+
same tab restores it, another tab never sees it (a tab opened from it with
|
|
168
|
+
an opener, or duplicated, starts with the browser's one-time copy),
|
|
169
|
+
closing the tab drops it.
|
|
170
|
+
Same key, same skipped controls, same `restore:`, and every read, write,
|
|
171
|
+
`ttl` expiry, successful-submit clear and `persist_state`/`persist_clear`
|
|
172
|
+
op goes to that store only — a `:local` draft is never restored by
|
|
173
|
+
`:session`, and a missing `sessionStorage` is "no draft", never a fallback
|
|
174
|
+
to `localStorage`. `storage: :local` (the default) keeps today's behavior
|
|
175
|
+
and today's wire byte-for-byte; `"local"`/`"session"` strings are accepted,
|
|
176
|
+
anything else raises at render. Client: +27 B gzipped in the default
|
|
177
|
+
bundle, +20 B in the persist module; the split core unchanged.
|
|
178
|
+
|
|
179
|
+
- **An image fallback that recovers — `reactive_image_fallback(hide: true, show_alt: nil)` (#383).**
|
|
180
|
+
`img(**reactive_image_fallback)` marks an `<img>` whose `src` fails with
|
|
181
|
+
`data-reactive-image-state="error"` and hides it; `show_alt: "#bank-name"`
|
|
182
|
+
also writes its `alt` (as `textContent`) into that node inside the root and
|
|
183
|
+
shows it; `hide: false` only sets the state, for CSS. `load` sets
|
|
184
|
+
`"loaded"`. A later `src` tries again: `copy_media`, `reactive_media` and
|
|
185
|
+
`preview_file` clear the error as they write the `src`, any `src` that loads
|
|
186
|
+
clears it, and a morph that changes the `src` lets the browser retry (one
|
|
187
|
+
that keeps it keeps the state). Removing the `src` is not an error. Only the
|
|
188
|
+
`hidden` the fallback added is ever removed. Composes with `reactive_media`
|
|
189
|
+
(`reactive_media(…, **reactive_image_fallback)`). The client listens once on
|
|
190
|
+
the document in the capture phase (`error`/`load` do not bubble) and reads
|
|
191
|
+
images that failed before it registered; nothing runs per root at connect.
|
|
192
|
+
No URL in any argument; `set_attr` still refuses `src`/`srcset`. `hide:`
|
|
193
|
+
that is not a boolean, or a `show_alt:` that is not a non-blank String (or
|
|
194
|
+
is `@root`), raises at render; a String that is not a valid CSS selector is
|
|
195
|
+
ignored by the client. Client: +597 B gzipped in the default bundle,
|
|
196
|
+
+550 B in the split core. Docs: "An image that falls back, and recovers" on
|
|
197
|
+
the client-ops example.
|
|
198
|
+
|
|
11
199
|
- **Media previews — `js.copy_media` and `reactive_media` (#373).**
|
|
12
200
|
`js.copy_media("#logo", from: "#logo-007")` copies `src`, `srcset` and `alt`
|
|
13
201
|
from an `<img>` the server rendered onto a preview `<img>`; the URL is read
|
|
14
202
|
from the live source element and is never an op argument, so `set_attr`
|
|
15
|
-
still refuses `src` (#96). A missing source
|
|
16
|
-
without `src`
|
|
203
|
+
still refuses `src` (#96). A missing source or a non-`<img>` source clears
|
|
204
|
+
the target (an `<img>` source without `src` keeps its `alt`, #381); a target that is not an `<img>` (an
|
|
17
205
|
`iframe`, `script`, `object`, `embed`, `frame`) is warned about and skipped;
|
|
18
206
|
the same URL twice is not written again. `img(**reactive_media(from: :code,
|
|
19
207
|
take: 3, pool: { "007" => "#logo-007" }, label: "#caption", blank_label:
|
|
@@ -215,6 +403,48 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
215
403
|
|
|
216
404
|
### Fixed
|
|
217
405
|
|
|
406
|
+
- **`reactive_media` waits for `take` characters before calling a key a miss
|
|
407
|
+
(#380).** A value shorter than `take:` sliced to a short key, missed the
|
|
408
|
+
pool and wrote `blank_label` while the user was still typing. It is now
|
|
409
|
+
*pending*: `blank_label` only names a full-length key that misses. New
|
|
410
|
+
options: `match:` (a Regexp, anchored to the whole key; a key it rejects is
|
|
411
|
+
pending too) and `pending: :clear` (default: clear the preview, empty the
|
|
412
|
+
label) or `:keep` (leave both alone). The preview carries
|
|
413
|
+
`data-reactive-media-state="pending|hit|miss"`, so a row can hide itself
|
|
414
|
+
with CSS. `match:` reaches the browser as a JS pattern: its `\A`/`\z`
|
|
415
|
+
anchors are dropped (a trailing `\Z` becomes `\n?`), and what JS reads
|
|
416
|
+
differently raises at render (options, escapes such as `\h`/`\p{…}`/`\s`/
|
|
417
|
+
`\u{…}`, POSIX and nested classes, `&&`, `(?i)`/`(?>…)`, characters above
|
|
418
|
+
U+FFFF). **Behaviour change:** with `take:`, a short value now leaves the
|
|
419
|
+
label empty instead of writing `blank_label`. Without `take:` or `match:`
|
|
420
|
+
nothing changes. Client: +160 B gzipped in the default bundle (+152 B on
|
|
421
|
+
top of #381/#382), +152 B in the bindings module, the split core unchanged. Docs: "Media
|
|
422
|
+
previews" on the client-ops example.
|
|
423
|
+
|
|
424
|
+
- **A pool `<img>` with an `alt` and no `src` keeps its name (#381).**
|
|
425
|
+
`js.copy_media` and `reactive_media` treated a source `<img>` without `src`
|
|
426
|
+
as a clear: the preview's `alt` was emptied and `label:` got `blank_label`,
|
|
427
|
+
so a known entry with no logo file lost its name. Such a source is now a hit
|
|
428
|
+
without an image: the target's `src` and `srcset` are removed, its `alt` is
|
|
429
|
+
set, and `label:` gets that `alt`. Only a pool miss, a selector that matches
|
|
430
|
+
nothing, or a source that is not an `<img>` clears. Client: +1 B gzipped in
|
|
431
|
+
the default bundle, −2 B in the split core; the bindings module is
|
|
432
|
+
unchanged.
|
|
433
|
+
|
|
434
|
+
- **`js.preview_file` follows an app's upload validation (#382).** A preview
|
|
435
|
+
ran once, on `change`, so a file the app then rejected (cleared with
|
|
436
|
+
`input.value = ""`, no new `change`) stayed on screen. Two pieces fix it.
|
|
437
|
+
`js.preview_file(…, valid: true)` treats an input whose `validity.valid` is
|
|
438
|
+
false (`setCustomValidity`, `required`) as empty: it never gets a preview,
|
|
439
|
+
and one it had is cleared and its URL revoked. And `reactive:file-changed`
|
|
440
|
+
is the documented event for "the app changed this input's files without a
|
|
441
|
+
`change` event": bind the op to it as well,
|
|
442
|
+
`mix(on_client(:change, op), on_client("reactive:file-changed", op))`, and
|
|
443
|
+
the app dispatches it after it clears or invalidates the field. That works
|
|
444
|
+
whichever `change` listener runs first. Without `valid:` the op behaves as
|
|
445
|
+
before. Client: +22 B gzipped in the default bundle, +34 B in the split
|
|
446
|
+
core (on top of #381). Docs: "Preview a chosen image" on the client-ops example.
|
|
447
|
+
|
|
218
448
|
- **A stream that renders into a connected root wires what its content needs
|
|
219
449
|
(#356).** A root read its feature markers only at connect and after a morph
|
|
220
450
|
of itself, so a `reactive_show` target, select-all/count group, filter,
|
|
@@ -251,6 +481,43 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
251
481
|
first paint already were. A target with any value term keeps the skip when
|
|
252
482
|
none of its fields is owned.
|
|
253
483
|
|
|
484
|
+
### Performance
|
|
485
|
+
|
|
486
|
+
- **Guarded and persisted forms connect in linear time.** Each `warn_unsaved`
|
|
487
|
+
root added its own `beforeunload` / `turbo:before-visit` pair on `window`,
|
|
488
|
+
and each persisted root its own `turbo:submit-end` listener on `document`.
|
|
489
|
+
A browser scans a target's listeners on every add and remove, so connecting
|
|
490
|
+
and disconnecting N of them was quadratic. One shared listener per window or
|
|
491
|
+
document now serves them all, added with the first root and removed with
|
|
492
|
+
the last; behaviour is unchanged (live dirty count, one prompt per visit,
|
|
493
|
+
#298). Headless Chromium, insert + connect: 4,000 guarded roots ~306 ->
|
|
494
|
+
~227 ms, 4,000 persisted roots ~280 -> ~230 ms. +194 B gzipped in the
|
|
495
|
+
default bundle (110 B for the guard, 84 B for persist).
|
|
496
|
+
- **A record-backed token no longer builds a GlobalID URI.** For a record
|
|
497
|
+
whose `to_gid` is GlobalID's own and whose id is an Integer, the gid is
|
|
498
|
+
built directly as `gid://<app>/<Class>/<id>` (byte-identical, pinned by
|
|
499
|
+
`identity_gid_spec.rb`); anything else still calls `to_gid`. The token goes
|
|
500
|
+
from ~11.4 µs and 54 objects to ~5.2 µs and 20 (in-place A/B).
|
|
501
|
+
- **`rake bench:browser`.** Connect, disconnect and a heap soak for the
|
|
502
|
+
shipped client in headless Chromium. The happy-dom connect bench is
|
|
503
|
+
engine-relative and missed the quadratic above; this is the one to trust.
|
|
504
|
+
- **The doctor's authorization check is linear, not quadratic.** It rebuilt
|
|
505
|
+
the Inspector's whole component list (a Prism scan per action) once for
|
|
506
|
+
every component. It now reads it once: `doctor.checks` over the dummy app's
|
|
507
|
+
90 components drops from 1,337 ms to 55 ms, and an app with a few hundred
|
|
508
|
+
components no longer waits tens of seconds on `rake phlex_reactive:doctor`.
|
|
509
|
+
- **`on()` checks for removed keywords without building arrays.** Every
|
|
510
|
+
trigger ran `params.keys & REMOVED_ON_KWARGS.keys`. It now returns at once
|
|
511
|
+
when there are no extra keywords. `on(:increment)` allocates 5 objects
|
|
512
|
+
instead of 8; a 7-trigger component's `render_component` 118 instead of 136
|
|
513
|
+
(~5% faster in an in-place A/B, a method-level win that shows on broadcast
|
|
514
|
+
fan-out, not request latency).
|
|
515
|
+
- **The request bench measures production, not a file watcher.** The dummy
|
|
516
|
+
app's test env left code reloading on, so every request globbed `app/`:
|
|
517
|
+
`rake bench:request` read ~1.7-2.0 ms and 1,272 objects per state-backed
|
|
518
|
+
action, against ~0.30-0.35 ms and 824 objects with it off (same machine,
|
|
519
|
+
interleaved). Test-env only; the gem is unchanged.
|
|
520
|
+
|
|
254
521
|
## [0.14.1] - 2026-10-07
|
|
255
522
|
|
|
256
523
|
### Added
|
data/README.md
CHANGED
|
@@ -302,6 +302,7 @@ imports one the first time a root on the page needs it:
|
|
|
302
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 |
|
|
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/countdown` (`reactive_countdown`) | 1.0 KB | inside the one file | fetched when a root is or holds a countdown |
|
|
305
306
|
| `phlex/reactive/features/hints` (`optimistic:`, `busy:`) | 1.0 KB | inside the one file | fetched when a root holds a trigger that declares a hint |
|
|
306
307
|
| `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 |
|
|
307
308
|
| `phlex/reactive/early` | 1.2 KB | on every page, if you import it | the same |
|
|
@@ -683,18 +684,20 @@ Use in controllers: `render turbo_stream: Counter.replace(counter)`.
|
|
|
683
684
|
| `on(:save, loading: { disable: true, class: "opacity-50", text: "…" })` | Full loading form: `disable:`, a loading `class:` (on the trigger or a `to:` target), a `text:` swap. Reverts on settle. |
|
|
684
685
|
| `busy_on(:save)` | Mark any element so it carries `data-reactive-busy` **only while `save` is in flight** — a spinner styled with pure CSS, zero Ruby. See [Loading states](#declarative-loading-states-loading--disable_with). |
|
|
685
686
|
| `on(:action, once: true)` | Fire at most once, then unbind (Stimulus's native `:once`). |
|
|
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
|
|
687
|
-
| `js` | The immutable op builder behind `on_client`: `show`/`hide`/`toggle` (the `hidden` attribute, with an optional `transition:` and `expanded:`), `add_class`/`remove_class`/`toggle_class`, `set_attr`/`remove_attr`/`toggle_attr` (allowlisted names; `toggle_attr` also flips between two values), `focus`/`focus_first`, `show_modal`/`close_dialog` (a real modal `<dialog>`), `copy_media` (copy `src`/`srcset`/`alt` from a server-rendered `<img>`, no URL argument), `text` (set `textContent` — XSS-safe), `set_value` (set a field's live `.value` and dispatch `input`), `dispatch`, `submit` (requestSubmit the target's own form, optionally through a `submitter:`), `paste_into` (read the clipboard into a field, gesture-gated), `preview_file` (paint a chosen image file onto an `img` through an object URL the client makes and revokes), `check_group` (tick or untick a checkbox group, then re-sync its bindings), and `persist_state`/`persist_clear` (the `reactive_persist` draft) — chainable. `keep: true` on the visibility, class and attr ops makes their result survive a morph. |
|
|
687
|
+
| `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 and `debounce:`/`throttle:` waits (ms) as `on`, plus `detail:` (run only when `event.detail` carries the listed values). See [Client-only ops](#client-only-ops-on_client--js--zero-round-trips). |
|
|
688
|
+
| `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`/`blur`, `show_modal`/`close_dialog` (a real modal `<dialog>`), `copy_media` (copy `src`/`srcset`/`alt` from a server-rendered `<img>`, no URL argument), `text` (set `textContent` — XSS-safe), `set_value` (set a field's live `.value` and dispatch `input`), `dispatch`, `submit` (requestSubmit the target's own form, optionally through a `submitter:`), `paste_into` (read the clipboard into a field, gesture-gated), `preview_file` (paint a chosen image file onto an `img` through an object URL the client makes and revokes), `check_group` (tick or untick a checkbox group, then re-sync its bindings), and `persist_state`/`persist_clear` (the `reactive_persist` draft) — chainable. `keep: true` on the visibility, class and attr ops makes their result survive a morph. |
|
|
688
689
|
| `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
690
|
| `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. |
|
|
690
691
|
| `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). |
|
|
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). |
|
|
692
|
-
| `reactive_media(from:, pool:, take:, 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
|
|
692
|
+
| `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; `{ same: :email }` compares two fields, `{ digits: 10 }` checks a digit format, `after: :blur` waits for the field's first blur, and `clear:`/`focus:`/`scroll:` run when the section changes visibility. See [Value-conditional visibility](#value-conditional-visibility-reactive_show). |
|
|
693
|
+
| `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
|
+
| `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
|
+
| `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). |
|
|
693
696
|
| `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). |
|
|
694
|
-
| `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. See [Bulk selection](#bulk-selection-reactive_select_all-reactive_count-reactive_enable). |
|
|
697
|
+
| `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). |
|
|
695
698
|
| `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). |
|
|
696
699
|
| `reactive_show_targets(:field, "#id" => value)` | **Cross-root visibility**: the component that owns the field declares which **outside**, id-allowlisted elements it governs (a nav tab, a panel in another pane) — the visibility parallel of `mirror:`. Spread on the **root** via `mix(reactive_root, …)`, **once per root** — several fields go in one call via the hash form. The value uses the same `where`-style vocabulary (`"advanced"`, `%w[a b]`, `10..`); a `"#id"` **key** takes a full conditions Hash for a **multi-field** predicate (`"#warn" => { if: { type: "trade", price: ..0 } }`). Id selectors only (raise at render + client warn-skip); toggles `hidden` only. See [Value-conditional visibility](#value-conditional-visibility-reactive_show). |
|
|
697
|
-
| `reactive_persist(key:, ttl: 7.days)` | **Client-only drafts**: spread on the **root** (once) and the generic controller keeps a `localStorage` draft of every **owned** control — debounced write on `input`, immediate on `change`, flushed on disconnect, restored into **blank** controls on the next connect (`restore: :always` lets the draft win), cleared by a successful Turbo submit / `ttl` / `js.persist_clear`. Never hidden/file/password; `reactive_persist_skip` opts a control out; `fields:` narrows. See [Client-only drafts](#client-only-drafts-reactive_persist). |
|
|
700
|
+
| `reactive_persist(key:, ttl: 7.days)` | **Client-only drafts**: spread on the **root** (once) and the generic controller keeps a `localStorage` draft of every **owned** control — debounced write on `input`, immediate on `change`, flushed on disconnect, restored into **blank** controls on the next connect (`restore: :always` lets the draft win), cleared by a successful Turbo submit / `ttl` / `js.persist_clear`. Never hidden/file/password; `reactive_persist_skip` opts a control out; `fields:` narrows; `storage: :session` keeps the draft in this tab's `sessionStorage` instead. See [Client-only drafts](#client-only-drafts-reactive_persist). |
|
|
698
701
|
| `js.persist_state(step: 2)` / `js.persist_clear` | The draft ops (actor-only): merge a flat state bag into the draft (restored as `data-reactive-persist-state` + the `reactive:persist-restored` event) / forget the draft. |
|
|
699
702
|
| `reactive_filter(:field, option: nil, group: nil, empty: nil)` | **Client-side option filtering** for a preloaded combobox: spread onto the root and name the **field** that drives it — `reactive_filter(:q)` compiles `:q` to `[name="q"]` (scope-aware) and typing shows/hides the options by their `data-reactive-filter-text` haystack, **zero round trips**. `option:` defaults to `[role=option]`; optional `group:` collapses an all-hidden group header; `empty:` reveals a no-matches node. `input:` is the escape hatch — a raw CSS selector for a **name-less** driving input (`input: "#tags_query"`), the form-builder case. See [Client-side option filtering](#client-side-option-filtering-reactive_filter). |
|
|
700
703
|
| `reactive_listnav("[role=option]")` | The **standalone** combobox keyboard wiring (Arrow/Enter/Escape) for an input that fires **no action** — the preload-and-filter case. Same behavior as `on(…, listnav:)`, minus the POST. `reactive_listnav(focus: true)` is the **roving-focus** variant for a `role="menu"` container: Arrow Down/Up move real focus among `[role=menuitem]` (wrapping), Home/End jump to the edges; Enter/Space stay native and Escape is yours to bind. |
|
|
@@ -1267,13 +1270,18 @@ button(**on_client(:click, js
|
|
|
1267
1270
|
`from` `<img>` onto the `to` `<img>` — how a preview shows an image the server
|
|
1268
1271
|
already rendered while `src` stays refused. Both are elements (`:root` or a
|
|
1269
1272
|
selector); the URL is read from the live source element, never passed. A
|
|
1270
|
-
missing source
|
|
1271
|
-
|
|
1273
|
+
missing source or a non-`<img>` source clears the target; an `<img>` source
|
|
1274
|
+
without `src` removes the target's `src`/`srcset` and copies its `alt` (#381),
|
|
1275
|
+
so an entry with no image keeps its name; a non-`<img>` target is warned about and skipped; an attribute that already
|
|
1272
1276
|
holds the copied value is not rewritten, so the same image twice does not
|
|
1273
1277
|
reload. Allowed in a broadcast (it only reads the subscriber's own page).
|
|
1274
1278
|
- **`focus(to)`** focuses the first match; **`focus_first(to)`** focuses the first
|
|
1275
1279
|
focusable descendant of the match (e.g. the first menuitem inside an opened
|
|
1276
1280
|
menu).
|
|
1281
|
+
- **`blur(to)`** (#399) blurs the focused element when it is the match or sits
|
|
1282
|
+
inside it, so Escape ends a menu's `:focus-within`
|
|
1283
|
+
(`on_client("keydown.esc", js.blur("#menu"))`); focus elsewhere on the page is
|
|
1284
|
+
left alone. Actor-only like focus: refused in broadcasts.
|
|
1277
1285
|
- **`show_modal(to)`** / **`close_dialog(to)`** (#365) call
|
|
1278
1286
|
`HTMLDialogElement#showModal()` / `#close()`: the top layer, a focus trap,
|
|
1279
1287
|
`::backdrop`, Escape to close and focus back on the opener — all the
|
|
@@ -1339,13 +1347,14 @@ button(**on_client(:click, js
|
|
|
1339
1347
|
(a broadcast that reads every subscriber's clipboard would be hostile). The
|
|
1340
1348
|
op is async fire-and-forget — chained siblings apply immediately, never
|
|
1341
1349
|
waiting for the read.
|
|
1342
|
-
- **`preview_file(to, into:, name: nil, global: false)`** (#375; `global: true`
|
|
1350
|
+
- **`preview_file(to, into:, name: nil, global: false, valid: false, wrap: nil, max_bytes: nil, accept: nil)`** (#375; `global: true`
|
|
1343
1351
|
resolves `to`, `into:` and `name:` document-wide, for an `img` outside the
|
|
1344
1352
|
root) paints the file a file input
|
|
1345
1353
|
holds onto an `img` **before any upload**:
|
|
1346
1354
|
`input(type: "file", id: "photo-file", accept: "image/*", **on_client(:change,
|
|
1347
1355
|
js.preview_file("#photo-file", into: "#photo", name: "#photo-name")))`. On
|
|
1348
|
-
`change` the client takes the input's first `image/*` file
|
|
1356
|
+
`change` the client takes the input's first `image/*` file (the default list;
|
|
1357
|
+
an explicit `accept:` below replaces it), makes an object
|
|
1349
1358
|
URL from it (`URL.createObjectURL`), sets it as the `img`'s `src`, and puts
|
|
1350
1359
|
`file.name` in the `name:` node's `textContent`. An empty selection or a
|
|
1351
1360
|
non-image file removes the `src` and clears the name. The op carries **no
|
|
@@ -1356,6 +1365,25 @@ button(**on_client(:click, js
|
|
|
1356
1365
|
and revokes it on a re-pick, a clear, and when the `img` leaves the page or a
|
|
1357
1366
|
morph strips its `src` — a server re-render resets the preview like any other
|
|
1358
1367
|
op. **Actor-only**: refused in `broadcast_to(js:)`.
|
|
1368
|
+
**Upload validation (#382):** `valid: true` treats an input whose
|
|
1369
|
+
`validity.valid` is false (`setCustomValidity`, `required`) as empty, so a
|
|
1370
|
+
rejected file never gets a preview. An app that clears the input itself
|
|
1371
|
+
(`input.value = ""` fires no `change`) dispatches `reactive:file-changed` on
|
|
1372
|
+
it; bind the op to that event too and the preview, the name and the URL are
|
|
1373
|
+
cleared: `op = js.preview_file("#photo-file", into: "#photo", valid: true)`,
|
|
1374
|
+
`input(type: "file", id: "photo-file", **mix(on_client(:change, op),
|
|
1375
|
+
on_client("reactive:file-changed", op)))`.
|
|
1376
|
+
**Frame, size and type (#391):** `wrap: "#photo-frame"` shows that element
|
|
1377
|
+
when an image is painted and hides it when the preview clears (marked
|
|
1378
|
+
`data-reactive-wrap-hidden`; only a wrapper carrying the mark is shown again,
|
|
1379
|
+
and its text is never written). `max_bytes: 2_000_000` refuses a larger file
|
|
1380
|
+
before any object URL exists; `accept: true` reads the input's `accept`
|
|
1381
|
+
attribute (a String is the list itself; omitted keeps `image/*`). Only an
|
|
1382
|
+
image is painted: a listed file that is not `image/*` (a PDF) clears the
|
|
1383
|
+
preview and hides `wrap:` without a rejection. A refused
|
|
1384
|
+
file clears the preview, hides `wrap:` and dispatches `reactive:file-rejected`
|
|
1385
|
+
on the input with `detail.reason` `"type"` or `"size"`; `input.files` and the
|
|
1386
|
+
validity are left alone.
|
|
1359
1387
|
- **`expanded:`** on `show`/`hide`/`toggle` (#271) keeps a disclosure trigger's
|
|
1360
1388
|
`aria-expanded` honest: `js.toggle("#menu", expanded: "#trigger")` writes the
|
|
1361
1389
|
op's **intended** state (`show` → `"true"`, `hide` → `"false"`, `toggle` → the
|
|
@@ -1567,6 +1595,15 @@ end
|
|
|
1567
1595
|
blank field has length 0. **`{ checked: … }` counts the ticked boxes of a
|
|
1568
1596
|
checkbox group** — `{ "ids[]" => { checked: 1.. } }` (see
|
|
1569
1597
|
[Bulk selection](#bulk-selection-reactive_select_all-reactive_count-reactive_enable)).
|
|
1598
|
+
**`{ same: :email }` compares the field with another owned field**
|
|
1599
|
+
(`{ confirm_email: { same: :email, case: :fold } }`); `case: :fold`
|
|
1600
|
+
lowercases ASCII `A-Z` only, on the server and in the client, and a `same:`
|
|
1601
|
+
name no element of the render carries raises at render.
|
|
1602
|
+
**`{ digits: 10, starts_with: "9" }` is a closed digit format** — ASCII
|
|
1603
|
+
`0-9` only, an exact count or an Integer Range (`digits: 9..11`), an
|
|
1604
|
+
optional digit prefix. Pair it with **`after: :blur`** (or `after: :code`
|
|
1605
|
+
for a multi-field condition) to keep a soft hint hidden until the field has
|
|
1606
|
+
blurred once.
|
|
1570
1607
|
`unless:` **negates** and composes with `if:`.
|
|
1571
1608
|
Never an expression — every term is a declared literal, so there is no eval
|
|
1572
1609
|
surface. A blank/non-numeric value fails a numeric term **closed** (hidden).
|
|
@@ -1583,6 +1620,16 @@ end
|
|
|
1583
1620
|
symbols while the client resolves `[name="form[field]"]`.
|
|
1584
1621
|
- **`disable: true`** disables a hidden section's own controls so a
|
|
1585
1622
|
switched-away value never submits (the stale-value fix).
|
|
1623
|
+
- **`clear:`, `focus:`, `scroll:`** run when the section **changes**
|
|
1624
|
+
visibility, never on first paint: going hidden, `clear: :notes` (or an Array)
|
|
1625
|
+
empties those fields with `js.set_value`'s write and dispatches `input` per
|
|
1626
|
+
field that changed (a checkbox unchecks); going visible, `focus: "#notes"`
|
|
1627
|
+
focuses the first match inside the root and `scroll: true` scrolls the
|
|
1628
|
+
section into view. A `clear:` name the render never produced, or a file
|
|
1629
|
+
input, raises when the outermost render completes (a fragment render, or
|
|
1630
|
+
one that calls `flush`, is not checked). These run on a controller that
|
|
1631
|
+
saw the previous visibility: a `morph: true` reply that flips it runs them,
|
|
1632
|
+
a plain replace reconnects the root and counts as first paint.
|
|
1586
1633
|
- **Field reads follow the collection rules**: a checkbox compares its *checked*
|
|
1587
1634
|
state; a radio group reads the **checked** radio's value; everything else
|
|
1588
1635
|
reads `.value`. Ownership is the usual rule — a nested reactive component's
|
|
@@ -1693,6 +1740,26 @@ end
|
|
|
1693
1740
|
flips, so computes, shows and completions re-run. Its own state follows the
|
|
1694
1741
|
group: **checked** when all are ticked, **indeterminate** when some are,
|
|
1695
1742
|
unchecked when none are. Give the header no `name` (it would post).
|
|
1743
|
+
- **Nested groups — `reactive_select_all(group, under: "library.books")`**
|
|
1744
|
+
(#398). A tree of headers over leaves that still post as **one** array. Each
|
|
1745
|
+
leaf names the path of its nearest parent with `data-reactive-group`; a
|
|
1746
|
+
header with `under:` owns the boxes of the name whose group is that path or
|
|
1747
|
+
below it (`library` owns `library` and `library.books`, never `libraryx`).
|
|
1748
|
+
The header ticks or unticks those leaves (with `input` + `change` on each one
|
|
1749
|
+
it flips) and is checked / indeterminate / unchecked from them, so a parent
|
|
1750
|
+
follows its nested headers. The header without `under:` is still every box
|
|
1751
|
+
of the name. Headers have no `name`, so they never post, and
|
|
1752
|
+
`reactive_count` / `reactive_enable` keep counting the leaves. A blank
|
|
1753
|
+
`under:`, an empty segment (`"a..b"`), or a space-padded segment
|
|
1754
|
+
(`"a .b"`) raises at render.
|
|
1755
|
+
|
|
1756
|
+
```ruby
|
|
1757
|
+
input(type: "checkbox", **reactive_select_all("perms[]")) # everything
|
|
1758
|
+
input(type: "checkbox", **reactive_select_all("perms[]", under: "library")) # a branch
|
|
1759
|
+
input(type: "checkbox", name: "perms[]", value: "notes", data: { reactive_group: "library" })
|
|
1760
|
+
input(type: "checkbox", **reactive_select_all("perms[]", under: "library.books")) # a sub-branch
|
|
1761
|
+
input(type: "checkbox", name: "perms[]", value: "view", data: { reactive_group: "library.books" })
|
|
1762
|
+
```
|
|
1696
1763
|
- **`reactive_count(group)`** — writes the ticked count via `textContent`
|
|
1697
1764
|
(change-guarded). Seed the first paint in its block.
|
|
1698
1765
|
- **`{ checked: n }` / `{ checked: 1.. }`** — a term in the shared conditions
|
|
@@ -2014,6 +2081,16 @@ wire attr: `data-reactive-persist='{"key":"village-apply","ttl":604800,"debounce
|
|
|
2014
2081
|
- **`restore: :blank`** (default) — a draft value lands only in a control the
|
|
2015
2082
|
server rendered **blank**, so a 422 re-render's submitted values beat an
|
|
2016
2083
|
older draft. `restore: :always` lets the draft win.
|
|
2084
|
+
- **`storage: :local`** (default) — the draft lives in `localStorage`: every
|
|
2085
|
+
tab of the origin sees it, and it outlives them until `ttl`.
|
|
2086
|
+
`storage: :session` (#397) keeps it in `sessionStorage` instead: the same tab
|
|
2087
|
+
restores it on reload, another tab never sees it, and closing the tab drops
|
|
2088
|
+
it. (A tab opened FROM this one with an opener — `window.open`, a link
|
|
2089
|
+
without `noopener` — or a duplicated tab starts with a copy of the
|
|
2090
|
+
browser's session store, draft included: a one-time snapshot, after which
|
|
2091
|
+
the two tabs' drafts are separate.) Same key, same exclusions, same `restore:`, same clears. The two stores
|
|
2092
|
+
are read separately — a `:local` draft is never restored by `:session`.
|
|
2093
|
+
`"local"` / `"session"` strings are accepted; anything else raises at render.
|
|
2017
2094
|
- **Clear** — a successful `turbo:submit-end` of the form that contains the
|
|
2018
2095
|
root, `ttl` expiry (checked on read; default `7.days`), or `js.persist_clear`.
|
|
2019
2096
|
A successful *reactive* action does **not** clear on its own — chain
|
|
@@ -2058,8 +2135,10 @@ are replayed via `.value`/`.checked`/`.selected`, a bare contenteditable via
|
|
|
2058
2135
|
`textContent`, and a rich editor through its own `value` setter (the same
|
|
2059
2136
|
sanitizing import a paste takes). A tampered draft can only produce what the
|
|
2060
2137
|
user could type or paste into that field. PII sits in this
|
|
2061
|
-
browser's `localStorage`
|
|
2062
|
-
|
|
2138
|
+
browser's `localStorage` (or, with `storage: :session`, this tab's
|
|
2139
|
+
`sessionStorage`) for `ttl`; the submit-clear, `ttl` and
|
|
2140
|
+
`storage: :session` (the draft goes with the tab) are the shared-computer
|
|
2141
|
+
mitigation. `persist_state`/`persist_clear` are **actor-only**
|
|
2063
2142
|
ops (refused by `broadcast_to(js:)`). See the [security page](docs/security.md).
|
|
2064
2143
|
|
|
2065
2144
|
### Client-side computes (`reactive_compute` + `reactive_text`)
|
|
@@ -3124,7 +3203,7 @@ Notifications::Badge.broadcast_to(user, :alerts,
|
|
|
3124
3203
|
js: js.add_class("#bell", "has-unread"), exclude: reactive_connection_id)
|
|
3125
3204
|
```
|
|
3126
3205
|
|
|
3127
|
-
`broadcast_to` with `js:` **refuses the actor-only ops** (`focus`/`focus_first`/
|
|
3206
|
+
`broadcast_to` with `js:` **refuses the actor-only ops** (`focus`/`focus_first`/`blur`/
|
|
3128
3207
|
`show_modal`/`close_dialog`/`submit`/`paste_into`/`preview_file`/`persist_state`/`persist_clear`/`check_group`/`set_value` raise
|
|
3129
3208
|
`ArgumentError`): broadcasting focus (or a modal) would steal it in every subscriber's tab, a
|
|
3130
3209
|
broadcast submit would force-submit every subscriber's form, and a broadcast
|
|
@@ -28,6 +28,7 @@ registerReactiveFeatureLoader("bindings", () => import("phlex/reactive/features/
|
|
|
28
28
|
registerReactiveFeatureLoader("compute", () => import("phlex/reactive/features/compute"), waitingForEdit)
|
|
29
29
|
registerReactiveFeatureLoader("sortable", () => import("phlex/reactive/features/sortable"))
|
|
30
30
|
registerReactiveFeatureLoader("presence", () => import("phlex/reactive/features/presence"))
|
|
31
|
+
registerReactiveFeatureLoader("countdown", () => import("phlex/reactive/features/countdown"))
|
|
31
32
|
registerReactiveFeatureLoader("effects", () => import("phlex/reactive/features/effects"))
|
|
32
33
|
registerReactiveFeatureLoader("hints", () => import("phlex/reactive/features/hints"))
|
|
33
34
|
registerReactiveFeatureLoader("devtools", () => import("phlex/reactive/features/devtools"))
|