Module: PhlexForms::Builder

Included in:
Forms::FieldsForBuilder, Forms::Form
Defined in:
lib/phlex_forms/builder.rb

Overview

The shared form-builder surface, included by Forms::Form and Forms::FieldsForBuilder so the two no longer copy-paste the API.

Two layers:

* The Control-first primary verb `field` — renders label + input +
error/hint in one call, inferring the input type and the `required` flag
from the model (structure, column types, validators — see
PhlexForms::Inference) with the attribute-name map as tiebreaker.

* Lower-level escape hatches (`Input`, `Select`, `Textarea`, `Checkbox`,
`Toggle`, `FileInput`, `Hidden`, `Label`, `Control`, `submit`) with the
same signatures they had before extraction, for when a caller needs full
control (custom Stimulus wiring, bespoke layout, ...).

Hosts mix this in and provide: render, model, scope, errors, and a field(name)-style field_object that returns a Forms::Field for a name.

Constant Summary collapse

INPUT_TYPE_MODIFIERS =

Positional symbols that name an input type (so f.Input(:x, :email) works).

%i[
  text email password tel url search number date time datetime
  color range file hidden month week
].freeze
INPUT_TYPE_INFERENCE =

Attribute-name -> input type inference. One canonical map (previously duplicated and divergent across Form and Field).

{
  email: :email,
  password: :password, password_confirmation: :password,
  current_password: :password, new_password: :password,
  new_password_confirmation: :password,
  phone: :tel, telephone: :tel, tel: :tel, mobile: :tel,
  url: :url, website: :url, homepage: :url,
  search: :search, query: :search,
  date: :date, birthday: :date, birthdate: :date, born_on: :date,
  started_at: :date, ended_at: :date,
  time: :time,
  datetime: :datetime, timestamp: :datetime,
  color: :color, colour: :color
}.freeze

Instance Method Summary collapse

Instance Method Details

#Checkbox(name, *modifiers, **options) ⇒ Object



155
156
157
158
# File 'lib/phlex_forms/builder.rb', line 155

def Checkbox(name, *modifiers, **options)
  fo = field_object(name)
  render fo.checkbox(*modifiers, **fo.apply_validations(options))
end

#Control(name) ⇒ Object



189
190
191
# File 'lib/phlex_forms/builder.rb', line 189

def Control(name, **, &)
  render field_object(name).control(**, &)
end

#field(name, *modifiers, label: nil, hint: nil, as: nil, required: nil, choices: nil, **options) ⇒ Object

Render a complete field: label + control-wrapped input + error/hint.

f.field :email                          # type=email, label auto, required inferred
f.field :notify                         # boolean column -> toggle
f.field :role                           # AR enum -> select, humanized
f.field :country                        # belongs_to -> select over the association
f.field :bio, as: :textarea, rows: 6    # explicit as: always wins
f.field :name, :primary, label: "Full name", hint: "As on your ID"

Options:

label:    label text (defaults to the model's humanized attribute name;
        pass false to omit the label)
hint:     help text shown when there is no error
as:       override the rendered control (:select, :textarea, :toggle,
        :checkbox, :file, :hidden, :rich_textarea, and text-like types)
required: force the required flag (otherwise inferred from validations)
choices:  choices for :select (implies as: :select when given)

Remaining positional modifiers/keywords pass through to the inner input.



67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
# File 'lib/phlex_forms/builder.rb', line 67

def field(name, *modifiers, label: nil, hint: nil, as: nil, required: nil,
          choices: nil, **options)
  modifiers = default_field_variants + modifiers
  inferred = PhlexForms::Inference.resolve(model:, name:, as:, modifiers:, choices:)
  # error_name: for a rewritten field (:country -> :country_id) errors still
  # live on the association name.
  fo = field_object(inferred.name, error_name: name)
  req = required.nil? ? (fo.required? || inferred.required == true) : required
  label_text = label == false ? nil : (label || inferred.label || fo.field_label)
  options = inferred.attributes.merge(options)
  if inferred.multiple
    options[:multiple] = true unless options.key?(:multiple)
    options[:name] ||= "#{fo.field_name}[]"
  end
  options = fo.apply_validations(options)
  choices ||= materialize_choices(inferred.choices)

  # A checkbox_group renders div[role="group"], which a plain <label for> can't
  # name. Give the Control's label/hint stable ids (control_opts) and point the
  # group at them with a passed-through aria: { labelledby:, describedby: }
  # (group_opts), reusing the Control's own visible chrome (issue #17).
  control_opts, group_opts = group_aria(fo, inferred.as, label_text, hint)
  options = options.merge(group_opts)

  render fo.control(label: label_text, hint:, required: req, **control_opts) do
    render_field_input(fo, inferred.name, inferred.as, modifiers, choices:, required: req, **options)
  end
