phlex-forms 0.2.2 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c814b751d3d8dd050595b4d29aa7a261dd0b5dbb15787912a58e676fe09d76a9
4
- data.tar.gz: 1c9bb1ff0df9584b34a7e9e67a4698a04569d985b0406f9e9d79e60d2abe160f
3
+ metadata.gz: 74bd32547d30535c35060ea6c93f9cfd199614a9ced2cf1a95a7c32fe62eb3e6
4
+ data.tar.gz: cf0087d9bee7cb13366ef489fb8764c82867b406881934eb92cdb7dbc271d0f8
5
5
  SHA512:
6
- metadata.gz: 6d86f0ca8fa5497a118b5e137e9bd23b67a68b4db6076cad19a38d6dc4bad4e3d42a5b3958077666e5f1370658b6069dc9787d816c3904c4717c5f5725d7cac0
7
- data.tar.gz: fbe09ab98824da11331b9a6d4abaf502356bb617157b12a68a82096eab790b2d5e794bd2457e8ccd94651e418b69dbab76317e3bfb6542f7dfe7d60d37479d25
6
+ metadata.gz: 33980ae64283b549a82ab25bd445df927616ac7bc5d0929c41aba13c10439d795c631268e6154bd64a5c0b9783baee4573e7e4efdeef7ecd3e8ac4633791b1bd
7
+ data.tar.gz: d4d4efd19dd0dbde90311922d91040052925a87948cdcd95d42b50049028186a6cd1b7b80a8b7027154cadd67e8801ad242777817a1ab214d13088e40a5558b3
data/CHANGELOG.md CHANGED
@@ -9,6 +9,44 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
9
9
 
10
10
  ### Added
11
11
 
12
+ - **`checkbox_group` — batched checkbox group for array-valued fields** (the
13
+ tag/facet-picker shape): `f.checkbox_group(:tag_ids, Tag.all, value: :id,
14
+ label: :name, variant: :pill, size: :sm)`, or via field inference
15
+ (`f.field :tag_ids, as: :checkbox_group, collection: Tag.all, value: :id`).
16
+ Shares one array-valued field name with a leading empty-array hidden field,
17
+ derives the checked set from the model's current value, and renders under both
18
+ themes. `variant:` (`:stack`/`:inline`/`:pill`) is layout-only, no JS.
19
+
20
+ ### Changed
21
+
22
+ - **Client-side validation Stimulus identifiers dropped the `forms--` prefix**:
23
+ the bundled controllers now emit `validations--presence`, `validations--length`,
24
+ … (and the `validations--form` coordinator) so
25
+ `lazyLoadControllersFrom("phlex_forms/controllers")` resolves them to their
26
+ shipped path `phlex_forms/controllers/validations/*_controller` — previously
27
+ `forms--validations--*` derived `.../forms/validations/*`, which 404'd and the
28
+ controllers never connected (issue #12). The `data-validations--*` binding
29
+ attributes and the `invalidate:validations` event changed to match. Hosts that
30
+ registered `forms--validations--*` explicitly must update the identifier.
31
+
32
+ ### Fixed
33
+
34
+ - **`f.Radio` / `Field#radio` rendered the model's current value on every radio
35
+ instead of each radio's own value**: `field_attributes` carried `value:
36
+ field_value` and was splatted after the explicit positional value, clobbering
37
+ it — a new record lost the value entirely, an edit form gave every radio the
38
+ same value. `radio` now drops `field_attributes`' `value` (issue #13).
39
+ - **`Form(validate: true)` never fired client-side validation on submit**: the
40
+ coordinator controller was attached but no `data-action` wired its `onSubmit`
41
+ handler, so submitting an invalid form was not blocked. `apply_validation_coordinator`
42
+ now emits `submit->validations--form#onSubmit` (joined with any
43
+ caller-supplied `data-action`).
44
+ - **`fields_for` iterated a Hash-backed association (JSONB), emitting bogus
45
+ indices**: a Hash responds to `#each_with_index`, so a JSONB column rendered
46
+ with `nested_attributes: false` produced `scope[assoc][0][field]`, `[1]`, …
47
+ instead of a single `scope[assoc][field]`. It is now treated as a single
48
+ nested scope; only genuine collections (Enumerable, not Hash) iterate.
49
+
12
50
  - **`Forms::Base` declarative form classes**: subclass, declare fields in
13
51
  `#fields` where `self` IS the form (bare `field :email`, no `f.` prefix),
14
52
  render with `render UserForm.new(model: @user)`. Class-level `form_options`
data/README.md CHANGED
@@ -90,7 +90,7 @@ control wrapping the label, the input, and an error (or hint).
90
90
  | --- | --- |
91
91
  | `label:` | Label text. Defaults to the model's humanized attribute name. `label: false` omits it. |
92
92
  | `hint:` | Help text shown when there is no error. |
