diff --git a/Gemfile b/Gemfile index d3f90c9..36dd1fd 100644 --- a/Gemfile +++ b/Gemfile @@ -8,9 +8,10 @@ group :development, :test do gem "activemodel", "~> 8.0" # for model-bound / validation-inference specs gem "daisyui", ">= 1.2" # soft runtime dependency; present here to test the daisy theme # soft runtime dependency (gemspec has no hard dep); present here to test - # Forms::Live and the Forms::TagField tag primitives (>= 0.11.4 ships the - # reactive_tags client handlers the tag widget's wire contract targets). - gem "phlex-reactive", ">= 0.11.4" + # Forms::Live and the Forms::TagField tag primitives (>= 0.12.2 ships the + # reactive_tags(name:)/reactive_filter(input:) escape hatches the tag widget's + # instance-dynamic wire name needs — issue #6 Caveats 1 & 2). + gem "phlex-reactive", ">= 0.12.2" gem "debug" gem "gem-release" gem "rake" diff --git a/docs/Gemfile b/docs/Gemfile index 15cd35f..3a7cf02 100644 --- a/docs/Gemfile +++ b/docs/Gemfile @@ -15,8 +15,9 @@ gem "phlex-forms", path: ".." # Soft deps of phlex-forms, loaded here so the live docs examples render with # full styling (daisyui, already above) and the client-only tag/live behavior -# (phlex-reactive >= 0.11.4 ships the reactive_tags handlers the tag_field uses). -gem "phlex-reactive", ">= 0.11.4" +# (phlex-reactive >= 0.12.2 ships the reactive_tags(name:)/reactive_filter(input:) +# escape hatches the tag_field's instance-dynamic wire name uses — issue #6). +gem "phlex-reactive", ">= 0.12.2" # Expose these docs to AI agents over MCP (a read-only /mcp endpoint, drawn in # config/routes.rb), with rack-attack throttling the public AI/tooling diff --git a/docs/Gemfile.lock b/docs/Gemfile.lock index d965c46..76bcb0c 100644 --- a/docs/Gemfile.lock +++ b/docs/Gemfile.lock @@ -205,7 +205,7 @@ GEM phlex (~> 2.4.0) railties (>= 7.1, < 9) zeitwerk (~> 2.7) - phlex-reactive (0.11.5) + phlex-reactive (0.12.2) globalid (~> 1.0) phlex-rails (>= 2.0, < 3) railties (>= 7.1, < 9.0) @@ -376,7 +376,7 @@ DEPENDENCIES mcp phlex-forms! phlex-rails - phlex-reactive (>= 0.11.4) + phlex-reactive (>= 0.12.2) propshaft puma (>= 5.0) rack-attack @@ -468,7 +468,7 @@ CHECKSUMS phlex (2.4.1) sha256=e596717fbfe38b5271840266758779ebe75092e02629f0c170287e6290a70b12 phlex-forms (0.2.6) phlex-rails (2.4.0) sha256=2bcddbd488681acb25753bab1887d3ac150e644244ff8ba307f2171a4d0195f5 - phlex-reactive (0.11.5) sha256=9c51b4c3e108850a6e96fd021b2917d0d1d0b9683624718a0c5999eff958a384 + phlex-reactive (0.12.2) sha256=b8d4606ed20ba9e339d0fcf121badfe99ea803d10040321874e751392da9643f pp (0.6.4) sha256=dfcb0fce700c41456265922884f9fe195d7fbb0674a3578e6c0f69588e82b570 prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193 prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85 diff --git a/lib/forms/live.rb b/lib/forms/live.rb index 119a09b..e586e0f 100644 --- a/lib/forms/live.rb +++ b/lib/forms/live.rb @@ -168,10 +168,16 @@ def form_attributes # widget (rendered in the block) is driven by this root, which then DOM-owns # its hidden field. Name/id derived through field_name/field_id — the same # path the rootless render uses, so the [name=…]/#…_query selectors match. + # This form IS a reactive component, so it has reactive_tags/reactive_filter + # itself — the same 0.12.2 escape-hatch sugar Forms::TagField uses, emitting + # both filter selectors the client needs (issue #6 Caveats 1 & 2). tag = self.class.live_tags_declaration return attrs unless tag - mix(attrs, Forms::TagField.root_tag_attributes(name: field_name(tag[:name]), id: field_id(tag[:name]))) + query_id = Forms::TagField.query_id(field_id(tag[:name])) + mix(attrs, + reactive_tags(name: field_name(tag[:name])), + reactive_filter(input: "##{query_id}")) end # Untouched fields get no error set, so nothing flashes before the user diff --git a/lib/forms/tag_field.rb b/lib/forms/tag_field.rb index 62af0ec..b202a31 100644 --- a/lib/forms/tag_field.rb +++ b/lib/forms/tag_field.rb @@ -18,11 +18,13 @@ module Forms # The reactive_tags_* client helpers require phlex-reactive >= 0.11.4. # # It uses the reactive_tags_add/option/remove helpers for the chip/query/option - # behavior, but emits the ROOT's `data-reactive-tags-field` raw rather than via - # reactive_tags(:tags): that helper compiles a SYMBOL through the class-level - # reactive_scope, but a form builder's wire name is per-instance ("user[tags]"). - # The data attribute IS the public contract; any CSS selector works (issue #6 - # Caveats 1 & 2). Likewise the query input targets by #id so it never submits. + # behavior, and the ROOT's wire attrs come from the 0.12.2 escape-hatch sugar: + # reactive_tags(name: @name) takes the per-instance wire name verbatim ("user[tags]") + # — the class-level reactive_scope compile can't express it — validated at render; + # reactive_filter(input: "#…_query") targets the query input by id so it never + # submits, and (unlike the old raw -input-only attr) also emits + # data-reactive-filter-option so the 0.12.x client type-ahead actually runs + # (issue #6 Caveats 1 & 2). class TagField < Phlex::HTML include Phlex::Reactive::ClientBindings @@ -48,21 +50,26 @@ def view_template end end - # The root's tag wire attrs. Raw, not reactive_tags(:tags)/reactive_filter(:q) - # (Caveats 1 & 2): target the hidden field by [name=…] and the query input by - # #id (an id selector means the query input never submits a stray param). - # Public so Forms::Live can hoist these onto the
root when the widget - # is lifted rootless. - def self.root_tag_attributes(name:, id:) - { data: { - reactive_tags_field: %([name="#{name}"]), - reactive_filter_input: "##{id}_query" - } } - end + # The query input's id — the reactive_filter(input:) target and the id the + # search input itself carries. Public so Forms::Live can derive the same id + # when it hoists the tag wire attrs onto the root (rootless widget). + def self.query_id(id) = "#{id}_query" private - def root_tag_attributes = self.class.root_tag_attributes(name: @name, id: @id) + # The root's tag wire attrs, via the 0.12.2 escape-hatch sugar (Caveats 1 & 2): + # reactive_tags(name:) takes the instance-dynamic wire name verbatim and + # validates it at render; reactive_filter(input:) targets the query input by + # #id (so it never submits a stray param) and emits both filter selectors the + # 0.12.x client needs to run the type-ahead. Both are private helpers from + # ClientBindings — Forms::Live has its own copies (it's a reactive component) + # and calls them directly when hoisting these onto the root. + def root_tag_attributes + mix( + reactive_tags(name: @name), + reactive_filter(input: "##{self.class.query_id(@id)}") + ) + end # The widget body WITHOUT its root wrapper — shared with the rootless variant # so chip/template/suggestion markup never drifts between the two. @@ -93,7 +100,7 @@ def current_tags = @value.split(",").map(&:strip).reject(&:empty?) def query_attributes { - id: "#{@id}_query", type: "search", autocomplete: "off", + id: self.class.query_id(@id), type: "search", autocomplete: "off", placeholder: @placeholder, class: input_classes, "aria-invalid": @error || nil }.compact diff --git a/spec/forms/live_tag_field_spec.rb b/spec/forms/live_tag_field_spec.rb index 224345f..6656eb4 100644 --- a/spec/forms/live_tag_field_spec.rb +++ b/spec/forms/live_tag_field_spec.rb @@ -52,6 +52,10 @@ def fields expect(form_tag).to include("data-reactive-tags-field=") expect(form_tag).to include("tagged_post[tags]") expect(form_tag).to include("data-reactive-filter-input=") + # Both filter selectors hoist (reactive_filter(input:), not the raw + # -input-only attr) so the 0.12.x client type-ahead runs on the live + # form root too (issue #6 Caveats 1 & 2). + expect(form_tag).to include(%(data-reactive-filter-option="[role=option]")) end it "renders the tag widget ROOTLESS (no nested reactive root) so the form owns the hidden field" do diff --git a/spec/forms/tag_field_spec.rb b/spec/forms/tag_field_spec.rb index 60b5712..531ccb1 100644 --- a/spec/forms/tag_field_spec.rb +++ b/spec/forms/tag_field_spec.rb @@ -33,13 +33,32 @@ end end - describe "the wire contract (raw data attributes, not the sugar helpers)" do + describe "the wire contract (the 0.12.2 escape-hatch sugar)" do it "targets the hidden field by [name=...] on the root" do - # instance-dynamic wire name: the raw attr is the contract, the symbol - # sugar can't express user[tags] (issue #6 Caveat 1) + # instance-dynamic wire name via reactive_tags(name: @name) — the 0.12.2 + # escape hatch that the symbol sugar can't express (issue #6 Caveat 1); + # validated at render (see the malformed-name example below). expect(output).to include(%(data-reactive-tags-field="[name="user[tags]"]")) end + it "emits both filter selectors so the client type-ahead actually runs" do + # reactive_filter(input:) emits reactive-filter-input AND + # reactive-filter-option; the 0.12.x client #syncFilter early-returns + # unless BOTH are present, so the raw -input-only workaround left + # filtering dead. This is the regression guard (issue #6 Caveats 1 & 2). + expect(output).to include(%(data-reactive-filter-input="#user_tags_query")) + expect(output).to include(%(data-reactive-filter-option="[role=option]")) + end + + it "validates the wire name at render (reactive_tags name: escape hatch)" do + # A wire name with a double quote would break the [name="…"] CSS selector + # the client queries with; verbatim_name_selector! fails loudly at render + # instead of silently mis-binding in the browser. + expect do + render_component(described_class.new(name: 'user[tags"]', id: "t", value: [])) + end.to raise_error(ArgumentError, /double quote/) + end + it "marks the reactive root, tags list, and chip template" do # Phlex emits valueless boolean data attributes (data-reactive-tags-list, # not =...="true"); the client keys on presence.