phlex-reactive 0.15.3 → 0.15.5

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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +184 -0
  3. data/README.md +92 -12
  4. data/app/javascript/phlex/reactive/core.js +1 -0
  5. data/app/javascript/phlex/reactive/core.min.js +6 -6
  6. data/app/javascript/phlex/reactive/core.min.js.map +4 -4
  7. data/app/javascript/phlex/reactive/features/bindings.js +129 -16
  8. data/app/javascript/phlex/reactive/features/bindings.min.js +2 -2
  9. data/app/javascript/phlex/reactive/features/bindings.min.js.map +3 -3
  10. data/app/javascript/phlex/reactive/features/countdown.js +140 -0
  11. data/app/javascript/phlex/reactive/features/countdown.min.js +4 -0
  12. data/app/javascript/phlex/reactive/features/countdown.min.js.map +10 -0
  13. data/app/javascript/phlex/reactive/features/persist.js +10 -6
  14. data/app/javascript/phlex/reactive/features/persist.min.js +2 -2
  15. data/app/javascript/phlex/reactive/features/persist.min.js.map +3 -3
  16. data/app/javascript/phlex/reactive/reactive_controller.js +2 -0
  17. data/app/javascript/phlex/reactive/reactive_controller.min.js +4 -4
  18. data/app/javascript/phlex/reactive/reactive_controller.min.js.map +8 -7
  19. data/app/javascript/phlex/reactive/runtime.js +255 -31
  20. data/lib/phlex/reactive/component/countdown_binding.rb +80 -0
  21. data/lib/phlex/reactive/component/field_references.rb +130 -0
  22. data/lib/phlex/reactive/component/helpers.rb +72 -14
  23. data/lib/phlex/reactive/component/image_fallback_binding.rb +18 -5
  24. data/lib/phlex/reactive/component/media_binding.rb +9 -1
  25. data/lib/phlex/reactive/component/media_pattern.rb +17 -17
  26. data/lib/phlex/reactive/component/selection.rb +24 -2
  27. data/lib/phlex/reactive/component/show_options.rb +91 -0
  28. data/lib/phlex/reactive/engine.rb +2 -2
  29. data/lib/phlex/reactive/js.rb +44 -2
  30. data/lib/phlex/reactive/response.rb +3 -4
  31. data/lib/phlex/reactive/show_conditions/compare_terms.rb +142 -0
  32. data/lib/phlex/reactive/show_conditions.rb +39 -6
  33. data/lib/phlex/reactive/streamable.rb +6 -4
  34. data/lib/phlex/reactive/version.rb +1 -1
  35. metadata +8 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2fb24b631e6cd4fec31a0635a1d522f3b3423cdf67ad1e41eecb279fcc1c279b
4
- data.tar.gz: 82f859da94e60c3a691d3b39a9aa51ead6e52fc44c3ead8b32d23b1f5b079143
3
+ metadata.gz: bb2e7841a38e3400b1777833cde2f16a73794b54c29f9e0ceb1a2b02c358263d
4
+ data.tar.gz: 8023c8ab7f9bb88833282c33c99a7797f565fb491795cf9b0c7566d0040653ef
5
5
  SHA512:
6
- metadata.gz: 03f24931aedcb275d1e938ededb4adfb0820660114373a96187999c3eaeb30fc532a26b6aee61dc4eb222ddb06accaf2f1c0242d8296329b8a55cf0bf3f5b836
7
- data.tar.gz: 4afa1a62e2872e6ba17394e342d41aa129d9377a1cfe47681f28f18c6caa3fefa0f5e19a94cd1b503048a7219b4e771eb5a4abbb72337b2f4a99bf99f5d1fb82
6
+ metadata.gz: 5257d059749e070f6b7e369e89c06fbca8b5c6b0cb9d6663b7983081a9af5a7831ba7f4d46b01da97d04a49f5fe727b6d760351f449d140e4ccffb2881cfd7c3
7
+ data.tar.gz: 0e29f1ca596e632855b89c9dacf2b1e0e4b6fc0db3f836f662d61c8349c0fbda0f11ebb706fe165acfcfb4ed9fbcd4d630154db8f25c023b8d7ce547dbaf9cab
data/CHANGELOG.md CHANGED
@@ -8,6 +8,174 @@ 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
+
11
179
  - **An image fallback that recovers — `reactive_image_fallback(hide: true, show_alt: nil)` (#383).**
12
180
  `img(**reactive_image_fallback)` marks an `<img>` whose `src` fails with
13
181
  `data-reactive-image-state="error"` and hides it; `show_alt: "#bank-name"`
@@ -235,6 +403,22 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
235
403
 
236
404
  ### Fixed
237
405
 
406
+ - **A server trigger inside a client-only root reaches the component around it
407
+ (#410).** A `ClientBindings` component (a `reactive_filter` list, a
408
+ `reactive_show` wrapper, an `on_client` menu) renders a root with no token.
409
+ Rendered inside a server component, it swallowed that component's `on(...)`
410
+ triggers: Stimulus sends the action to the nearest root, which had no token,
411
+ and the outer root's field collection skipped every control under it (the
412
+ #15 rule). A tokenless client-only root is now transparent to server round
413
+ trips: `dispatch` forwards to the nearest token-bearing root above (skipping
414
+ further client-only roots, waking a dormant one), and that root's action
415
+ collects the controls under nested client-only roots. A nested server
416
+ component keeps its own fields and triggers, and client bindings stay on
417
+ their own root. With no token-bearing root above, nothing is sent, and
418
+ `verbose_errors`/debug logs one warning. Lazy and deferred shells are not
419
+ client-only. Client: +414 B gzipped in the default bundle, +415 B in the
420
+ split core.
421
+
238
422
  - **`reactive_media` waits for `take` characters before calling a key a miss
239
423
  (#380).** A value shorter than `take:` sliced to a short key, missed the
240
424
  pool and wrote `blank_label` while the user was still typing. It is now
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,19 +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, plus `detail:` (run only when `event.detail` carries the listed values). See [Client-only ops](#client-only-ops-on_client--js--zero-round-trips). |
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:, match:, 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); `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). |
693
- | `reactive_image_fallback(hide: true, show_alt: 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; `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). |
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). |
694
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). |
695
- | `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). |
696
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). |
697
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). |
698
- | `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). |
699
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. |
700
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). |
701
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. |
@@ -902,6 +904,17 @@ named inputs, never a nested component's. An invoice editor's `save` sees its
902
904
  flat fields; each line-item row's `quantity`/`price` belong to that row's own
903
905
  action. No name-disjointness workarounds required.
904
906
 
907
+ A **client-only** helper (a `Phlex::Reactive::ClientBindings` component: a
908
+ `reactive_filter` list, a `reactive_show` wrapper) is the exception. Its root
909
+ has no token and no action, so it is transparent to server round trips: an
910
+ `on(...)` trigger inside it dispatches on the nearest server component above,
911
+ and that component's actions collect the controls inside it. Wrap a long
912
+ checkbox list in a searchable helper and its boxes still post to the form
913
+ around it (#410). The helper's own bindings stay on the helper's root. A
914
+ lazy or deferred shell (`reactive_lazy`, `reply.defer`) is never treated as
915
+ client-only, with or without a token: it is a server component still loading,
916
+ so it stays a boundary.
917
+
905
918
  **Debounced triggers (live-as-you-type).** Pass `debounce:` (milliseconds) to
906
919
  coalesce rapid events — typically keystrokes on an `"input"` trigger — into a
907
920
  single action round trip fired after the quiet period, instead of one POST per
@@ -1276,6 +1289,10 @@ button(**on_client(:click, js
1276
1289
  - **`focus(to)`** focuses the first match; **`focus_first(to)`** focuses the first
1277
1290
  focusable descendant of the match (e.g. the first menuitem inside an opened
1278
1291
  menu).
1292
+ - **`blur(to)`** (#399) blurs the focused element when it is the match or sits
1293
+ inside it, so Escape ends a menu's `:focus-within`
1294
+ (`on_client("keydown.esc", js.blur("#menu"))`); focus elsewhere on the page is
1295
+ left alone. Actor-only like focus: refused in broadcasts.
1279
1296
  - **`show_modal(to)`** / **`close_dialog(to)`** (#365) call
1280
1297
  `HTMLDialogElement#showModal()` / `#close()`: the top layer, a focus trap,
