phlex-reactive 0.13.2 → 0.13.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cb580ca60e8e64b7857e665f34e5311e35f66c8aa4348135c61286456b06649f
4
- data.tar.gz: fb25bb13bd78f0923f6a7a3c5ea383101cca16eaf9b808607e074c49dbbaa583
3
+ metadata.gz: ce768022637774ec42b7bca32e6533613fb563c792d9c82f58c8b07eea722e7f
4
+ data.tar.gz: 42a470435e59c6a364f3845a4626bdbb0b8e23a1cc7aadcb0c2281b89c922555
5
5
  SHA512:
6
- metadata.gz: 6d50f4794edea6b74bf4178df8d4a271e947e8bc3eef268d19ba8cb855c3207e87ee9c3bd345a22b3d9192f58510bb00860278c64a59c7f1e1b3478b72af62c0
7
- data.tar.gz: '0233287800960c1886865f7df4fe2785237820d16e212b616e1971c879d0a97abd373f55588f2921aef4bd742d8cb053ffb2b560a81862517767dbd883556fac'
6
+ metadata.gz: d2f238f055aabaf9466212ab07158ae2ba40139207b77b118b12fcafb18ca43ad14a2f48b82ea666345375e19afd499bba456c9bec4e978b12875a560f215637
7
+ data.tar.gz: 19105c1e86bec4015e9be48f42b4580c0adf6fa70fa1a00d6b5e4c2c5e9a3f5f297095b4fbaf9dcca3a49a6b6c254ef1070998b7f895e4bf6de085d5d152e66d
data/CHANGELOG.md CHANGED
@@ -9,6 +9,41 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
9
9
 
10
10
  ### Added
11
11
 
12
+ - **Several `on_client` bindings on one element (#271).** Two `on_client`
13
+ calls merged with `mix` now both work — e.g. a root that closes a menu on an
14
+ outside click AND on Escape. Each call carries its own descriptor, flags and
15
+ `confirm:`, and the client runs only the binding whose event (type, key
16
+ filter, window-boundness) fired. An outside guard or confirm never leaks onto
17
+ a sibling binding.
18
+
19
+ - **`toggle_attr(to, name, on, off)` — flip between two values (#271).**
20
+ `js.toggle_attr("#trigger", "aria-expanded", "true", "false")` keeps a
21
+ two-valued attribute (`aria-expanded`, `aria-pressed`, `data-state`) honest;
22
+ an absent attribute becomes `on`. One value, or two equal values, raise at
23
+ build time. Without values it is the presence toggle it always was.
24
+
25
+ - **`expanded:` on `show`/`hide`/`toggle` (#271).** `js.toggle("#menu",
26
+ expanded: "#trigger")` writes the op's intended state into the trigger's
27
+ `aria-expanded` (`show` → `"true"`, `hide` → `"false"`, `toggle` → the
28
+ state after the flip: `"true"` when the target was hidden), before any
29
+ transition. Resolved with the op's own scoping.
30
+
31
+ - **`reactive_listnav(focus: true)` — roving focus for menus (#271).** Spread
32
+ on a `role="menu"` container: Arrow Down/Up move real focus among
33
+ `[role=menuitem]` (wrapping), Home/End jump to the edges. Enter/Space stay
34
+ native activation; Escape is the caller's `on_client("keydown.esc", …)`.
35
+ Without `focus:` the helper is byte-identical to before.
36
+
37
+ - **`pending_template` — opt-in pending MARKUP for `reply.pending` (#249).** A
38
+ row component that defines `pending_template` now has its markup swapped by
39
+ `reply.pending` (a real "Queued" badge, a genuinely removed button), in
40
+ addition to the #248 markers. The swap renders a memoized anonymous subclass
41
+ whose `.name` is the row's own, so a row that signs its token still verifies
42
+ on its next action; the memo resets on Rails code reload. A template that
43
+ drops the row's `id` raises before anything is enqueued. Rows without the
44
+ hook are byte-identical to before. The swapped markup is undone only by a
45
+ settle that replaces or removes the row.
46
+
12
47
  - **`bin/release` — the release front door, ported from pgbus.** Works out the
13
48
  next version (`patch` by default, `minor`, `major`, or an explicit `X.Y.Z`
14
49
  with an optional `v`), prints the commits since the last tag, and hands off
@@ -441,6 +476,141 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
441
476
 
442
477
  ### Fixed
443
478
 
479
+ - **`reactive_compute` never saw a checkbox's checked state (#262).**
480
+ `#recompute` resolved each declared name first-wins and read `.value`, which
481
+ for a checkbox is a constant. Measured: a Rails `check_box` pair read `0`
482
+ ticked or not (the hidden companion comes first), a lone `value="1"` box read
483
+ `1` either way, and a box with no value attribute read `0` (`Number("on")` is
484
+ NaN). A radio group read its FIRST radio's value, whichever was checked. The
485
+ reducer ran on the toggle, from values that could not have changed.
486
+
487
+ A checkbox now contributes its checked state, coerced by the declared type:
488
+ `1`/`0` as a `:number` (and in the array form), `"true"`/`"false"` as a
489
+ `:string`, and `true`/`false` under the new **`:boolean`** type. It wins over
490
+ its same-named hidden companion, as it already did for `reactive_show`. A radio
491
+ group contributes its checked radio's value. The identity mirror and the
492
+ `mirror:` fallback paint the same reading.
493
+
494
+ Outputs were wrong the same way: a result whose name resolved to a checkbox
495
+ pair wrote `.value` on the hidden companion, changing what the UNCHECKED state
496
+ submits and leaving the box alone. An output now sets a checkbox's `checked`
497
+ from the result's truthiness (`""`, `"0"`, `"false"`, `0` and `false` untick)
498
+ and checks the radio of a group that carries the result.
499
+
500
+ **If a reducer relied on the old reading**, it changes: a lone checkbox with a
501
+ numeric `value` used to read that number regardless of state and now reads
502
+ `1`/`0` — write the amount in the reducer (`gift ? 25 : 0`). Apps that worked
503
+ around the defect by reading the box from `document` inside the reducer keep
504
+ working and can drop the workaround.
505
+
506
+ Cost, same machine, happy-dom (engine-relative): the 30-input calculator
507
+ bench goes from 16.3 to 18.4 µs/iter, allocations flat. That is one `el.type`
508
+ read per declared name, which is what telling a checkbox from a text field
509
+ takes.
510
+
511
+ - **The dropped-param hints said nothing for a schema with string keys.**
512
+ `ParamSchema.compile` keeps keys as the author wrote them, so
513
+ `params: { "date" => :string }` is valid, but the #16/#21 hints only looked
514
+ up symbol keys. The param was still dropped and logged, just without the hint
515
+ that says where the schema declares it.
516
+
517
+ - **A checkbox group collapsed to one boolean, and the chosen values never left
518
+ the browser (#258).** `#collectFields` wrote `fields[name] = field.checked` for
519
+ every checkbox, so several boxes sharing a `features[]` name overwrote each
520
+ other and the action received the LAST box's checked state — `{}` under a
521
+ `[:string]` schema, `"false"` under a flat `:string` one, silent either way,
522
+ while a native submission of the same boxes sends
523
+ `features[]=news&features[]=events`. A name ending in `[]` is now collected as
524
+ an array of the chosen values: a ticked box contributes its `value`, an
525
+ unticked one nothing, a `<select multiple>` its selected options. The suffix is
526
+ the only trigger — a group says so rather than being inferred from two controls
527
+ sharing a name.
528
+
529
+ **That makes the suffix a migration point.** ANY `[]`-named control now
530
+ contributes to an array, including a single one and including a named rich
531
+ editor or bare contenteditable, which the collector reads in a second pass: a
532
+ lone `<input type="text" name="tags[]">` used to post `"abc"` and now posts
533
+ `["abc"]`. An editor sharing its `[]` name with another control adds its
534
+ value to the group instead of standing down behind it, so the entry count
535
+ changes there too. Beside a radio it still stands down, because a radio keeps
536
+ its single value with or without the suffix. A ready but empty editor
537
+ contributes an empty string, the way an empty text field in the same group
538
+ does. An unticked box still contributes nothing. A hidden input under the
539
+ same name keeps contributing: nothing in the DOM tells a hidden that mirrors
540
+ an editor from one that is a list JS maintains, and a value posted twice is
541
+ visible where a swallowed one is not. Against a flat `params: { tags: :string
542
+ }` the array coerces to the literal `"[\"abc\"]"` — silently, with a 200. A
543
+ control whose name ends in `[]` has to be declared as an array type (`tags:
544
+ [:string]`), or renamed without the suffix if it was never meant as a list.
545
+
546
+ Three shapes keep their meaning on purpose: a lone checkbox without `[]` stays
547
+ the documented yes/no boolean, a radio group keeps its single checked value
548
+ with or without the suffix, and a hidden input sharing a name with a checkbox
549
+ is that box's companion. The companion is identified by the shared name, not
550
+ by its value, because Rails renders three of them: `check_box` emits
551
+ `value="0"`, `check_box(..., multiple: true)` the same under a `[]` name, and
552
+ `collection_check_boxes` a blank one — or none at all with
553
+ `unchecked_value: nil`. Reading the second shape by value collected
554
+ `["0","0","0","3"]` for three boxes with the third ticked.
555
+
556
+ A form body can't carry an empty array, so a cleared group used to go missing
557
+ whenever the client sent a form body (as soon as a file input holds a file),
558
+ and the action got its keyword default instead of `[]`. Now the client names
559
+ the cleared group in `empty_groups[]`, a field next to `token`, `act` and
560
+ `params`, and the endpoint sets it to `[]`. A request without that field
561
+ behaves as before, and a group that carries values keeps them.
562
+
563
+ It applies to params the action declares as an array, by plain name or with
564
+ the component's `reactive_scope` in front, and only at the top level. A group
565
+ declared one level down or inside nested attributes is ignored and keeps its
566
+ keyword default; `verbose_errors` logs it. The endpoint never takes the key it
567
+ writes from the request. It looks the name up in the declaration and writes
568
+ the declared key, so a request can't create a param the action didn't ask for.
569
+
570
+ A blank entry (`params[name][]=""`) would have been the other way, but the
571
+ schema reads `[""]` per element type, and for a `[:file]` param behind
572
+ `has_many_attached` that is the difference between "not sent" and "remove the
573
+ attachments".
574
+
575
+ `post_reactive_multipart` takes `empty_groups:` so a request spec can send a
576
+ cleared group the way the client does. `ParamSchema#array_param(name)`
577
+ returns the declared key for an array param, or `nil`.
578
+
579
+ `reactive_persist` drafts such a group as the list of ticked values and
580
+ restores exactly those boxes; before, the draft held one boolean and the
581
+ restore ticked every box of the group. Whether the server already rendered a
582
+ box ticked — in which case it keeps its say and the draft yields — is decided
583
+ once before the restore walks the controls, because the walk writes `checked`
584
+ as it goes and asking from inside it would read the restore's own work: the
585
+ first box it ticks would make every later box of the group look
586
+ server-rendered, and a draft of two values would come back as one. A
587
+ non-checkbox control sharing the group's name additionally threw inside the
588
+ draft write, which is swallowed — the root then persisted nothing at all,
589
+ silently. On restore, such a control keeps what the server rendered whenever
590
+ the group has two or more contributors: the list records the values, not
591
+ which control each one came from, so replaying it would paste `freeform,news`
592
+ into a text field, an editor, or a contenteditable. A group of ONE
593
+ contributor has no such ambiguity — its single entry can only have come from
594
+ that control — so a plain field whose name merely ends in `[]`, the usual
595
+ shape for a list JS maintains, keeps its draft exactly as it did before
596
+ groups existed. A `<select multiple>` reads a list by matching option values,
597
+ which is only sound when the list is its own: sharing a group with another
598
+ contributor, it too keeps what the server rendered, since a text value that
599
+ happens to equal an option would otherwise select it.
600
+
601
+ Drafts written before this release are not discarded, but their group key is
602
+ no longer applied to the controls that read a list: it holds one boolean (or,
603
+ in a mixed group, whichever control wrote last), and applying that kept
604
+ causing damage for as long as the draft lived — by default seven days after
605
+ the upgrade. The damage differed by control. A checkbox group came back fully
606
+ ticked. A `<select multiple>` sharing the group's name lost its rendered
607
+ selection instead, because under `restore: "always"` the select branch skips
608
+ the "the server had a say" check and matches `Set{"true"}` against its
609
+ options, where nothing matches. The next snapshot replaces the key with the
610
+ list. Only that one key changed meaning, which is why `PERSIST_VERSION` stays
611
+ where it is: bumping it would also throw away the drafted prose of every form
612
+ that has no checkbox group at all.
613
+
444
614
  - **`reply.pending` kept its settle handle under
445
615
  `enqueue_after_transaction_commit = true` (#254).** The handle was captured in
446
616
  `Phlex::Reactive::Settles#serialize`, which reads a thread-local that lives
@@ -622,6 +792,18 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
622
792
 
623
793
  ### Changed
624
794
 
795
+ - **`on_client` emits a binding record (#271).** `data-reactive-ops-param` now
796
+ holds `{"on":…,"ops":[…], "window"?, "outside"?, "confirm"?, "confirmWhen"?}`
797
+ instead of the bare `[[op, args]]` list, with every space written as
798
+ `\u0020`. `on_client` no longer writes the element-wide
799
+ `data-reactive-outside-param` / `-window-param` / `-confirm-param` /
800
+ `-confirm-when-param` (`on(...)` still does). A hand-built legacy
801
+ `[[op, args]]` attribute still runs, reading its flags from those params as
802
+ before. **Upgrade note:** apps served by the engine's importmap pin get the
803
+ matching client automatically; an app that bundles its own copy of
804
+ `reactive_controller.js` must rebuild from the shipped source, or its
805
+ `on_client` triggers go dead (the old client parses only arrays).
806
+
625
807
  - **Client build toolchain: bun 1.3.14 → 1.4.0.** `.bun-version`, the root `engines.bun`
626
808
  floor, and the docs `packageManager` pin all move together. The shipped
627
809
  `*.min.js` / `.map` artifacts (and their vendored twins under
data/README.md CHANGED
@@ -385,7 +385,7 @@ Use in controllers: `render turbo_stream: Counter.replace(counter)`.
385
385
  | `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). |
386
386
  | `on(:action, once: true)` | Fire at most once, then unbind (Stimulus's native `:once`). |
387
387
  | `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. See [Client-only ops](#client-only-ops-on_client--js--zero-round-trips). |
388
- | `js` | The immutable op builder behind `on_client`: `show`/`hide`/`toggle` (the `hidden` attribute, with an optional `transition:`), `add_class`/`remove_class`/`toggle_class`, `set_attr`/`remove_attr`/`toggle_attr` (allowlisted names), `focus`/`focus_first`, `text` (set `textContent` — XSS-safe), `dispatch`, `submit` (requestSubmit the target's own form), `paste_into` (read the clipboard into a field, gesture-gated), and `persist_state`/`persist_clear` (the `reactive_persist` draft) — chainable. |
388
+ | `js` | The immutable op builder behind `on_client`: `show`/`hide`/`toggle` (the `hidden` attribute, with an optional `transition:` and `expanded:`), `add_class`/`remove_class`/`toggle_class`, `set_attr`/`remove_attr`/`toggle_attr` (allowlisted names; `toggle_attr` also flips between two values), `focus`/`focus_first`, `text` (set `textContent` — XSS-safe), `dispatch`, `submit` (requestSubmit the target's own form), `paste_into` (read the clipboard into a field, gesture-gated), and `persist_state`/`persist_clear` (the `reactive_persist` draft) — chainable. |
389
389
  | `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)) { … }`. |
390
390
  | `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). |
391
391
  | `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). |
@@ -393,10 +393,10 @@ Use in controllers: `render turbo_stream: Counter.replace(counter)`.
393
393
  | `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). |
394
394
  | `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. |
395
395
  | `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). |
396
- | `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. |
396
+ | `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. |
397
397
  | `reactive_tags(:tags)` | **Tag-chip input** (the combobox/tags widget): spread onto the root and name the hidden field that stores the **comma-joined** value — the client maintains that field + the chip list entirely client-side (form state, zero round trips), rebuilding chips from your server-owned `<template>`. Composes with `reactive_filter` (type to narrow) and `reactive_listnav` (Enter picks the highlighted option). `name:` is the escape hatch — a **verbatim** wire name (`name: "user[tags]"`, never re-scoped), the form-builder case. See [Tag-chip input](#tag-chip-input-reactive_tags). |
398
398
  | `reactive_tags_add` / `reactive_tags_option(tag)` / `reactive_tags_remove(tag)` | The tags triggers, all **client-only**: `reactive_tags_add` on the query input adds the typed text on Enter (mix it **after** `reactive_listnav`; Enter never submits the enclosing form); `reactive_tags_option` makes a preloaded suggestion add its declared tag on click; `reactive_tags_remove` is a chip's remove button (no arg inside the template — the client fills the tag per chip). |
399
- | `reactive_compute :name, inputs: { title: :string, qty: :number }, outputs:` | **Typed** inputs: a `:string` reaches the JS reducer raw, a `:number` is coerced through `Number`. The array form (`inputs: %i[a b]`) stays all-numeric; the **permit-style** form (`inputs: [:qty, title: :string]`) mixes both — bare symbols default `:number`, a trailing hash types the exceptions. `outputs:` is the field allowlist; a reducer-result key also paints any owned `reactive_text` node by presence and any `mirror:` id, so an `outputs:` entry that exists only to reach a text node is redundant (harmless — a widening). |
399
+ | `reactive_compute :name, inputs: { title: :string, qty: :number }, outputs:` | **Typed** inputs: a `:string` reaches the JS reducer raw, a `:number` is coerced through `Number`, a `:boolean` is a checkbox's checked state. The array form (`inputs: %i[a b]`) stays all-numeric; the **permit-style** form (`inputs: [:qty, title: :string]`) mixes both — bare symbols default `:number`, a trailing hash types the exceptions. `outputs:` is the field allowlist; a reducer-result key also paints any owned `reactive_text` node by presence and any `mirror:` id, so an `outputs:` entry that exists only to reach a text node is redundant (harmless — a widening). |
400
400
  | `reactive_compute :name, ..., mirror: { sum: "#summary-sum" }` | **Cross-root text mirrors**: paint a compute value into declared, id-allowlisted nodes **outside** the reactive root (a recap in another tab pane) via `textContent` — no bespoke listener. See [Cross-root mirrors](#cross-root-mirrors-mirror--painting-a-recap-outside-the-root). |
401
401
  | `reactive_dirty` / `reactive_dirty warn_unsaved: true` / `reactive_dirty only: %i[...]` | **Dirty tracking**, declared once at the class level, against the DOM's own `defaultValue`/`defaultChecked`/`defaultSelected` — no client state. Marks changed fields + the root `data-reactive-dirty`; `warn_unsaved:` arms a `beforeunload`/`turbo:before-visit` guard; `only:` scopes tracking to named fields. Style with `[data-reactive-dirty]`. See [Dirty-field tracking](#dirty-field-tracking-reactive_dirty). |
402
402
  | `nested_update!(:assoc, attrs)` | Map a nested param onto `<assoc>_attributes` with id preservation; update the record. |
@@ -491,12 +491,55 @@ def view_template
491
491
  end
492
492
  ```
493
493
 
494
- > **One multipart caveat:** `FormData` can't carry an *empty* array or hash, so on
495
- > the multipart (file-present) path an empty `[]`/`{}` param is **omitted** and the
496
- > action's keyword default applies — it does **not** arrive as an explicit empty
497
- > collection the way it does over JSON. If you rely on sending `tags: []` to clear
498
- > a collection, send that action *without* a file (the JSON path). A non-empty
499
- > nested/array param rides along fine next to a file.
494
+ > **Clearing a checkbox group next to a file.** When the reactive root holds a
495
+ > file, the action goes out as `FormData`, and `FormData` has no way to write an
496
+ > empty array. A group with values is fine: each value goes out as
497
+ > `params[name][]`. A group with nothing ticked would simply be missing, so the
498
+ > client lists its name in a separate field, `empty_groups[]`, and the endpoint
499
+ > sets that param to `[]`. The action gets the same value it would get over JSON.
500
+ >
501
+ > This works for any param the action declares as an array, by its plain name or
502
+ > with the component's `reactive_scope` in front. A `features[]` group is
503
+ > announced as `features`, and a `todo[tags][]` group under `reactive_scope :todo`
504
+ > as `todo[tags]`. Under a scope you write the group's name by hand, because
505
+ > `reactive_field(:"tags[]")` gives `todo[tags[]]`, which isn't read as a group:
506
+ >
507
+ > ```ruby
508
+ > input(type: "checkbox", name: "todo[tags][]", value: "ruby") # reactive_scope :todo
509
+ > ```
510
+ >
511
+ > The endpoint only fills a key the body didn't send, and only at the top level.
512
+ > A group declared one level down (`params: { project: { features: [:string] } }`)
513
+ > or inside nested attributes is left alone, and the action gets its keyword
514
+ > default as before. With `verbose_errors` on, the log tells you when that happens.
515
+ >
516
+ > Other empty `[]` or `{}` params are still left out of a form body. If you rely
517
+ > on `tags: []` to clear something that isn't a `[]` group, send that action
518
+ > without a file.
519
+
520
+ **Checkbox groups.** Controls sharing a name that ends in `[]` are collected
521
+ as an **array of the chosen values** — a ticked box contributes its `value`, an
522
+ unticked one nothing, a `<select multiple>` its selected options. Nothing ticked is
523
+ an empty array, not a missing key, so an action can tell a cleared group from one
524
+ that never rendered, over a form body as well (see above). Declare it as an
525
+ array type:
526
+
527
+ ```ruby
528
+ action :save, params: { features: [:string] } # <input type="checkbox" name="features[]" value="news">
529
+ ```
530
+
531
+ Three shapes keep their own meaning: a lone checkbox without `[]` stays the
532
+ documented yes/no boolean; a radio group keeps its single checked value, `[]` or
533
+ not; and a hidden input sharing a name with a checkbox is that box's **companion**
534
+ (Rails' `check_box` emits one, carrying the `unchecked_value`) and contributes
535
+ nothing. A hidden input *without* a same-named checkbox is an ordinary value — the
536
+ usual shape for a list maintained by JS.
537
+
538
+ The suffix is the only trigger, and it applies to a single control too: a lone
539
+ `<input type="text" name="tags[]">` posts `["abc"]` where it used to post
540
+ `"abc"`. Declare such a param as an array type (`tags: [:string]`) — against a
541
+ flat `tags: :string` the array coerces to its literal `to_s`. If the `[]` was
542
+ never meant as a list, drop it from the name.
500
543
 
501
544
  **Array & nested params.** Wrap a type in an array for an array param, or a hash
502
545
  schema in an array for Rails-style nested attributes — so one reactive action can
@@ -849,7 +892,10 @@ button(**on_client(:click, js
849
892
  ```
850
893
 
851
894
  - **`set_attr(to, name, value)` / `remove_attr(to, name)` / `toggle_attr(to, name)`**
852
- mutate an attribute. The **attribute name is allowlisted, enforced twice** — at
895
+ mutate an attribute. `toggle_attr(to, name, on, off)` (#271) flips **between two
896
+ values** instead of toggling presence — `toggle_attr("#trigger", "aria-expanded",
897
+ "true", "false")`; an absent attribute becomes `on`, and one value or two equal
898
+ values raise at build time. The **attribute name is allowlisted, enforced twice** — at
853
899
  build time in Ruby (an offending name raises) and again in the client
854
900
  interpreter (a hand-built op is warned and skipped). Refused: **event handlers**
855
901
  (`on*` → XSS), **URL-bearing** names (`href`, `src`, `srcdoc`, `action`,
@@ -897,6 +943,14 @@ button(**on_client(:click, js
897
943
  (a broadcast that reads every subscriber's clipboard would be hostile). The
898
944
  op is async fire-and-forget — chained siblings apply immediately, never
899
945
  waiting for the read.
946
+ - **`expanded:`** on `show`/`hide`/`toggle` (#271) keeps a disclosure trigger's
947
+ `aria-expanded` honest: `js.toggle("#menu", expanded: "#trigger")` writes the
948
+ op's **intended** state (`show` → `"true"`, `hide` → `"false"`, `toggle` → the
949
+ state after the flip: `"true"` when the target was hidden, `"false"` when it
950
+ was visible) onto the target — set before any transition runs. It resolves
951
+ with the op's own scoping (`:root`, a root-scoped selector, or document-wide
952
+ under `global: true`). Pair it with one disclosure target; with several, the
953
+ last one wins.
900
954
  - **`transition: { during:, from:, to: }`** on `show`/`hide`/`toggle` animates the
901
955
  visibility flip: `during`+`from` are applied, then `from`→`to` swaps on the next
902
956
  frame, and the helper classes are cleaned up on `animationend` (with a timeout
@@ -907,6 +961,32 @@ button(**on_client(:click, js
907
961
  modifiers: the dropdown above closes on any click outside the component, and
908
962
  window-bound triggers never `preventDefault`, so links elsewhere keep working.
909
963
 
964
+ **Several bindings on one element (#271).** Each `on_client` call emits one
965
+ self-describing **binding record** — its descriptor, flags, `confirm:` and ops —
966
+ so two calls compose through `mix`. The descriptors token-join; the records
967
+ space-join (spaces inside a record ride as `\u0020`); the client runs only the
968
+ record whose descriptor matches the firing event (event type, key filter,
969
+ window-boundness):
970
+
971
+ ```ruby
972
+ div(**mix(reactive_root,
973
+ on_client(:click, js.hide("#menu", expanded: "#trigger"), outside: true),
974
+ on_client("keydown.esc", js.hide("#menu", expanded: "#trigger").focus("#trigger"))))
975
+ # data-action="click@window->reactive#runOps keydown.esc->reactive#runOps"
976
+ # data-reactive-ops-param='{"on":"click","window":true,"outside":true,"ops":[…]} {"on":"keydown.esc","ops":[…]}'
977
+ ```
978
+
979
+ An outside guard or a `confirm:` belongs to its own binding only — it never
980
+ gates a sibling. Two element-bound `:click` calls on the same `<button>` still
981
+ collide on the forced `type="button"` (`mix` renders `type="button button"`):
982
+ chain the ops into one call instead.
983
+
984
+ **An accessible menu with no custom JS.** Pair the above with
985
+ `reactive_listnav(focus: true)` on the `role="menu"` list — Arrow Down/Up rove
986
+ real focus among `[role=menuitem]` (wrapping), Home/End jump to the edges — and
987
+ the whole WAI-ARIA menu-button pattern is declared ops. See the
988
+ [client-only ops example](https://phlex-reactive.zoolutions.llc/docs/example-client-ops).
989
+
910
990
  **The general autosubmit story.** With `submit` in the vocabulary, the classic
911
991
  `onchange="this.form.requestSubmit()"` filter form is one declared line — no
912
992
  bespoke controller, no reactive action, and Turbo Drive turns the resulting
@@ -1244,6 +1324,29 @@ setComputeReducer("preview", ({ title }) => ({
1244
1324
  **permit-style form** (`inputs: [:qty, title: :string]`) combines both in one
1245
1325
  declaration — bare symbols default to `:number`, a trailing hash types the
1246
1326
  exceptions.
1327
+ - **Checkboxes and radios read their checked state.** A checkbox's `.value` is a
1328
+ constant, so a compute reads whether the box is ticked instead, coerced by the
1329
+ declared type: `1`/`0` as a `:number` (and in the array form),
1330
+ `"true"`/`"false"` as a `:string`, `true`/`false` as a **`:boolean`**. The
1331
+ checkbox wins over the hidden companion Rails' `check_box` renders before it.
1332
+ A radio group reads its checked radio's value (`""`, or `0` as a number, when
1333
+ none is checked). A `:boolean` on any other control is `false` for `""`, `"0"`
1334
+ and `"false"`.
1335
+
1336
+ ```ruby
1337
+ reactive_compute :total, inputs: [:price, { gift_wrap: :boolean }], outputs: %i[total]
1338
+ ```
1339
+
1340
+ ```js
1341
+ setComputeReducer("total", ({ price, gift_wrap }) => ({ total: price + (gift_wrap ? 25 : 0) }))
1342
+ ```
1343
+
1344
+ An **output** that resolves to a checkbox sets `checked` from the result's
1345
+ truthiness, and one that resolves to a radio group checks the radio carrying
1346
+ the result (a value no radio carries clears the group). Neither rewrites a
1347
+ `value` attribute, so what the control submits is unchanged. A `[]`-named
1348
+ checkbox *group* is not a compute input — compute values are scalars; declare
1349
+ each box under its own name.
1247
1350
  - **`reactive_text(:name, initial)`** mirrors a value into a **text node** via
1248
1351
  `textContent` (XSS-safe by construction). Every reducer-result key paints any
1249
1352
  matching sink: an owned **field** if declared in `outputs:`, any owned
@@ -2569,6 +2672,44 @@ bug). It emits:
2569
2672
  3. an inert `reactive:token` refresh, so the container's signed token rolls
2570
2673
  forward and the list is not act-once-only.
2571
2674
 
2675
+ #### Honest pending markup: `pending_template` (opt-in)
2676
+
2677
+ The markers dim a *live* row. When the pending state should be real markup — a
2678
+ "Queued" badge, a button that is genuinely gone rather than hidden by
2679
+ `pointer-events: none` — define `pending_template` on the row component (private
2680
+ is fine). `reply.pending` then also **replaces** each such row with it, just
2681
+ before marking it:
2682
+
2683
+ ```ruby
2684
+ class TransferRow < ApplicationComponent
2685
+ include Phlex::Reactive::Streamable
2686
+ include Phlex::Reactive::Component
2687
+
2688
+ def view_template
2689
+ li(**mix(reactive_attrs, id:)) { span { @transfer.label }; button(**on(:retry)) { "Retry" } }
2690
+ end
2691
+
2692
+ private
2693
+
2694
+ def pending_template
2695
+ li(**mix(reactive_attrs, id:)) { span { @transfer.label }; span(class: "badge") { "Queued" } }
2696
+ end
2697
+ end
2698
+ ```
2699
+
2700
+ A row without the hook is byte-identical to the marker-only reply.
2701
+
2702
+ - **Keep the root's `id`.** The settle targets it; a `pending_template` without
2703
+ `id="<the row's id>"` raises before anything is enqueued.
2704
+ - **Keep `reactive_attrs`** if the row carries its own token — the swapped row
2705
+ signs the row class's real name, so its next action verifies as usual.
2706
+ - **Un-pending is the settle's job.** Markers are attributes any settle strips;
2707
+ swapped markup is only undone by a settle that replaces (`s.replace`) or
2708
+ removes (`s.remove`) the row. A flash-only settle, `finish: true`, or a failed job clears the markers
2709
+ but leaves the pending markup in place.
2710
+ - It is a *template* method, unlike `deferred_placeholder` (which returns inner
2711
+ content for a shell the gem owns): here the row's own root is swapped.
2712
+
2572
2713
  | `reply.pending(...)` | |
2573
2714
  |---|---|
2574
2715
  | `records` | one record, an enumerable of records, or built Streamable components |
@@ -3222,8 +3363,10 @@ endpoint maps it to 403). Matchers: `have_reactive_replace`,
3222
3363
  refresh so a reply that would silently break the next click fails your test.
3223
3364
 
3224
3365
  **HTTP helpers** — `post_reactive_action(component_or_class, act, params:, payload:)`
3225
- and `post_reactive_multipart(...)` POST a signed token to
3226
- `Phlex::Reactive.action_path` exactly as the client does. **Token minting** —
3366
+ and `post_reactive_multipart(..., empty_groups: [])` POST a signed token to
3367
+ `Phlex::Reactive.action_path` exactly as the client does; `empty_groups:` lists
3368
+ the groups the client cleared by name, without the `[]`, the way a form body
3369
+ sends them. **Token minting** —
3227
3370
  `reactive_token_for(component_or_class, payload = {})`.
3228
3371
 
3229
3372
  > `verbose_errors` defaults ON in test (it changes only an error BODY, never a
@@ -556,6 +556,7 @@ module Phlex
556
556
  def coerce_params(action_def, component_class: nil, action_name: nil)
557
557
  dropped = Phlex::Reactive.verbose_errors ? [] : nil
558
558
  raw = unwrap_scope(params.fetch(:params, {}), component_class)
559
+ raw = apply_empty_groups(raw, action_def.schema, component_class, dropped) if params[:empty_groups]
559
560
 
560
561
  coerced = action_def.schema.coerce(raw, dropped)
561
562
  log_dropped_params(dropped, action_def.params, component_class, action_name)
@@ -570,7 +571,7 @@ module Phlex
570
571
  # raw params pass through untouched (unscoped components + nested_attributes
571
572
  # shapes are unaffected).
572
573
  def unwrap_scope(raw, component_class)
573
- scope = component_class.reactive_scope if component_class.respond_to?(:reactive_scope)
574
+ scope = reactive_scope_of(component_class)
574
575
  return raw unless scope
575
576
 
576
577
  # At the endpoint `raw` is ActionController::Parameters, so `raw[scope]` is
@@ -580,6 +581,68 @@ module Phlex
580
581
  nested.is_a?(Hash) || nested.is_a?(ActionController::Parameters) ? nested : raw
581
582
  end
582
583
 
584
+ # Issue #258: a form body cannot carry an empty array, so the client
585
+ # ANNOUNCES a cleared `[]` group — its key absent from params, its name in
586
+ # `empty_groups[]` beside token/act/params. Those names are written back as
587
+ # empty arrays, the value the JSON path sends outright. The README documents
588
+ # what the rule accepts and what it leaves alone.
589
+ #
590
+ # The key written is whatever `array_param` hands back, so it is always a
591
+ # key the DECLARATION holds — never the announced name, which is only ever
592
+ # compared. Runs AFTER unwrap_scope so the params root is the only target:
593
+ # no node is built to reach a group, hence no nested-attributes row.
594
+ def apply_empty_groups(raw, schema, component_class, dropped)
595
+ names = params[:empty_groups]
596
+ return raw unless names.is_a?(Array)
597
+ # `raw` is whatever arrived: a String from `params=x` in a query string
598
+ # normalises to {} in coerce, but writing into it would raise.
599
+ return raw unless raw.is_a?(Hash) || raw.is_a?(ActionController::Parameters)
600
+
601
+ scope = reactive_scope_of(component_class)
602
+ # Flat is enough — only the root is written — and it keeps the filled key
603
+ # off the request's own params object.
604
+ raw = raw.dup
605
+ names.each do
606
+ key = announced_key(it, schema, scope)
607
+ # Its OWN reason, not :undeclared: that one routes through the #16/#21
608
+ # shape hints, which would read `empty_groups` as a param and advise
609
+ # nesting the group under it. `dropped` is nil unless verbose_errors.
610
+ next dropped&.<<(["empty_groups #{it}", ANNOUNCED_UNDECLARED]) unless key
611
+
612
+ raw[key] = [] unless raw.key?(key)
613
+ end
614
+ raw
615
+ end
616
+
617
+ ANNOUNCED_UNDECLARED = :"undeclared — the action declares no array param by that name"
618
+ private_constant :ANNOUNCED_UNDECLARED
619
+
620
+ # The declared key an announced name resolves to, or nil — bare (`tags`) or
621
+ # carrying the component's scope (`todo[tags]`), the two shapes the client
622
+ # emits for one group. Anything else resolves to nothing, whatever its
623
+ # type: array_param only answers with a key it already holds.
624
+ def announced_key(name, schema, scope)
625
+ schema.array_param(unscoped_group_name(name.to_s, scope))
626
+ end
627
+
628
+ # `todo[tags]` => `tags` under `reactive_scope :todo`. One level off the
629
+ # front, never a walk — a deeper name (todo[a][tags]) keeps its remaining
630
+ # brackets and simply fails the lookup.
631
+ def unscoped_group_name(name, scope)
632
+ return name unless scope
633
+
634
+ prefix = "#{scope}["
635
+ return name unless name.start_with?(prefix) && name.end_with?("]")
636
+
637
+ name.delete_prefix(prefix).delete_suffix("]")
638
+ end
639
+
640
+ # Read the way unwrap_scope reads it — the peel and this strip have to
641
+ # agree on the scope or an announced name lands at the wrong depth.
642
+ def reactive_scope_of(component_class)
643
+ component_class.reactive_scope if component_class.respond_to?(:reactive_scope)
644
+ end
645
+
583
646
  # ---- verbose_errors dropped-param logging --------------------------
584
647
  # ParamSchema collects the dropped entries; the controller formats the ONE
585
648
  # warn line (with the #16/#21 shape hints). Everything below runs ONLY when
@@ -615,7 +678,7 @@ module Phlex
615
678
  segments = bracket_path(path)
616
679
  if segments.length > 1
617
680
  leaf = segments.last
618
- return unless schema.key?(leaf.to_sym)
681
+ return unless declared_key?(schema, leaf)
619
682
 
620
683
  "schema declares :#{leaf} at top level; nested schemas look like " \
621
684
  "{ #{segments.first}: { #{leaf}: :string } }"
@@ -628,12 +691,18 @@ module Phlex
628
691
  end
629
692
  end
630
693
 
694
+ # A schema declares `name` whether it was written with a symbol or a
695
+ # string key; ParamSchema.compile keeps whichever the author used.
696
+ def declared_key?(schema, name)
697
+ schema.key?(name.to_sym) || schema.key?(name.to_s)
698
+ end
699
+
631
700
  # The first schema key whose nested hash (or array-of-hash element
632
701
  # schema) declares `name` one level down.
633
702
  def nested_declaration_of(name, schema)
634
703
  schema.find do |_key, type|
635
704
  inner = type.is_a?(Array) ? type.first : type
636
- inner.is_a?(Hash) && inner.key?(name.to_sym)
705
+ inner.is_a?(Hash) && declared_key?(inner, name)
637
706
  end&.first
638
707
  end
639
708
 
@@ -28,13 +28,21 @@
28
28
  // issue #104) arrive as the RAW string. `reactive_compute :x, inputs:
29
29
  // { title: :string, qty: :number }` is what selects per-input types;
30
30
  // `inputs: %i[a b]` stays all-numeric (backward compatible).
31
+ // A CHECKBOX arrives as its checked state, never its constant
32
+ // .value (issue #262): 1/0 untyped or :number, "true"/"false" as a
33
+ // :string, true/false as a :boolean. A radio group arrives as its
34
+ // checked radio's value ("" / 0 when none is checked).
31
35
  // meta — { changed }: the name (string) of the declared input the
32
36
  // triggering event edited, or null (a direct recompute() call, or a
33
37
  // target this root doesn't own / didn't declare as an input).
34
38
  //
35
39
  // OUTPUTS may be a form FIELD or a TEXT NODE (issue #104). An output whose name
36
40
  // matches an owned control writes its .value (+ the change-guarded input
37
- // dispatch below). An output with NO matching field writes textContent to every
41
+ // dispatch below) — or, for a checkbox or a radio group, its CHECKED state
42
+ // (issue #262): a truthy result ticks the box ("", "0", "false", 0 and false
43
+ // untick it), and a radio group checks the radio carrying the result. Their
44
+ // value attributes are never rewritten, so what they submit is unchanged.
45
+ // An output with NO matching field writes textContent to every
38
46
  // owned [data-reactive-text="<name>"] node (reactive_text(:name)) — XSS-safe by
39
47
  // construction, change-guarded, NO input dispatch (a text node has no listener
40
48
  // contract). A declared INPUT also mirrors into its own text node on every