93
- | `as:` | Override the control: `:select`, `:textarea`, `:toggle`, `:checkbox`, `:file`, `:radio`, `:hidden`, `:rich_textarea`, or any text-like type. |
93
+ | `as:` | Override the control: `:select`, `:textarea`, `:toggle`, `:checkbox`, `:file`, `:radio`, `:hidden`, `:rich_textarea`, `:tags` (see [Tag fields](#tag-fields)), or any text-like type. |
94
94
  | `required:` | Force the required flag. Otherwise inferred from the model's presence validators. |
95
95
  | `choices:` | Choices for a select (implies `as: :select`). |
96
96
  | positional modifiers | daisyui variants — `:primary`, `:lg`, `:ghost`, … — stacked onto the input. |
@@ -154,6 +154,67 @@ end
154
154
 
155
155
  Both work on inline forms (`f.row { … }`) and inside `fields_for` builders.
156
156
 
157
+ ### Tag fields
158
+
159
+ `as: :tags` renders a polished tag/chip input — label + error/hint chrome and
160
+ daisyUI styling on top of
161
+ [phlex-reactive](https://github.com/mhenrixon/phlex-reactive)'s client-only tag
162
+ primitives (form state, no server round trips). **Requires phlex-reactive** (the `:tags` role is
163
+ registered only when it's loaded).
164
+
165
+ ```ruby
166
+ f.field :tags, as: :tags, suggestions: %w[Ruby Rails Hotwire Postgres]
167
+
168
+ # Hash form: the value is a "haystack" of synonyms the type-ahead filter matches
169
+ f.field :tags, as: :tags, suggestions: { "Postgres" => "postgres database db sql" }
170
+ ```
171
+
172
+ The widget submits **one comma-joined param** (`user[tags] = "Ruby,Rails"`) — the
173
+ primitive's wire contract. The visible type-ahead input carries **no `name`**, so
174
+ it never posts a stray param; only a hidden field does.
175
+
176
+ Have the model split the comma-joined string back into an array:
177
+
178
+ ```ruby
179
+ class Post < ApplicationRecord
180
+ attribute :tags, default: [] # a text[] / JSONB column, say
181
+
182
+ # accept the widget's "Ruby,Rails" and a normal Array alike
183
+ def tags=(value)
184
+ super(value.is_a?(String) ? value.split(",").map(&:strip).reject(&:empty?) : value)
185
+ end
186
+ end
187
+ ```
188
+
189
+ Notes:
190
+
191
+ - **Custom styling** — the leaf reads daisyUI classes from overridable seams
192
+ (`root_classes`, `chip_classes`, `menu_classes`, …). The plain theme's twin
193
+ (`Forms::Plain::TagField`) keeps the full client wire contract but ships zero
194
+ styling classes; the invalid state rides `aria-invalid` on the query input.
195
+ - **Inside a `live` form**, declare `live_tags` so the outer form validates the
196
+ tags too. A standalone tag widget is a *nested* reactive root, so the live
197
+ root would skip its hidden field — `live_tags` lifts the widget's wire
198
+ attributes onto the `<form>` root and renders the widget rootless, so the form
199
+ owns the hidden field and `:validate` sees the value:
200
+
201
+ ```ruby
202
+ class PostForm < Forms::Base
203
+ live model: Post
204
+ live_tags :tags, suggestions: %w[Ruby Rails Hotwire] # lift onto the form root
205
+
206
+ def fields
207
+ field :title
208
+ field :tags, as: :tags # renders rootless; the form validates it
209
+ submit :primary
210
+ end
211
+ end
212
+ ```
213
+
214
+ phlex-reactive's tag controller reads **one** tag field per root, so a live
215
+ form lifts **at most one** — a second `live_tags` raises. A second tag input
216
+ must stay a standalone (non-live) `field :other, as: :tags`.
217
+
157
218
  ## Escape hatches & custom widgets
158
219
 
159
220
  The lower-level component methods are always available with stable signatures:
@@ -276,6 +337,11 @@ import { lazyLoadControllersFrom } from "@hotwired/stimulus-loading"
276
337
  lazyLoadControllersFrom("phlex_forms/controllers", application)
277
338
  ```
278
339
 
340
+ The emitted identifiers are `validations--presence`, `validations--length`, … (and
341
+ the form-level `validations--form` coordinator), which `lazyLoadControllersFrom`
342
+ resolves to `phlex_forms/controllers/validations/*_controller` — the path the gem
343
+ ships them at.
344
+
279
345
  Messages ship for `en` / `fr` / `af`; override via `window.PhlexForms.messages`.
280
346
 
281
347
  ## Nested attributes, collections & escape valves
@@ -290,13 +356,29 @@ f.fields_for(:settings, nested_attributes: false) do |s|
290
356
  end
291
357
 
292
358
  f.collection_check_boxes(:role_ids, Role.all, :id, :name) do |b|
293
- render b.check_box
359
+ render b.check_box # per-item control, full custom layout
294
360
  render b.label
295
361
  end
296
362
 
363
+ # The batched "tag/facet picker" shape: one array-valued field name, checked
364
+ # state derived from the model (record.tag_ids), sensible defaults, no block.
365
+ f.checkbox_group(:tag_ids, Tag.all, value: :id, label: :name)
366
+ f.checkbox_group(:tag_ids, Tag.all, value: :id,
367
+ label: ->(t) { t.name.presence || t.slug }, # Symbol method or Proc
368
+ variant: :pill, # :stack (default) | :inline | :pill
369
+ size: :sm) # daisyUI checkbox size
370
+ # ...or through field inference:
371
+ f.field :tag_ids, as: :checkbox_group, collection: Tag.all, value: :id, label: :name
372
+
297
373
  f.collection_select(:country_id, Country.all, :id, :name, prompt: "Select…")
298
374
  ```
299
375
 
376
+ `checkbox_group` submits an array param (`user[tag_ids][]`) with a leading
377
+ empty-array hidden field, so deselecting everything still submits. The checked
378
+ set comes from the model's current value matched by each item's resolved
379
+ `value:` — re-rendering an edit form pre-checks the right boxes. The `:pill`
380
+ variant styles the active chip with Tailwind's `has-[:checked]:` (no JS).
381
+
300
382
  `Form(model: @item, scope: false)` emits **bare** field names
301
383
  (`name="quantity"`) — the shape phlex-reactive row editors and
302
384
  `<template>`-cloned rows need. External widgets bind through the public
@@ -10,7 +10,7 @@ import { Controller } from "@hotwired/stimulus"
10
10
  // reads from data attributes; the base class only knows about the
11
11
  // `allowBlank` / `allowNil` short-circuits.
12
12
  export class FieldValidatorController extends Controller {
13
- // `error` is opt-in: callers that pre-render a `<p data-forms--validations--error-target="error">`
13
+ // `error` is opt-in: callers that pre-render a `<p data-validations--<validator>-target="error">`
14
14
  // get a stable slot the controller toggles. Inputs without an
15
15
  // explicit target still work — the controller lazily creates one
16
16
  // adjacent to the input below.
@@ -24,12 +24,12 @@ export class FieldValidatorController extends Controller {
24
24
  // directly to <input>, <textarea>, <select> via the form builder.
25
25
  connect() {
26
26
  this.element.addEventListener("blur", this.onBlur)
27
- this.element.addEventListener("invalidate:forms--validations", this.onValidate)
27
+ this.element.addEventListener("invalidate:validations", this.onValidate)
28
28
  }
29
29
 
30
30
  disconnect() {
31
31
  this.element.removeEventListener("blur", this.onBlur)
32
- this.element.removeEventListener("invalidate:forms--validations", this.onValidate)
32
+ this.element.removeEventListener("invalidate:validations", this.onValidate)
33
33
  }
34
34
 
35
35
  onBlur = () => {
@@ -150,7 +150,7 @@ export class FieldValidatorController extends Controller {
150
150
  // adjacent to the input. Keeps the framework usable on plain
151
151
  // forms that haven't opted into the static-target convention.
152
152
  const id = this.element.id || this.element.name
153
- const selector = `[data-forms--validations--error="${id}"]`
153
+ const selector = `[data-validations--error="${id}"]`
154
154
  const existing = this.element.closest("form")?.querySelector(selector)
155
155
  if (existing) return existing
156
156
  if (!create) return null
@@ -159,7 +159,7 @@ export class FieldValidatorController extends Controller {
159
159
  container.className = "text-error text-sm mt-1"
160
160
  // `dataset` rejects keys with `--`, so we set the attribute
161
161
  // directly. The CSS selector still matches.
162
- container.setAttribute("data-forms--validations--error", id)
162
+ container.setAttribute("data-validations--error", id)
163
163
  this.element.insertAdjacentElement("afterend", container)
164
164
  return container
165
165
  }
@@ -3,7 +3,7 @@ import { Controller } from "@hotwired/stimulus"
3
3
  // Form-level coordinator for the validation framework. Sits on the
4
4
  // <form> element and intercepts `submit` to broadcast a synchronous
5
5
  // validation event to every field. Each field controller listens
6
- // for `invalidate:forms--validations`, runs its check, and (on
6
+ // for `invalidate:validations`, runs its check, and (on
7
7
  // failure) appends its error to the event's `detail.errors` array.
8
8
  // If anything ended up in that array, we cancel the submit and
9
9
  // focus the first invalid field.
@@ -23,11 +23,11 @@ export default class extends Controller {
23
23
  fields.forEach((field) => {
24
24
  const validators = (field.dataset.controller || "")
25
25
  .split(/\s+/)
26
- .filter((c) => c.startsWith("forms--validations--") && c !== "forms--validations--form")
26
+ .filter((c) => c.startsWith("validations--") && c !== "validations--form")
27
27
  if (validators.length === 0) return
28
28
 
29
29
  field.dispatchEvent(
30
- new CustomEvent("invalidate:forms--validations", {
30
+ new CustomEvent("invalidate:validations", {
31
31
  detail: { errors },
32
32
  }),
33
33
  )
@@ -12,7 +12,7 @@ export default class extends FieldValidatorController {
12
12
  // Stimulus walks the prototype chain to accumulate `static values`
13
13
  // and `static targets`, so we only declare the validator-specific
14
14
  // ones here. `counter` is opt-in: callers that pre-render
15
- // `<span data-forms--validations--length-target="counter">` get a
15
+ // `<span data-validations--length-target="counter">` get a
16
16
  // stable slot the controller updates. Inputs without an explicit
17
17
  // target still get a lazily-injected one (see counterElement).
18
18
  static targets = ["counter"]
@@ -80,7 +80,7 @@ export default class extends FieldValidatorController {
80
80
 
81
81
  // Fallback: cache-by-id lookup or lazy injection for plain forms.
82
82
  const id = this.element.id || this.element.name
83
- const selector = `[data-forms--validations--counter="${id}"]`
83
+ const selector = `[data-validations--counter="${id}"]`
84
84
  const existing = this.element.closest("form")?.querySelector(selector)
85
85
  if (existing) return existing
86
86
  if (!create) return null
@@ -88,7 +88,7 @@ export default class extends FieldValidatorController {
88
88
  const counter = document.createElement("span")
89
89
  counter.className = "text-xs text-base-content/60 ml-auto"
90
90
  // `dataset` rejects keys containing `--`; use the raw attribute API.
91
- counter.setAttribute("data-forms--validations--counter", id)
91
+ counter.setAttribute("data-validations--counter", id)
92
92
  this.element.insertAdjacentElement("afterend", counter)
93
93
  return counter
94
94
  }
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Forms
4
+ # A batched checkbox group for an array-valued field (the tag/facet-picker
5
+ # shape). Renders a set of checkboxes sharing ONE array-valued field name
6
+ # (`user[tag_ids][]`), with a leading empty-array hidden field so an empty
7
+ # selection still submits, and derives each box's checked state from the
8
+ # resolved value: of its item against the model's current set.
9
+ #
10
+ # f.checkbox_group(:tag_ids, Tag.all, value: :id,
11
+ # label: ->(t) { t.name.presence || t.slug }, variant: :pill, size: :sm)
12
+ #
13
+ # value: method or proc -> the submitted value of each item (default :id)
14
+ # label: method or proc -> the visible text of each item (default :to_s)
15
+ # variant: :stack (default) | :inline | :pill — layout only, zero JS
16
+ # size: daisyUI checkbox size modifier (:xs :sm :md :lg :xl)
17
+ #
18
+ # The checked set is passed in pre-resolved by the builder (Field#checkbox_group
19
+ # matches the model's current value by each item's resolved value:), so the
20
+ # component itself stays presentation-only. Each checkbox's markup is delegated
21
+ # to DaisyUI::Checkbox so its size class is a literal, scanner-visible token.
22
+ class CheckboxGroup < Phlex::HTML
23
+ # variant -> the container class. The pill variant uses Tailwind's
24
+ # `has-[:checked]:` to style the active label with no JS.
25
+ VARIANT_CLASSES = {
26
+ stack: "flex flex-col gap-2",
27
+ inline: "flex flex-wrap gap-4",
28
+ pill: "flex flex-wrap gap-2"
29
+ }.freeze
30
+
31
+ def initialize(name:, id:, options:, variant: :stack, size: nil, error: false, **attributes)
32
+ @name = name # already the array name: "user[tag_ids][]"
33
+ @id = id
34
+ @options = options # [{ value:, label:, checked:, id: }, ...]
35
+ @variant = variant
36
+ @size = size
37
+ @error = error
38
+ @attributes = attributes
39
+ super()
40
+ end
41
+
42
+ def view_template
43
+ # Empty-array hidden field so an empty selection still submits (the same
44
+ # convention as collection_check_boxes).
45
+ input(type: "hidden", name: @name, value: "")
46
+
47
+ div(class: group_classes, role: "group", "aria-invalid": @error || nil) do
48
+ @options.each { |option| item(option) }
49
+ end
50
+ end
51
+
52
+ private
53
+
54
+ def item(option)
55
+ label(class: item_classes) do
56
+ render_checkbox(option)
57
+ span(class: item_label_classes) { option[:label].to_s }
58
+ end
59
+ end
60
+
61
+ # Delegate the checkbox markup to the daisyui gem so the size modifier
62
+ # resolves to a literal class (checkbox-sm, ...) the CSS scanner can see.
63
+ def render_checkbox(option)
64
+ render DaisyUI::Checkbox.new(
65
+ *checkbox_modifiers,
66
+ name: @name, id: option[:id], value: option[:value],
67
+ checked: option[:checked] || nil, class: @attributes[:class]
68
+ )
69
+ end
70
+
71
+ def checkbox_modifiers = @size ? [@size] : []
72
+
73
+ # --- styling seams (the Plain twin overrides these to bare/empty) ---
74
+
75
+ def group_classes = VARIANT_CLASSES.fetch(@variant, VARIANT_CLASSES[:stack])
76
+
77
+ def item_classes
78
+ return "label cursor-pointer gap-2 justify-start" unless @variant == :pill
79
+
80
+ "badge badge-lg cursor-pointer gap-2 has-[:checked]:badge-primary"
81
+ end
82
+
83
+ def item_label_classes = nil
84
+ end
85
+ end
data/lib/forms/field.rb CHANGED
@@ -67,11 +67,15 @@ module Forms
67
67
  end
68
68
 
69
69
  def radio(value, *modifiers, **options)
70
+ # field_attributes carries value: field_value (the model's CURRENT value).
71
+ # Drop it here so it can't clobber this radio's own positional value —
72
+ # otherwise every radio in the group renders the model's value (issue #13).
73
+ attrs = field_attributes.except(:value).merge(options)
70
74
  theme[:radio].new(
71
75
  *modifiers,
72
76
  value:,
73
77
  checked: field_value == value,
74
- **field_attributes.merge(options).merge(id: "#{field_id}_#{value}")
78
+ **attrs.merge(id: "#{field_id}_#{value}")
75
79
  )
76
80
  end
77
81
  alias radio_button radio
@@ -91,6 +95,40 @@ module Forms
91
95
  end
92
96
  end
93
97
 
98
+ # A model-bound tag/chip input (phlex-reactive client-only primitives).
99
+ # suggestions: an Array of tags or a Hash of tag => haystack (synonyms the
100
+ # filter matches). Submits one comma-joined param under the field name.
101
+ def tag_field(*modifiers, suggestions: [], **)
102
+ theme[:tag_field].new(
103
+ *modifiers,
104
+ name: field_name, id: field_id, value: field_value,
105
+ suggestions:, error: invalid?, **
106
+ )
107
+ end
108
+
109
+ # A model-bound checkbox group over a collection. Shares one array-valued
110
+ # field name (`scope[name][]`) and derives the checked set from the model's
111
+ # current value, matched by each item's resolved value: (issue #9).
112
+ #
113
+ # field.checkbox_group(Tag.all, value: :id, label: ->(t) { t.name })
114
+ #
115
+ # value:/label: are a method name (Symbol) or a proc taking the item.
116
+ def checkbox_group(collection, value: :id, label: :to_s, **)
117
+ # The model's current value is already the raw values (e.g. record.tag_ids
118
+ # => [1, 3]), so compare against them directly — don't re-resolve value:.
119
+ selected = Array(field_value)
120
+ opts = Array(collection).map do |item|
121
+ item_value = resolve_item(item, value)
122
+ {
123
+ value: item_value,
124
+ label: resolve_item(item, label),
125
+ checked: selected.include?(item_value),
126
+ id: "#{field_id}_#{item_value}"
127
+ }
128
+ end
129
+ theme[:checkbox_group].new(name: "#{field_name}[]", id: field_id, options: opts, error: invalid?, **)
130
+ end
131
+
94
132
  def label(text = nil, *modifiers, **, &block)
95
133
  theme[:label].new(*modifiers, text: text || (block ? nil : field_label), for: field_id, **, &block)
96
134
  end
@@ -218,6 +256,11 @@ module Forms
218
256
  validator.options.key?(:if) || validator.options.key?(:unless) || validator.options.key?(:on)
219
257
  end
220
258
 
259
+ # value:/label: for checkbox_group: a Proc taking the item, or a method name.
260
+ def resolve_item(item, accessor)
261
+ accessor.respond_to?(:call) ? accessor.call(item) : item.public_send(accessor)
262
+ end
263
+
221
264
  def field_attributes
222
265
  { name: field_name, id: field_id, value: field_value, error: invalid? }
223
266
  end
data/lib/forms/form.rb CHANGED
@@ -104,7 +104,7 @@ module Forms
104
104
  attributes_key = nested_attributes ? "#{association_name}_attributes" : association_name.to_s
105
105
  base_scope = @scope ? "#{@scope}[#{attributes_key}]" : attributes_key
106
106
 
107
- if associated.respond_to?(:each_with_index)
107
+ if collection?(associated)
108
108
  associated.each_with_index do |item, index|
109
109
  yield build_fields_for("#{base_scope}[#{index}]", item)
110
110
  end
@@ -133,6 +133,13 @@ module Forms
133
133
  end
134
134
  end
135
135
 
136
+ # A batched checkbox group for an array-valued field (issue #9). Delegates to
137
+ # Field#checkbox_group, which derives the checked set from the model.
138
+ # f.checkbox_group(:tag_ids, Tag.all, value: :id, label: :name, variant: :pill)
139
+ def checkbox_group(name, collection, **)
140
+ render field_object(name).checkbox_group(collection, **)
141
+ end
142
+
136
143
  # Rails-style collection_select over an enumerable of records.
137
144
  def collection_select(name, collection, value_method, text_method, options = {}, html_options = {})
138
145
  choices = collection.map do |item|
@@ -156,6 +163,15 @@ module Forms
156
163
 
157
164
  private
158
165
 
166
+ # A genuine has_many collection (Array / ActiveRecord::Relation), NOT a
167
+ # Hash-backed nested scope. A Hash responds to #each_with_index but is a
168
+ # single nested record (a JSONB column), so iterating it would emit bogus
169
+ # positional indices — scope[assoc][0][field] — instead of scope[assoc][field]
170
+ # (issue #10). Enumerable-but-not-Hash covers Relations without requiring AR.
171
+ def collection?(associated)
172
+ associated.is_a?(Enumerable) && !associated.is_a?(Hash)
173
+ end
174
+
159
175
  def build_fields_for(scope, item)
160
176
  Forms::FieldsForBuilder.new(
161
177
  model: item,
@@ -194,8 +210,16 @@ module Forms
194
210
  # UI (novalidate) — the Stimulus layer owns error display.
195
211
  def apply_validation_coordinator(attrs)
196
212
  existing = attrs[:data][:controller].to_s
197
- coordinator = "forms--validations--form"
213
+ # Derive the coordinator identifier from the introspector's prefix so the
214
+ # form-level and field-level controllers can never drift (issue #12).
215
+ coordinator = "#{Forms::Validations::Introspector::CONTROLLER_PREFIX}--form"
198
216
  attrs[:data][:controller] = [existing, coordinator].reject(&:empty?).join(" ")
217
+ # Wire the coordinator's submit handler. Without this data-action the
218
+ # controller connects but onSubmit never fires, so an invalid form is not
219
+ # blocked client-side (issue #11). Joined with any caller-supplied action.
220
+ existing_action = attrs[:data][:action].to_s
221
+ submit_action = "submit->#{coordinator}#onSubmit"
222
+ attrs[:data][:action] = [existing_action, submit_action].reject(&:empty?).join(" ")
199
223
  attrs[:novalidate] = true
200
224
  end
201
225
 
@@ -17,6 +17,30 @@ module Forms
17
17
 
18
18
  merged.merge(data: merge_data(merged[:data], @live_trigger[:data]))
19
19
  end
20
+
21
+ # When THIS field is the form's declared `live_tags` field, render the
22
+ # ROOTLESS variant: no nested reactive root, so the outer <form> DOM-owns
23
+ # the hidden tags field and live :validate collects it. Its wire attrs were
24
+ # hoisted onto the form root by Forms::Live#form_attributes. Any other tag
25
+ # field falls through to the standard (self-rooted, non-live) widget.
26
+ def tag_field(*modifiers, suggestions: [], **)
27
+ declaration = @form.class.respond_to?(:live_tags_declaration) && @form.class.live_tags_declaration
28
+ return super unless declaration && declaration[:name] == @name
29
+
30
+ # Call-site suggestions win; otherwise fall back to the declaration's.
31
+ suggestions = declaration[:suggestions] if blank_suggestions?(suggestions)
32
+ theme[:rootless_tag_field].new(
33
+ *modifiers,
34
+ name: field_name, id: field_id, value: field_value,
35
+ suggestions:, error: invalid?, **
36
+ )
37
+ end
38
+
39
+ private
40
+
41
+ def blank_suggestions?(suggestions)
42
+ suggestions.respond_to?(:empty?) ? suggestions.empty? : suggestions.nil?
43
+ end
20
44
  end
21
45
  end
22
46
  end
data/lib/forms/live.rb CHANGED
@@ -67,8 +67,38 @@ module Forms
67
67
  def live_permit(*attrs) = @live_permit = attrs.map(&:to_s)
68
68
  def live_deny(*attrs) = @live_deny = attrs.map(&:to_s)
69
69
 
70
+ # Lift a tag field onto the form's reactive root so live :validate sees its
71
+ # comma-joined value. The widget renders ROOTLESS (no nested reactive root),
72
+ # so the outer <form> DOM-owns the hidden tags field; its wire attrs ride on
73
+ # the form root (see #form_attributes). The declared tag name is
74
+ # auto-permitted for :validate assignment.
75
+ #
76
+ # class PostForm < Forms::Base
77
+ # live model: Post
78
+ # live_tags :tags, suggestions: %w[Ruby Rails]
79
+ # def fields = field(:tags, as: :tags)
80
+ # end
81
+ #
82
+ # phlex-reactive's tag controller reads ONE data-reactive-tags-field per
83
+ # root, so a live form lifts AT MOST ONE tag field — a second raises.
84
+ def live_tags(name, suggestions: [])
85
+ if @live_tags
86
+ raise ArgumentError,
87
+ "a live form can lift at most one tag field onto its reactive root " \
88
+ "(already declared live_tags #{@live_tags[:name].inspect}); render the " \
89
+ "second as a standalone `field #{name.inspect}, as: :tags` (it stays " \
90
+ "non-live)."
91
+ end
92
+
93
+ @live_tags = { name: name.to_sym, suggestions: }
94
+ end
95
+
96
+ def live_tags_declaration = @live_tags || inherited_live(:live_tags_declaration)
97
+
70
98
  def live_permitted_attributes(model)
71
99
  permitted = @live_permit || derived_live_attributes(model)
100
+ tag = live_tags_declaration
101
+ permitted |= [tag[:name].to_s] if tag
72
102
  permitted - (@live_deny || [])
73
103
  end
74
104
 
@@ -129,11 +159,19 @@ module Forms
129
159
  # per-element).
130
160
  def form_attributes
131
161
  attrs = super
132
- mix(
162
+ attrs = mix(
133
163
  attrs,
134
164
  reactive_root(id: attrs[:id] || id),
135
165
  on(:validate, event: "input", debounce: self.class.live_debounce)
136
166
  )
167
+ # Hoist a declared tag field's wire attrs onto the form root so the rootless
168
+ # widget (rendered in the block) is driven by this root, which then DOM-owns
169
+ # its hidden field. Name/id derived through field_name/field_id — the same
170
+ # path the rootless render uses, so the [name=…]/#…_query selectors match.
171
+ tag = self.class.live_tags_declaration
172
+ return attrs unless tag
173
+
174
+ mix(attrs, Forms::TagField.root_tag_attributes(name: field_name(tag[:name]), id: field_id(tag[:name])))
137
175
  end
138
176
 
139
177
  # Untouched fields get no error set, so nothing flashes before the user
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Forms
4
+ module Plain
5
+ # Bare checkbox group. Inherits the whole binding contract from
6
+ # Forms::CheckboxGroup (the shared array name, the empty-array hidden field,
7
+ # the per-item checked state) and overrides only the rendering seams to ship
8
+ # zero daisyUI classes. The invalid state rides aria-invalid on the group,
9
+ # never a color class.
10
+ class CheckboxGroup < Forms::CheckboxGroup
11
+ private
12
+
13
+ # Bare <input type=checkbox>, no DaisyUI delegation, no styling classes.
14
+ def render_checkbox(option)
15
+ input(
16
+ type: "checkbox", name: @name, id: option[:id],
17
+ value: option[:value], class: @attributes[:class],
18
+ checked: option[:checked] || nil
19
+ )
20
+ end
21
+
22
+ def group_classes = @attributes[:class]
23
+ def item_classes = nil
24
+ def item_label_classes = nil
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Forms
4
+ module Plain
5
+ # The rootless tag field under the plain theme: the wire contract of
6
+ # Forms::RootlessTagField with the bare-styling seams of Forms::Plain::TagField.
7
+ # Multiple-inheritance-free: subclass the rootless variant and re-apply the
8
+ # plain seam overrides.
9
+ class RootlessTagField < Forms::RootlessTagField
10
+ private
11
+
12
+ def root_classes = "tag-field"
13
+ def list_classes = nil
14
+ def menu_classes = nil
15
+ def option_classes = nil
16
+ def chip_classes = nil
17
+ def remove_classes = nil
18
+ def input_classes = @attributes[:class]
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Forms
4
+ module Plain
5
+ # Bare tag/chip input. Inherits the ENTIRE reactive wire contract from
6
+ # Forms::TagField (the client behavior must still work under the plain theme)
7
+ # and overrides only the styling seams to ship zero daisyUI classes. The
8
+ # invalid state rides aria-invalid on the query input, never a color class.
9
+ #
10
+ # Like its parent, this file autoloads only when Phlex::Reactive is present
11
+ # (it inherits from a ClientBindings-including class).
12
+ class TagField < Forms::TagField
13
+ private
14
+
15
+ def root_classes = "tag-field"
16
+ def list_classes = nil
17
+ def menu_classes = nil
18
+ def option_classes = nil
19
+ def chip_classes = nil
20
+ def remove_classes = nil
21
+ def input_classes = @attributes[:class]
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Forms
4
+ # A tag field WITHOUT its own reactive root — for use inside a Forms::Live form,
5
+ # which is itself the reactive root and carries the tag wire attributes
6
+ # (data-reactive-tags-field / data-reactive-filter-input, hoisted by
7
+ # Forms::Live#form_attributes). Emitting no nested root means the hidden tags
8
+ # field's nearest reactive-root ancestor is the <form>, so the outer form OWNS
9
+ # it and live :validate collects it (phlex-reactive #ownsField, issue #15).
10
+ #
11
+ # Reuses Forms::TagField's #body verbatim (chip/template/suggestion markup), so
12
+ # the two never drift; it only drops the root <div> wrapper.
13
+ #
14
+ # Autoloaded only when Phlex::Reactive is present (it inherits from a
15
+ # ClientBindings-including class).
16
+ class RootlessTagField < Forms::TagField
17
+ def view_template
18
+ # No reactive_root wrapper: a bare container that groups the body. The tag
19
+ # attrs live on the ancestor <form>, not here.
20
+ div(class: root_classes) { body }
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Forms
4
+ # A model-bound tag/chip input, composed from phlex-reactive's CLIENT-ONLY tag
5
+ # primitives (form state, no token, zero round trips). phlex-forms owns the
6
+ # polished chrome — daisyUI styling, model binding, the hidden comma-joined
7
+ # field — while phlex-reactive owns the behavior contract.
8
+ #
9
+ # f.field :tags, as: :tags, suggestions: %w[Ruby Rails Hotwire Postgres]
10
+ # f.field :tags, as: :tags, suggestions: { "Postgres" => "postgres database db sql" }
11
+ #
12
+ # Submits ONE comma-joined param (`user[tags] = "Ruby,Rails"`), the primitive's
13
+ # wire contract — the model splits it (an `attribute :tags` + a `tags=` writer,
14
+ # or an ActiveModel array type).
15
+ #
16
+ # This file is autoloaded ONLY when Phlex::Reactive is present (the Forms::Live
17
+ # gate in lib/phlex_forms.rb) because it includes ClientBindings at class level.
18
+ # The reactive_tags_* client helpers require phlex-reactive >= 0.11.4.
19
+ #
20
+ # It uses the reactive_tags_add/option/remove helpers for the chip/query/option
21
+ # behavior, but emits the ROOT's `data-reactive-tags-field` raw rather than via
22
+ # reactive_tags(:tags): that helper compiles a SYMBOL through the class-level
23
+ # reactive_scope, but a form builder's wire name is per-instance ("user[tags]").
24
+ # The data attribute IS the public contract; any CSS selector works (issue #6
25
+ # Caveats 1 & 2). Likewise the query input targets by #id so it never submits.
26
+ class TagField < Phlex::HTML
27
+ include Phlex::Reactive::ClientBindings
28
+
29
+ def initialize(*modifiers, name:, id:, value: nil, suggestions: [], error: false,
30
+ placeholder: "Add a tag…", **attributes)
31
+ @modifiers = modifiers
32
+ @name = name # "user[tags]" — instance-dynamic
33
+ @id = id
34
+ @value = value.is_a?(Array) ? value.compact.join(",") : value.to_s
35
+ @suggestions = normalize_suggestions(suggestions)
36
+ @error = error
37
+ @placeholder = placeholder
38
+ @attributes = attributes
39
+ super()
40
+ end
41
+
42
+ def view_template
43
+ # Standalone: THIS div is the reactive root. Inside a Forms::Live form the
44
+ # widget renders rootless (Forms::RootlessTagField) — the outer <form> is
45
+ # the root and carries these tag attrs — so the form owns the hidden field.
46
+ div(**mix(reactive_root(id: "#{@id}_widget"), root_tag_attributes, class: root_classes)) do
47
+ body
48
+ end
49
+ end
50
+
51
+ # The root's tag wire attrs. Raw, not reactive_tags(:tags)/reactive_filter(:q)
52
+ # (Caveats 1 & 2): target the hidden field by [name=…] and the query input by
53
+ # #id (an id selector means the query input never submits a stray param).
54
+ # Public so Forms::Live can hoist these onto the <form> root when the widget
55
+ # is lifted rootless.
56
+ def self.root_tag_attributes(name:, id:)
57
+ { data: {
58
+ reactive_tags_field: %([name="#{name}"]),
59
+ reactive_filter_input: "##{id}_query"
60
+ } }
61
+ end
62
+
63
+ private
64
+
65
+ def root_tag_attributes = self.class.root_tag_attributes(name: @name, id: @id)
66
+
67
+ # The widget body WITHOUT its root wrapper — shared with the rootless variant
68
+ # so chip/template/suggestion markup never drifts between the two.
69
+ def body
70
+ input(type: :hidden, name: @name, id: @id, value: @value)
71
+
72
+ div(class: list_classes, data: { reactive_tags_list: true }) do
73
+ current_tags.each { |tag| chip(tag) } # server-rendered first paint
74
+ end
75
+ template(data: { reactive_tags_template: true }) { chip }
76
+
77
+ # Enter adds free text; mix AFTER reactive_listnav so Enter prefers a
78
+ # highlighted option. NO name → never submits.
79
+ input(**mix(reactive_listnav, reactive_tags_add, query_attributes))
80
+
81
+ ul(class: menu_classes) do
82
+ @suggestions.each { |tag, haystack| suggestion(tag, haystack) }
83
+ end
84
+ end
85
+
86
+ def normalize_suggestions(suggestions)
87
+ return suggestions if suggestions.is_a?(Hash)
88
+
89
+ Array(suggestions).to_h { |tag| [tag, tag.to_s.downcase] }
90
+ end
91
+
92
+ def current_tags = @value.split(",").map(&:strip).reject(&:empty?)
93
+
94
+ def query_attributes
95
+ {
96
+ id: "#{@id}_query", type: "search", autocomplete: "off",
97
+ placeholder: @placeholder, class: input_classes,
98
+ "aria-invalid": @error || nil
99
+ }.compact
100
+ end
101
+
102
+ # A preloaded suggestion that adds its tag on click (reactive_tags_option
103
+ # forces type="button" + role="option" + the tagsPick action + the tag
104
+ # param). The filter haystack rides alongside via mix.
105
+ def suggestion(tag, haystack)
106
+ li do
107
+ button(**mix(reactive_tags_option(tag),
108
+ { class: option_classes, data: { reactive_filter_text: haystack } })) { tag }
109
+ end
110
+ end
111
+
112
+ # One method, both forms: with a tag = a server-rendered chip (its remove
113
+ # button carries the tag param); without = the <template> prototype (the
114
+ # client fills the text node + the remove button's tag param per clone).
115
+ def chip(tag = nil)
116
+ span(class: chip_classes, data: { reactive_tag: tag }) do
117
+ span(data: { reactive_tag_text: true }) { tag }
118
+ button(**mix(reactive_tags_remove(tag),
119
+ { class: remove_classes, aria: { label: "Remove" } })) { "×" }
120
+ end
121
+ end
122
+
123
+ # --- styling seams (the Plain twin overrides these to bare/empty) ---
124
+
125
+ def root_classes = "tag-field flex flex-col gap-2"
126
+ def list_classes = "flex flex-wrap gap-1"
127
+ def menu_classes = "menu bg-base-200 rounded-box"
128
+ def option_classes = nil
129
+ def chip_classes = "badge badge-primary gap-1"
130
+ def remove_classes = "cursor-pointer"
131
+ def input_classes = PhlexForms::ClassMerge.merge("input w-full", @attributes[:class])
132
+ end
133
+ end
@@ -13,7 +13,11 @@ module Forms
13
13
  # validation remains authoritative — the client side just
14
14
  # shortens the loop for the common cases.
15
15
  class Introspector
16
- CONTROLLER_PREFIX = "forms--validations"
16
+ # The Stimulus identifier prefix. Kept in sync with the file path the
17
+ # controllers ship at (phlex_forms/controllers/validations/*_controller),
18
+ # so lazyLoadControllersFrom("phlex_forms/controllers") resolves
19
+ # `validations--length` → .../validations/length_controller (issue #12).
20
+ CONTROLLER_PREFIX = "validations"
17
21
 
18
22
  # Validators we know how to mirror. Keys are the short class
19
23
  # name (without namespace), values are the controller suffix
@@ -59,9 +63,9 @@ module Forms
59
63
 
60
64
  # Returns a hash of the shape:
61
65
  # {
62
- # controller: "forms--validations--presence forms--validations--length",
63
- # forms__validations__presence_required_value: "true",
64
- # forms__validations__length_maximum_value: "60",
66
+ # controller: "validations--presence validations--length",
67
+ # validations__presence_required_value: "true",
68
+ # validations__length_maximum_value: "60",
65
69
  # }
66
70
  #
67
71
  # Returns {} when no supported validators are present.
@@ -95,7 +99,7 @@ module Forms
95
99
 
96
100
  # Phlex turns underscores in `data:` hash keys into hyphens
97
101
  # in the rendered HTML. To produce a key like
98
- # `data-forms--validations--length-maximum-value` from a
102
+ # `data-validations--length-maximum-value` from a
99
103
  # Ruby symbol we need every "-" represented as "__" in the
100
104
  # symbol. That's what this method builds.
101
105
  def data_key(suffix, key)
@@ -165,15 +165,29 @@ module PhlexForms
165
165
  when :textarea then render fo.textarea(*modifiers, required:, **)
166
166
  when :toggle then render fo.toggle(*modifiers, required:, **)
167
167
  when :checkbox then render fo.checkbox(*modifiers, required:, **)
168
+ # required: doesn't apply to a group of checkboxes sharing one array name;
169
+ # validate the selection server-side instead.
170
+ when :checkbox_group then render_checkbox_group(fo, **)
168
171
  when :file then render fo.file(*modifiers, required:, **)
169
172
  when :hidden then render fo.hidden(**)
170
173
  when :rich_textarea then render fo.rich_textarea(*modifiers, **)
174
+ # The tag widget's value lives in a hidden field; `required` on it can't be
175
+ # satisfied by the browser, so drop it (validate server-side instead).
176
+ when :tags then render fo.tag_field(*modifiers, **)
171
177
  else
172
178
  type = kind == :datetime ? :"datetime-local" : kind
173
179
  render fo.input(*(modifiers - INPUT_TYPE_MODIFIERS), type:, required:, **)
174
180
  end
175
181
  end
176
182
 
183
+ # `f.field :tag_ids, as: :checkbox_group, collection: Tag.all, value:, label:`.
184
+ # collection: names the enumerable; the rest (value:/label:/variant:/size:)
185
+ # passes through to Field#checkbox_group. (choices:/required: are consumed by
186
+ # render_field_input's own signature, so they never reach here.)
187
+ def render_checkbox_group(fo, collection: [], **)
188
+ render fo.checkbox_group(collection, **)
189
+ end
190
+
177
191
  def materialize_choices(choices)
178
192
  choices.respond_to?(:call) ? choices.call : choices
179
193
  end
@@ -51,29 +51,46 @@ module PhlexForms
51
51
  "Gemfile, or use the plain theme."
52
52
  end
53
53
 
54
- @daisy ||= new(
54
+ @daisy ||= new({
55
55
  input: Forms::Input, select: Forms::Select, choices_select: Forms::ChoicesSelect,
56
56
  textarea: Forms::Textarea, rich_textarea: Forms::RichTextarea,
57
57
  checkbox: Forms::Checkbox, toggle: Forms::Toggle, radio: Forms::Radio,
58
+ checkbox_group: Forms::CheckboxGroup,
58
59
  file: Forms::FileInput, wrapped_input: Forms::WrappedInput,
59
60
  control: Forms::FormControl, label: Forms::Label,
60
61
  field_error: Forms::FieldError, field_hint: Forms::FieldHint,
61
- submit: Forms::Submit, row: Forms::Row, group: Forms::Group
62
- )
62
+ submit: Forms::Submit, row: Forms::Row, group: Forms::Group,
63
+ # tag_field is phlex-reactive-gated (ClientBindings); only registered
64
+ # when the soft dep is present, else :tag_field raises a clear KeyError.
65
+ **reactive_roles(Forms::TagField, Forms::RootlessTagField)
66
+ })
63
67
  end
64
68
 
65
69
  # choices_select degrades to the native plain select (no choices.js) and
66
70
  # rich_textarea to a plain textarea; toggle renders as a checkbox.
67
71
  def plain
68
- @plain ||= new(
72
+ @plain ||= new({
69
73
  input: Forms::Plain::Input, select: Forms::Plain::Select, choices_select: Forms::Plain::Select,
70
74
  textarea: Forms::Plain::Textarea, rich_textarea: Forms::Plain::Textarea,
71
75
  checkbox: Forms::Plain::Checkbox, toggle: Forms::Plain::Checkbox, radio: Forms::Plain::Radio,
76
+ checkbox_group: Forms::Plain::CheckboxGroup,
72
77
  file: Forms::Plain::FileInput, wrapped_input: Forms::Plain::WrappedInput,
73
78
  control: Forms::Plain::Control, label: Forms::Plain::Label,
74
79
  field_error: Forms::Plain::FieldError, field_hint: Forms::Plain::FieldHint,
75
- submit: Forms::Plain::Submit, row: Forms::Plain::Row, group: Forms::Plain::Group
76
- )
80
+ submit: Forms::Plain::Submit, row: Forms::Plain::Row, group: Forms::Plain::Group,
81
+ **reactive_roles(Forms::Plain::TagField, Forms::Plain::RootlessTagField)
82
+ })
83
+ end
84
+
85
+ private
86
+
87
+ # phlex-reactive-gated roles: mapped only when the soft dep is present. When
88
+ # absent, the leaf classes aren't autoloaded, so these roles are simply not
89
+ # registered (fetching one raises the theme's own KeyError with the list).
90
+ def reactive_roles(tag_field_class, rootless_tag_field_class)
91
+ return {} unless defined?(Phlex::Reactive)
92
+
93
+ { tag_field: tag_field_class, rootless_tag_field: rootless_tag_field_class }
77
94
  end
78
95
  end
79
96
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PhlexForms
4
- VERSION = "0.2.2"
4
+ VERSION = "0.2.4"
5
5
  end
data/lib/phlex_forms.rb CHANGED
@@ -91,10 +91,15 @@ loader.ignore("#{__dir__}/phlex_forms/rubocop.rb") # cop entry point (RuboCop::
91
91
  loader.ignore("#{__dir__}/phlex_forms/engine.rb")
92
92
 
93
93
  # The live-validation layer includes Phlex::Reactive::Component at class level,
94
- # so it can only load when the phlex-reactive soft dependency is present.
94
+ # and the tag_field leaf includes Phlex::Reactive::ClientBindings both can only
95
+ # load when the phlex-reactive soft dependency is present.
95
96
  unless defined?(Phlex::Reactive)
96
97
  loader.ignore("#{__dir__}/forms/live.rb")
97
98
  loader.ignore("#{__dir__}/forms/live")
99
+ loader.ignore("#{__dir__}/forms/tag_field.rb")
100
+ loader.ignore("#{__dir__}/forms/plain/tag_field.rb")
101
+ loader.ignore("#{__dir__}/forms/rootless_tag_field.rb")
102
+ loader.ignore("#{__dir__}/forms/plain/rootless_tag_field.rb")
98
103
  end
99
104
 
100
105
  loader.setup
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: phlex-forms
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.2
4
+ version: 0.2.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson
@@ -114,6 +114,7 @@ files:
114
114
  - config/rubocop.yml
115
115
  - lib/forms/base.rb
116
116
  - lib/forms/checkbox.rb
117
+ - lib/forms/checkbox_group.rb
117
118
  - lib/forms/choices_select.rb
118
119
  - lib/forms/collection_check_box.rb
119
120
  - lib/forms/collection_check_box_builder.rb
@@ -133,6 +134,7 @@ files:
133
134
  - lib/forms/live/field.rb
134
135
  - lib/forms/password_field.rb
135
136
  - lib/forms/plain/checkbox.rb
137
+ - lib/forms/plain/checkbox_group.rb
136
138
  - lib/forms/plain/control.rb
137
139
  - lib/forms/plain/field_error.rb
138
140
  - lib/forms/plain/field_hint.rb
@@ -141,17 +143,21 @@ files:
141
143
  - lib/forms/plain/input.rb
142
144
  - lib/forms/plain/label.rb
143
145
  - lib/forms/plain/radio.rb
146
+ - lib/forms/plain/rootless_tag_field.rb
144
147
  - lib/forms/plain/row.rb
145
148
  - lib/forms/plain/select.rb
146
149
  - lib/forms/plain/submit.rb
150
+ - lib/forms/plain/tag_field.rb
147
151
  - lib/forms/plain/textarea.rb
148
152
  - lib/forms/plain/wrapped_input.rb
149
153
  - lib/forms/radio.rb
150
154
  - lib/forms/range.rb
151
155
  - lib/forms/rich_textarea.rb
156
+ - lib/forms/rootless_tag_field.rb
152
157
  - lib/forms/row.rb
153
158
  - lib/forms/select.rb
154
159
  - lib/forms/submit.rb
160
+ - lib/forms/tag_field.rb
155
161
  - lib/forms/textarea.rb
156
162
  - lib/forms/time_zone_select.rb
157
163
  - lib/forms/toggle.rb