1281
1298
  `::backdrop`, Escape to close and focus back on the opener — all the
@@ -1341,13 +1358,14 @@ button(**on_client(:click, js
1341
1358
  (a broadcast that reads every subscriber's clipboard would be hostile). The
1342
1359
  op is async fire-and-forget — chained siblings apply immediately, never
1343
1360
  waiting for the read.
1344
- - **`preview_file(to, into:, name: nil, global: false, valid: false)`** (#375; `global: true`
1361
+ - **`preview_file(to, into:, name: nil, global: false, valid: false, wrap: nil, max_bytes: nil, accept: nil)`** (#375; `global: true`
1345
1362
  resolves `to`, `into:` and `name:` document-wide, for an `img` outside the
1346
1363
  root) paints the file a file input
1347
1364
  holds onto an `img` **before any upload**:
1348
1365
  `input(type: "file", id: "photo-file", accept: "image/*", **on_client(:change,
1349
1366
  js.preview_file("#photo-file", into: "#photo", name: "#photo-name")))`. On
1350
- `change` the client takes the input's first `image/*` file, makes an object
1367
+ `change` the client takes the input's first `image/*` file (the default list;
1368
+ an explicit `accept:` below replaces it), makes an object
1351
1369
  URL from it (`URL.createObjectURL`), sets it as the `img`'s `src`, and puts
1352
1370
  `file.name` in the `name:` node's `textContent`. An empty selection or a
1353
1371
  non-image file removes the `src` and clears the name. The op carries **no
@@ -1366,6 +1384,17 @@ button(**on_client(:click, js
1366
1384
  cleared: `op = js.preview_file("#photo-file", into: "#photo", valid: true)`,
1367
1385
  `input(type: "file", id: "photo-file", **mix(on_client(:change, op),
1368
1386
  on_client("reactive:file-changed", op)))`.
1387
+ **Frame, size and type (#391):** `wrap: "#photo-frame"` shows that element
1388
+ when an image is painted and hides it when the preview clears (marked
1389
+ `data-reactive-wrap-hidden`; only a wrapper carrying the mark is shown again,
1390
+ and its text is never written). `max_bytes: 2_000_000` refuses a larger file
1391
+ before any object URL exists; `accept: true` reads the input's `accept`
1392
+ attribute (a String is the list itself; omitted keeps `image/*`). Only an
1393
+ image is painted: a listed file that is not `image/*` (a PDF) clears the
1394
+ preview and hides `wrap:` without a rejection. A refused
1395
+ file clears the preview, hides `wrap:` and dispatches `reactive:file-rejected`
1396
+ on the input with `detail.reason` `"type"` or `"size"`; `input.files` and the
1397
+ validity are left alone.
1369
1398
  - **`expanded:`** on `show`/`hide`/`toggle` (#271) keeps a disclosure trigger's
1370
1399
  `aria-expanded` honest: `js.toggle("#menu", expanded: "#trigger")` writes the
1371
1400
  op's **intended** state (`show` → `"true"`, `hide` → `"false"`, `toggle` → the
@@ -1577,6 +1606,15 @@ end
1577
1606
  blank field has length 0. **`{ checked: … }` counts the ticked boxes of a
1578
1607
  checkbox group** — `{ "ids[]" => { checked: 1.. } }` (see
1579
1608
  [Bulk selection](#bulk-selection-reactive_select_all-reactive_count-reactive_enable)).
1609
+ **`{ same: :email }` compares the field with another owned field**
1610
+ (`{ confirm_email: { same: :email, case: :fold } }`); `case: :fold`
1611
+ lowercases ASCII `A-Z` only, on the server and in the client, and a `same:`
1612
+ name no element of the render carries raises at render.
1613
+ **`{ digits: 10, starts_with: "9" }` is a closed digit format** — ASCII
1614
+ `0-9` only, an exact count or an Integer Range (`digits: 9..11`), an
1615
+ optional digit prefix. Pair it with **`after: :blur`** (or `after: :code`
1616
+ for a multi-field condition) to keep a soft hint hidden until the field has
1617
+ blurred once.
1580
1618
  `unless:` **negates** and composes with `if:`.
1581
1619
  Never an expression — every term is a declared literal, so there is no eval
1582
1620
  surface. A blank/non-numeric value fails a numeric term **closed** (hidden).
@@ -1593,6 +1631,16 @@ end
1593
1631
  symbols while the client resolves `[name="form[field]"]`.
1594
1632
  - **`disable: true`** disables a hidden section's own controls so a
1595
1633
  switched-away value never submits (the stale-value fix).
1634
+ - **`clear:`, `focus:`, `scroll:`** run when the section **changes**
1635
+ visibility, never on first paint: going hidden, `clear: :notes` (or an Array)
1636
+ empties those fields with `js.set_value`'s write and dispatches `input` per
1637
+ field that changed (a checkbox unchecks); going visible, `focus: "#notes"`
1638
+ focuses the first match inside the root and `scroll: true` scrolls the
1639
+ section into view. A `clear:` name the render never produced, or a file
1640
+ input, raises when the outermost render completes (a fragment render, or
1641
+ one that calls `flush`, is not checked). These run on a controller that
1642
+ saw the previous visibility: a `morph: true` reply that flips it runs them,
1643
+ a plain replace reconnects the root and counts as first paint.
1596
1644
  - **Field reads follow the collection rules**: a checkbox compares its *checked*
1597
1645
  state; a radio group reads the **checked** radio's value; everything else
1598
1646
  reads `.value`. Ownership is the usual rule — a nested reactive component's
@@ -1703,6 +1751,26 @@ end
1703
1751
  flips, so computes, shows and completions re-run. Its own state follows the
1704
1752
  group: **checked** when all are ticked, **indeterminate** when some are,
1705
1753
  unchecked when none are. Give the header no `name` (it would post).
1754
+ - **Nested groups — `reactive_select_all(group, under: "library.books")`**
1755
+ (#398). A tree of headers over leaves that still post as **one** array. Each
1756
+ leaf names the path of its nearest parent with `data-reactive-group`; a
1757
+ header with `under:` owns the boxes of the name whose group is that path or
1758
+ below it (`library` owns `library` and `library.books`, never `libraryx`).
1759
+ The header ticks or unticks those leaves (with `input` + `change` on each one
1760
+ it flips) and is checked / indeterminate / unchecked from them, so a parent
1761
+ follows its nested headers. The header without `under:` is still every box
1762
+ of the name. Headers have no `name`, so they never post, and
1763
+ `reactive_count` / `reactive_enable` keep counting the leaves. A blank
1764
+ `under:`, an empty segment (`"a..b"`), or a space-padded segment
1765
+ (`"a .b"`) raises at render.
1766
+
1767
+ ```ruby
1768
+ input(type: "checkbox", **reactive_select_all("perms[]")) # everything
1769
+ input(type: "checkbox", **reactive_select_all("perms[]", under: "library")) # a branch
1770
+ input(type: "checkbox", name: "perms[]", value: "notes", data: { reactive_group: "library" })
1771
+ input(type: "checkbox", **reactive_select_all("perms[]", under: "library.books")) # a sub-branch
1772
+ input(type: "checkbox", name: "perms[]", value: "view", data: { reactive_group: "library.books" })
1773
+ ```
1706
1774
  - **`reactive_count(group)`** — writes the ticked count via `textContent`
1707
1775
  (change-guarded). Seed the first paint in its block.
1708
1776
  - **`{ checked: n }` / `{ checked: 1.. }`** — a term in the shared conditions
@@ -2024,6 +2092,16 @@ wire attr: `data-reactive-persist='{"key":"village-apply","ttl":604800,"debounce
2024
2092
  - **`restore: :blank`** (default) — a draft value lands only in a control the
2025
2093
  server rendered **blank**, so a 422 re-render's submitted values beat an
2026
2094
  older draft. `restore: :always` lets the draft win.
2095
+ - **`storage: :local`** (default) — the draft lives in `localStorage`: every
2096
+ tab of the origin sees it, and it outlives them until `ttl`.
2097
+ `storage: :session` (#397) keeps it in `sessionStorage` instead: the same tab
2098
+ restores it on reload, another tab never sees it, and closing the tab drops
2099
+ it. (A tab opened FROM this one with an opener — `window.open`, a link
2100
+ without `noopener` — or a duplicated tab starts with a copy of the
2101
+ browser's session store, draft included: a one-time snapshot, after which
2102
+ the two tabs' drafts are separate.) Same key, same exclusions, same `restore:`, same clears. The two stores
2103
+ are read separately — a `:local` draft is never restored by `:session`.
2104
+ `"local"` / `"session"` strings are accepted; anything else raises at render.
2027
2105
  - **Clear** — a successful `turbo:submit-end` of the form that contains the
2028
2106
  root, `ttl` expiry (checked on read; default `7.days`), or `js.persist_clear`.
2029
2107
  A successful *reactive* action does **not** clear on its own — chain
@@ -2068,8 +2146,10 @@ are replayed via `.value`/`.checked`/`.selected`, a bare contenteditable via
2068
2146
  `textContent`, and a rich editor through its own `value` setter (the same
2069
2147
  sanitizing import a paste takes). A tampered draft can only produce what the
2070
2148
  user could type or paste into that field. PII sits in this
2071
- browser's `localStorage` for `ttl`; the submit-clear and `ttl` are the
2072
- shared-computer mitigation. `persist_state`/`persist_clear` are **actor-only**
2149
+ browser's `localStorage` (or, with `storage: :session`, this tab's
2150
+ `sessionStorage`) for `ttl`; the submit-clear, `ttl` and
2151
+ `storage: :session` (the draft goes with the tab) are the shared-computer
2152
+ mitigation. `persist_state`/`persist_clear` are **actor-only**
2073
2153
  ops (refused by `broadcast_to(js:)`). See the [security page](docs/security.md).
2074
2154
 
2075
2155
  ### Client-side computes (`reactive_compute` + `reactive_text`)
@@ -3134,7 +3214,7 @@ Notifications::Badge.broadcast_to(user, :alerts,
3134
3214
  js: js.add_class("#bell", "has-unread"), exclude: reactive_connection_id)
3135
3215
  ```
3136
3216
 
3137
- `broadcast_to` with `js:` **refuses the actor-only ops** (`focus`/`focus_first`/
3217
+ `broadcast_to` with `js:` **refuses the actor-only ops** (`focus`/`focus_first`/`blur`/
3138
3218
  `show_modal`/`close_dialog`/`submit`/`paste_into`/`preview_file`/`persist_state`/`persist_clear`/`check_group`/`set_value` raise
3139
3219
  `ArgumentError`): broadcasting focus (or a modal) would steal it in every subscriber's tab, a
3140
3220
  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"))