Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
5 changes: 3 additions & 2 deletions docs/Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions docs/Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion lib/forms/live.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 25 additions & 18 deletions lib/forms/tag_field.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 <form> 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 <form> 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 <form> 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.
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions spec/forms/live_tag_field_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 22 additions & 3 deletions spec/forms/tag_field_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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=&quot;user[tags]&quot;]"))
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.
Expand Down
Loading