end

#FileInput(name, *modifiers, **options) ⇒ Object



169
170
171
172
# File 'lib/phlex_forms/builder.rb', line 169

def FileInput(name, *modifiers, **options)
  fo = field_object(name)
  render fo.file(*modifiers, **fo.apply_validations(options))
end

#group(legend: nil) ⇒ Object

A fieldset with a legend for sectioning related fields. f.group(legend: "Address") { f.field :street }



129
130
131
# File 'lib/phlex_forms/builder.rb', line 129

def group(legend: nil, **, &)
  render theme[:group].new(legend:, **), &
end

#group_aria(fo, as, label_text, hint) ⇒ Object

For a checkbox_group field: the stable ids to stamp on the Control's label/hint (control_opts), and a group aria: hash pointing back at them (group_opts) — passed through to the group div, no special leaf API. Returns [{}, {}] for every other field type (a label associates via for/id, no aria needed) and when neither label nor hint is present.



101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
# File 'lib/phlex_forms/builder.rb', line 101

def group_aria(fo, as, label_text, hint)
  return [{}, {}] unless as == :checkbox_group

  control_opts = {}
  aria = {}
  if label_text
    control_opts[:label_id] = "#{fo.field_id}_label"
    aria[:labelledby] = control_opts[:label_id]
  end
  if hint
    control_opts[:hint_id] = "#{fo.field_id}_hint"
    aria[:describedby] = control_opts[:hint_id]
  end
  [control_opts, aria.empty? ? {} : { aria: }]
end

#Hidden(name) ⇒ Object



174
175
176
# File 'lib/phlex_forms/builder.rb', line 174

def Hidden(name, **)
  render field_object(name).hidden(**)
end

#hidden_field(name) ⇒ Object

Rails FormBuilder-compatible hidden field, for straight migration of form.hidden_field(:token, value: x) call sites. Same model-bound path as Hidden; an explicit value: wins over the model's current value.



181
182
183
# File 'lib/phlex_forms/builder.rb', line 181

def hidden_field(name, **)
  render field_object(name).hidden(**)
end

#Input(name, *modifiers, **options) ⇒ Object


Escape-hatch component API (stable signatures)



137
138
139
140
141
142
143
# File 'lib/phlex_forms/builder.rb', line 137

def Input(name, *modifiers, **options)
  fo = field_object(name)
  type = resolve_input_type(name, modifiers)
  type = :"datetime-local" if type == :datetime
  remaining = modifiers - INPUT_TYPE_MODIFIERS
  render fo.input(*remaining, type:, **fo.apply_validations(options))
end

#Label(name, text = nil, *modifiers) ⇒ Object



185
186
187
# File 'lib/phlex_forms/builder.rb', line 185

def Label(name, text = nil, *modifiers, **, &)
  render field_object(name).label(text, *modifiers, **, &)
end

#Radio(name, value, *modifiers) ⇒ Object



160
161
162
# File 'lib/phlex_forms/builder.rb', line 160

def Radio(name, value, *modifiers, **)
  render field_object(name).radio(value, *modifiers, **)
end

#row(columns: 2) ⇒ Object

Side-by-side fields in a responsive grid (stacked on mobile). f.row { f.field :first_name; f.field :last_name }



123
124
125
# File 'lib/phlex_forms/builder.rb', line 123

def row(columns: 2, **, &)
  render theme[:row].new(columns:, **), &
end

#Select(name, *modifiers, choices: nil, **options) ⇒ Object



145
146
147
148
# File 'lib/phlex_forms/builder.rb', line 145

def Select(name, *modifiers, choices: nil, **options)
  fo = field_object(name)
  render fo.choices_select(choices, *modifiers, **fo.apply_validations(options))
end

#Textarea(name, *modifiers, **options) ⇒ Object



150
151
152
153
# File 'lib/phlex_forms/builder.rb', line 150

def Textarea(name, *modifiers, **options)
  fo = field_object(name)
  render fo.textarea(*modifiers, **fo.apply_validations(options))
end

#Toggle(name, *modifiers, **options) ⇒ Object



164
165
166
167
# File 'lib/phlex_forms/builder.rb', line 164

def Toggle(name, *modifiers, **options)
  fo = field_object(name)
  render fo.toggle(*modifiers, **fo.apply_validations(options))
end