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 +4 -4
- data/CHANGELOG.md +182 -0
- data/README.md +155 -12
- data/app/controllers/phlex/reactive/actions_controller.rb +72 -3
- data/app/javascript/phlex/reactive/compute.js +9 -1
- data/app/javascript/phlex/reactive/compute.min.js.map +2 -2
- data/app/javascript/phlex/reactive/confirm_predicate.js +7 -1
- data/app/javascript/phlex/reactive/confirm_predicate.min.js.map +2 -2
- data/app/javascript/phlex/reactive/inspect.js +21 -9
- data/app/javascript/phlex/reactive/inspect.min.js +2 -2
- data/app/javascript/phlex/reactive/inspect.min.js.map +3 -3
- data/app/javascript/phlex/reactive/reactive_controller.js +676 -69
- data/app/javascript/phlex/reactive/reactive_controller.min.js +2 -2
- data/app/javascript/phlex/reactive/reactive_controller.min.js.map +3 -3
- data/lib/phlex/reactive/component/dsl.rb +7 -0
- data/lib/phlex/reactive/component/helpers.rb +69 -22
- data/lib/phlex/reactive/component.rb +2 -1
- data/lib/phlex/reactive/engine.rb +1 -0
- data/lib/phlex/reactive/js.rb +44 -10
- data/lib/phlex/reactive/param_schema.rb +16 -0
- data/lib/phlex/reactive/pending/markup.rb +128 -0
- data/lib/phlex/reactive/pending.rb +25 -21
- data/lib/phlex/reactive/test_helpers.rb +10 -3
- data/lib/phlex/reactive/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ce768022637774ec42b7bca32e6533613fb563c792d9c82f58c8b07eea722e7f
|
|
4
|
+
data.tar.gz: 42a470435e59c6a364f3845a4626bdbb0b8e23a1cc7aadcb0c2281b89c922555
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
-
> **
|
|
495
|
-
> the
|
|
496
|
-
>
|
|
497
|
-
>
|
|
498
|
-
>
|
|
499
|
-
>
|
|
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.
|
|
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(
|
|
3226
|
-
`Phlex::Reactive.action_path` exactly as the client does
|
|
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
|
|
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
|
|
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) &&
|
|
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)
|
|
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
|