Skip to content

tag_field: composed from phlex-reactive 0.11.4's reactive_tags primitives (draft) #6

Description

@mhenrixon

Context

phlex-reactive 0.11.4 ships the unstyled tag-chip behavior primitives (zoolutions/phlex-reactive#203, PR zoolutions/phlex-reactive#206): reactive_tags (root names a hidden comma-joined field the client maintains), reactive_tags_add (Enter adds typed text), reactive_tags_option (click a suggestion), reactive_tags_remove (chip remove), composing with reactive_filter + reactive_listnav. The agreed split: phlex-reactive owns the behavior contract, phlex-forms owns the polished tag_field — label/error/hint chrome, daisyUI styling, model binding. This issue is that draft, to judge the composition end-to-end.

The widget is CLIENT-ONLY (Phlex::Reactive::ClientBindings — form state, no token, zero round trips), so it follows the Forms::Live soft-dependency pattern: loaded only when Phlex::Reactive is defined, loader.ignored otherwise.

Usage target

f.field :tags, as: :tags, suggestions: %w[Ruby Rails Hotwire Postgres]
# or with haystacks (synonyms the filter matches):
f.field :tags, as: :tags, suggestions: { "Postgres" => "postgres database db sql" }

Submits ONE comma-joined param (user[tags] = "Ruby,Rails"), the primitive's wire contract — the model splits (attribute :tags + a tags=(v) = super(v.is_a?(String) ? v.split(",") : v) writer, or the cosmos Taggable concern).

The leaf component (sketch)

# lib/forms/tag_field.rb — ignored when Phlex::Reactive is absent (the Forms::Live gate)
module Forms
  class TagField < Phlex::HTML
    include Phlex::Reactive::ClientBindings

    def initialize(*modifiers, name:, id:, value: nil, suggestions: [], error: false,
                   placeholder: "Add a tag…", **attributes)
      @modifiers = modifiers
      @name = name                                   # "user[tags]" — INSTANCE-dynamic (see caveat 1)
      @id = id
      @value = value.is_a?(Array) ? value.compact.join(",") : value.to_s
      @suggestions = suggestions.is_a?(Hash) ? suggestions : suggestions.to_h { [it, it.downcase] }
      @error = error
      @placeholder = placeholder
      @attributes = attributes
      super()
    end

    def view_template
      div(**mix(
        reactive_root(id: "#{@id}_widget"),
        # RAW wire attrs, not the reactive_tags/reactive_filter sugar: the helpers
        # compile a SYMBOL through the component's class-level reactive_scope, but a
        # form builder's wire name is per-instance ("user[tags]"). The data attrs
        # are the public contract; any CSS selector works. (Caveat 1 below.)
        { data: {
          reactive_tags_field: %([name="#{@name}"]),
          reactive_filter_input: "##{@id}_query"      # by ID → the query never submits (caveat 2)
        } },
        class: "tag-field"
      )) do
        input(type: :hidden, name: @name, id: @id, value: @value)

        div(class: "flex flex-wrap gap-1", data: { reactive_tags_list: true }) do
          current_tags.each { chip(it) }              # server-rendered first paint
        end
        template(data: { reactive_tags_template: true }) { chip }

        input(**mix(reactive_listnav, reactive_tags_add,
          id: "#{@id}_query", type: "search", autocomplete: "off",
          placeholder: @placeholder, class: input_classes))   # NO name → never submits

        ul(class: "menu bg-base-200 rounded-box") do
          @suggestions.each do |tag, haystack|
            li do
              button(**mix(reactive_tags_option(tag),
                data: { reactive_filter_text: haystack })) { tag }
            end
          end
        end
      end
    end

    private

    def current_tags = @value.split(",").map(&:strip).reject(&:empty?)

    # One method, both forms: with a tag = server-rendered chip; without = the
    # template prototype (the client fills text + remove param per clone).
    def chip(tag = nil)
      span(class: "badge badge-primary gap-1", data: { reactive_tag: tag }) do
        span(data: { reactive_tag_text: true }) { tag }
        button(**(tag ? reactive_tags_remove(tag) : reactive_tags_remove),
          aria: { label: "Remove" }, class: "cursor-pointer") { "×" }
      end
    end

    def input_classes = PhlexForms::ClassMerge.merge("input w-full", @attributes[:class])
  end
end

Wiring (the ChoicesSelect precedent)

  • Forms::Field#tag_field mirroring #select (field.rb:79): theme[:tag_field].new(suggestions:, value: field_value, **field_attributes_minus_value_shape)
  • Builder#render_field_input (builder.rb:161): when :tags then render fo.tag_field(...)
  • Theme roles in PhlexForms::Theme.daisy/.plain + a Forms::Plain::TagField (aria-invalid, no daisy classes)
  • Chrome comes free: f.field already wraps in fo.control → Label + FieldError/FieldHint

Caveats surfaced by drafting (the end-to-end judgment)

  1. Sugar gap in phlex-reactive: reactive_tags(:tags)/reactive_filter(:q) compile a symbol via class-level reactive_scope — no way to express an instance-dynamic wire name (user[tags]). Raw data attrs work today (they ARE the contract) but skip the render-time blank/comma validation. Possible upstream follow-up: accept a raw string form, e.g. reactive_tags(name: @name) / reactive_filter(input: "#id")-style escape hatch.
  2. The query input should have NO name inside a real POST form (or it submits a stray param). The reactive_filter sugar only compiles [name=…] selectors; targeting the input by id via the raw attr avoids the stray param entirely. Upstream sugar could allow it.
  3. Inside a Forms::Live form: the tag widget is a NESTED reactive root, so the outer live-validation root's #collectFields skips the hidden tags field (issue fix: radio value clobbering + validation controller path resolution (#13 #12) #15 ownership) — live validate won't see the tags value. Acceptable for v1 (tags rarely need live validation); worth a README note.
  4. Value casting: phlex-forms could optionally ship the tags= split writer (or an ActiveModel::Type) so f.field :tags, as: :tags works against a plain attribute :tags, array: true with zero model code.

Refs zoolutions/phlex-reactive#203

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions