From 469ba58eb76dc45308d134bfb5eacaa0a1acf3ee Mon Sep 17 00:00:00 2001 From: mhenrixon Date: Sat, 11 Jul 2026 14:50:48 +0200 Subject: [PATCH] =?UTF-8?q?feat(component):=20instance-dynamic=20wire=20na?= =?UTF-8?q?mes=20=E2=80=94=20keyword=20escape=20hatches=20on=20the=20field?= =?UTF-8?q?-compiling=20helpers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A form builder's wire name is computed per instance (user[tags]), which the class-level reactive_scope compile can't express — the gap that forced phlex-forms' tag_field draft (mhenrixon/phlex-forms#6) onto raw data attrs, losing render-time validation. Every field-compiling helper now takes a verbatim keyword escape hatch (never re-scoped, validated at render, mutually exclusive with the blessed field form): - reactive_tags(name: "user[tags]") - reactive_filter(input: "#tags_query") — a raw CSS selector for the deliberately NAME-LESS query input (re-blesses the kwarg removed in #186, narrowed to this escape-hatch purpose) - nested_field_name(:items, :qty, scope: "order") — per-call prefix, wins over reactive_scope - reactive_nested_list(:items, as: :json, name: "order[items]") Server-side sugar only: the client already resolves these attributes as arbitrary root-scoped selectors; existing calls emit a byte-identical wire. Hardened beyond the plan after adversarial review: verbatim names reject backslashes/control chars (a raw newline makes Chromium's querySelectorAll throw during connect, breaking the whole root), and the kwargs dispatch on nil-presence so `input: cond && "#sel"` with a false condition fails loudly instead of emitting a silent dead binding. ## Test Coverage - unit: verbatim compile, never-re-scoped under reactive_scope, blank/ quote/backslash/control/boolean rejection, mutual exclusion, name: without as: :json, bracketed scope: - system (Puma + Falcon): the form-builder-shaped demo — id-targeted name-less query input filters/adds/removes client-side; a real submit carries user[tags] comma-joined with ZERO stray params ## Verification - [x] bundle exec rubocop passes (gem + docs app) - [x] bundle exec rspec spec/phlex spec/requests — 1381 passed - [x] bundle exec rspec spec/system — full suite green (Puma); new spec also green under CAPYBARA_SERVER=falcon - [x] rake build:js_check — no client drift (no JS changes by design) Closes #224 --- CHANGELOG.md | 20 ++ README.md | 46 ++++- .../views/docs/pages/draft_rows_new_parent.rb | 10 + lib/phlex/reactive/component/helpers.rb | 161 ++++++++++++--- .../components/form_tags_field_component.rb | 110 +++++++++++ .../dummy/app/controllers/demos_controller.rb | 13 ++ spec/dummy/config/routes.rb | 2 + spec/phlex/reactive/component_spec.rb | 186 +++++++++++++++++- spec/system/tags_field_form_spec.rb | 85 ++++++++ 9 files changed, 602 insertions(+), 31 deletions(-) create mode 100644 spec/dummy/app/components/form_tags_field_component.rb create mode 100644 spec/system/tags_field_form_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 0dcc774..1de2f1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,26 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Added +- **Instance-dynamic wire names — keyword escape hatches on the + field-compiling helpers (#224).** A form builder's wire name is computed per + instance (`user[tags]`), which the class-level `reactive_scope` compile can't + express — the gap that forced phlex-forms' `tag_field` draft + (mhenrixon/phlex-forms#6) onto raw data attrs. Every helper that compiles a + field name now takes a verbatim keyword escape hatch (never re-scoped, + validated at render, mutually exclusive with the blessed field form): + `reactive_tags(name: "user[tags]")`, `reactive_filter(input: "#tags_query")` + (a raw CSS selector — the deliberately **name-less** query input inside a + real form, targeted by id, so it can never submit a stray param), + `nested_field_name(:items, :qty, scope: "order")` (a per-call parent prefix + that wins over `reactive_scope`), and + `reactive_nested_list(:items, as: :json, name: "order[items]")` (the hidden + JSON sync field, JSON mode only). Server-side sugar only — the client already + resolves these attributes as arbitrary root-scoped selectors; the shipped + wire for existing calls is byte-identical. Note: `reactive_filter(input:)` + deliberately re-blesses the kwarg removed in #186 — the field form stays the + blessed default, and a pre-0.10 `input:`/`option:` call shape is valid again + with identical semantics instead of raising the removal error. + - **Project board — the kanban flagship demo (#216).** A live three-lane board on the docs site (`/docs/example-project-board`, `/demos/project-board`) composing the toolkit end to end: each card is its own nested reactive root diff --git a/README.md b/README.md index 3111116..a0df455 100644 --- a/README.md +++ b/README.md @@ -390,17 +390,17 @@ Use in controllers: `render turbo_stream: Counter.replace(counter)`. | `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). | | `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). | | `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). | -| `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. See [Client-side option filtering](#client-side-option-filtering-reactive_filter). | +| `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). | | `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_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 `