phlex-forms 0.2.3 → 0.2.5

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: 536c63a40e17d971808a285a30d6bd8a96a0f0049ecdd1fc2472e39d2359337b
4
- data.tar.gz: 81460e049eccf72d1181420e705834b111bcd64aa5e4865f04fa811350c423f1
3
+ metadata.gz: '0038bad89aee72fb6b4e162bfc83dc9afbaadbc2280f3ea23be2985e81774d41'
4
+ data.tar.gz: 9b35b71658c4ffbb14667a79e6e85e4a0437d6953b64921272ae629a443a0ac2
5
5
  SHA512:
6
- metadata.gz: 1479141adae7a0f38ed945d1cd572b6a1ad6c0504964d6690e0f4db1ed1a901f8f9ba99c6e2af497020bce01adbca8fbe1ab36c46f59815296c6b46bc894f482
7
- data.tar.gz: a72f1a2367fc0e0ddcc155241805e5c70943c7d11ebe5a08ad88f2e33f2094e7a03b14ecbd61fc4f205072f0473f3e6ba9cf5413ed3ce3a8313a920db5309d1e
6
+ metadata.gz: b8ada79a4a87a79096b16f82d000ea0e5620096ebe4b8fff9560ae5e556ff982ff6e13ccb52c77e0d7ea72e0d094055595870468de9c40d9f498350bab399eb4
7
+ data.tar.gz: bce76ac9a71f33cab8c15f6f4b529b636da88d802c4b86ba07ee90948daaa1a2e1c0637f898a11be75e37ab4b8dd7abb8f42ce654a9481907b5cbcc5a299263a
data/CHANGELOG.md CHANGED
@@ -9,6 +9,51 @@ 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
+ - **`checkbox_group` accessible name** (issue #17): the `div[role="group"]` can
20
+ now carry an accessible name/description. HTML/ARIA attributes pass straight
21
+ through to the group, so the bare verb is named with plain `aria:`
22
+ (`aria: { label: "Tags" }` or `aria: { labelledby: "id" }`); through `f.field`
23
+ the Control's own visible `label:` / `hint:` get stable ids and the field wires
24
+ `aria-labelledby` / `aria-describedby` at them (no duplicate markup). Absent a
25
+ name, output is unchanged.
26
+
27
+ ### Changed
28
+
29
+ - **Client-side validation Stimulus identifiers dropped the `forms--` prefix**:
30
+ the bundled controllers now emit `validations--presence`, `validations--length`,
31
+ … (and the `validations--form` coordinator) so
32
+ `lazyLoadControllersFrom("phlex_forms/controllers")` resolves them to their
33
+ shipped path `phlex_forms/controllers/validations/*_controller` — previously
34
+ `forms--validations--*` derived `.../forms/validations/*`, which 404'd and the
35
+ controllers never connected (issue #12). The `data-validations--*` binding
36
+ attributes and the `invalidate:validations` event changed to match. Hosts that
37
+ registered `forms--validations--*` explicitly must update the identifier.
38
+
39
+ ### Fixed
40
+
41
+ - **`f.Radio` / `Field#radio` rendered the model's current value on every radio
42
+ instead of each radio's own value**: `field_attributes` carried `value:
43
+ field_value` and was splatted after the explicit positional value, clobbering
44
+ it — a new record lost the value entirely, an edit form gave every radio the
45
+ same value. `radio` now drops `field_attributes`' `value` (issue #13).
46
+ - **`Form(validate: true)` never fired client-side validation on submit**: the
47
+ coordinator controller was attached but no `data-action` wired its `onSubmit`
48
+ handler, so submitting an invalid form was not blocked. `apply_validation_coordinator`
49
+ now emits `submit->validations--form#onSubmit` (joined with any
50
+ caller-supplied `data-action`).
51
+ - **`fields_for` iterated a Hash-backed association (JSONB), emitting bogus
52
+ indices**: a Hash responds to `#each_with_index`, so a JSONB column rendered
53
+ with `nested_attributes: false` produced `scope[assoc][0][field]`, `[1]`, …
54
+ instead of a single `scope[assoc][field]`. It is now treated as a single
55
+ nested scope; only genuine collections (Enumerable, not Hash) iterate.
56
+
12
57
  - **`Forms::Base` declarative form classes**: subclass, declare fields in
13
58
  `#fields` where `self` IS the form (bare `field :email`, no `f.` prefix),
14
59
  render with `render UserForm.new(model: @user)`. Class-level `form_options`
data/README.md CHANGED
@@ -337,6 +337,11 @@ import { lazyLoadControllersFrom } from "@hotwired/stimulus-loading"
337
337
  lazyLoadControllersFrom("phlex_forms/controllers", application)
338
338
  ```
339
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
+
340
345
  Messages ship for `en` / `fr` / `af`; override via `window.PhlexForms.messages`.
341
346
 
342
347
  ## Nested attributes, collections & escape valves
@@ -351,13 +356,45 @@ f.fields_for(:settings, nested_attributes: false) do |s|
351
356
  end
352
357
 
353
358
  f.collection_check_boxes(:role_ids, Role.all, :id, :name) do |b|
354
- render b.check_box
359
+ render b.check_box # per-item control, full custom layout
355
360
  render b.label
356
361
  end
357
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
+ aria: { label: "Tags" }) # names the group for screen readers
371
+ # ...or through field inference (the field's label/hint name the group):
372
+ f.field :tag_ids, as: :checkbox_group, collection: Tag.all, value: :id,
373
+ label: "Tags", hint: "Pick any"
374
+
358
375
  f.collection_select(:country_id, Country.all, :id, :name, prompt: "Select…")
359
376
  ```
360
377
 
378
+ `checkbox_group` submits an array param (`user[tag_ids][]`) with a leading
379
+ empty-array hidden field, so deselecting everything still submits. The checked
380
+ set comes from the model's current value matched by each item's resolved
381
+ `value:` — re-rendering an edit form pre-checks the right boxes. The `:pill`
382
+ variant styles the active chip with Tailwind's `has-[:checked]:` (no JS).
383
+
384
+ A `role="group"` needs an **accessible name** for assistive tech. The verb has
385
+ no bespoke naming option — HTML/ARIA attributes pass straight through to the
386
+ group, so name it with plain `aria:` (`aria: { label: "Tags" }` for a literal
387
+ name, or `aria: { labelledby: "some_id" }` to point at an existing element).
388
+ Through `f.field`, the Control's own visible `label:` / `hint:` name the group
389
+ automatically (the field wires `aria-labelledby` / `aria-describedby` at them).
390
+ Without a name the group renders as before — naming is the caller's call, the
391
+ same posture as Rails' derived form markup.
392
+
393
+ Field ids derive from scope + name (+ value for group items), exactly like
394
+ Rails' `form_with`; the gem does not guarantee page-wide id uniqueness across
395
+ multiple forms for the same model — scope one form (`Form(model:, scope: …)`) to
396
+ disambiguate, as you would in Rails.
397
+
361
398
  `Form(model: @item, scope: false)` emits **bare** field names
362
399
  (`name="quantity"`) — the shape phlex-reactive row editors and
363
400
  `<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,101 @@
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
+ # Accessible name (issue #17): `role="group"` needs one so a screen reader
19
+ # announces the group when focus enters a checkbox. The leaf does NOT invent
20
+ # its own naming API — extra attributes pass straight through to the group
21
+ # `div`, so the caller names it with plain HTML/ARIA:
22
+ #
23
+ # f.checkbox_group(:tag_ids, Tag.all, value: :id, aria: { label: "Tags" })
24
+ # f.checkbox_group(:tag_ids, Tag.all, value: :id, aria: { labelledby: "hdr" })
25
+ #
26
+ # Through `f.field`, the builder points the group at the Control's own visible
27
+ # <label>/hint via `aria: { labelledby:, describedby: }` (so the accessible
28
+ # name matches what sighted users see) — same passthrough, no special API.
29
+ #
30
+ # The checked set is passed in pre-resolved by the builder (Field#checkbox_group
31
+ # matches the model's current value by each item's resolved value:), so the
32
+ # component itself stays presentation-only. Each checkbox's markup is delegated
33
+ # to DaisyUI::Checkbox so its size class is a literal, scanner-visible token.
34
+ class CheckboxGroup < Phlex::HTML
35
+ # variant -> the container class. The pill variant uses Tailwind's
36
+ # `has-[:checked]:` to style the active label with no JS.
37
+ VARIANT_CLASSES = {
38
+ stack: "flex flex-col gap-2",
39
+ inline: "flex flex-wrap gap-4",
40
+ pill: "flex flex-wrap gap-2"
41
+ }.freeze
42
+
43
+ def initialize(name:, id:, options:, variant: :stack, size: nil, error: false, **attributes)
44
+ @name = name # already the array name: "user[tag_ids][]"
45
+ @id = id
46
+ @options = options # [{ value:, label:, checked:, id: }, ...]
47
+ @variant = variant
48
+ @size = size
49
+ @error = error
50
+ @attributes = attributes
51
+ super()
52
+ end
53
+
54
+ def view_template
55
+ # Empty-array hidden field so an empty selection still submits (the same
56
+ # convention as collection_check_boxes).
57
+ input(type: "hidden", name: @name, value: "")
58
+
59
+ # class is the per-checkbox styling seam (see render_checkbox), not a group
60
+ # attribute — everything else the caller passed lands on the group so aria:,
61
+ # data:, id: etc. pass straight through.
62
+ div(class: group_classes, role: "group", "aria-invalid": @error || nil,
63
+ **@attributes.except(:class)) do
64
+ @options.each { |option| item(option) }
65
+ end
66
+ end
67
+
68
+ private
69
+
70
+ def item(option)
71
+ label(class: item_classes) do
72
+ render_checkbox(option)
73
+ span(class: item_label_classes) { option[:label].to_s }
74
+ end
75
+ end
76
+
77
+ # Delegate the checkbox markup to the daisyui gem so the size modifier
78
+ # resolves to a literal class (checkbox-sm, ...) the CSS scanner can see.
79
+ def render_checkbox(option)
80
+ render DaisyUI::Checkbox.new(
81
+ *checkbox_modifiers,
82
+ name: @name, id: option[:id], value: option[:value],
83
+ checked: option[:checked] || nil, class: @attributes[:class]
84
+ )
85
+ end
86
+
87
+ def checkbox_modifiers = @size ? [@size] : []
88
+
89
+ # --- styling seams (the Plain twin overrides these to bare/empty) ---
90
+
91
+ def group_classes = VARIANT_CLASSES.fetch(@variant, VARIANT_CLASSES[:stack])
92
+
93
+ def item_classes
94
+ return "label cursor-pointer gap-2 justify-start" unless @variant == :pill
95
+
96
+ "badge badge-lg cursor-pointer gap-2 has-[:checked]:badge-primary"
97
+ end
98
+
99
+ def item_label_classes = nil
100
+ end
101
+ 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
@@ -102,6 +106,29 @@ module Forms
102
106
  )
103
107
  end
104
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
+
105
132
  def label(text = nil, *modifiers, **, &block)
106
133
  theme[:label].new(*modifiers, text: text || (block ? nil : field_label), for: field_id, **, &block)
107
134
  end
@@ -229,6 +256,11 @@ module Forms
229
256
  validator.options.key?(:if) || validator.options.key?(:unless) || validator.options.key?(:on)
230
257
  end
231
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
+
232
264
  def field_attributes
233
265
  { name: field_name, id: field_id, value: field_value, error: invalid? }
234
266
  end
@@ -12,7 +12,7 @@ module Forms
12
12
  def view_template
13
13
  return unless @text
14
14
 
15
- p(class: classes) { @text }
15
+ p(class: classes, **@options.except(:class)) { @text }
16
16
  end
17
17
 
18
18
  private
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
 
@@ -6,27 +6,33 @@ module Forms
6
6
  # workhorse behind the Control-first `f.field` API and the explicit `f.Control`
7
7
  # escape hatch.
8
8
  class FormControl < Phlex::HTML
9
- def initialize(*modifiers, label: nil, hint: nil, error: nil, for: nil, required: false, **options)
9
+ # label_id:/hint_id: give the label and hint stable ids so a group control
10
+ # (checkbox_group's div[role="group"], which a plain `for`/`id` can't name)
11
+ # can reference them via aria-labelledby / aria-describedby (issue #17).
12
+ def initialize(*modifiers, label: nil, hint: nil, error: nil, for: nil, required: false,
13
+ label_id: nil, hint_id: nil, **options)
10
14
  @modifiers = modifiers
11
15
  @label = label
12
16
  @hint = hint
13
17
  @error = error
14
18
  @field_id = grab(for:)
15
19
  @required = required
20
+ @label_id = label_id
21
+ @hint_id = hint_id
16
22
  @options = options
17
23
  super()
18
24
  end
19
25
 
20
26
  def view_template(&)
21
27
  div(class: control_classes, **@options.except(:class)) do
22
- render Forms::Label.new(text: @label, for: @field_id, required: @required) if @label
28
+ render Forms::Label.new(text: @label, for: @field_id, required: @required, id: @label_id) if @label
23
29
 
24
30
  yield if block_given?
25
31
 
26
32
  if @error
27
33
  render Forms::FieldError.new(message: @error)
28
34
  elsif @hint
29
- render Forms::FieldHint.new(text: @hint)
35
+ render Forms::FieldHint.new(text: @hint, id: @hint_id)
30
36
  end
31
37
  end
32
38
  end
@@ -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
@@ -7,14 +7,14 @@ module Forms
7
7
  class Control < Forms::FormControl
8
8
  def view_template
9
9
  div(**@options.except(:class), class: @options[:class]) do
10
- render Label.new(text: @label, for: @field_id, required: @required) if @label
10
+ render Label.new(text: @label, for: @field_id, required: @required, id: @label_id) if @label
11
11
 
12
12
  yield if block_given?
13
13
 
14
14
  if @error
15
15
  render FieldError.new(message: @error)
16
16
  elsif @hint
17
- render FieldHint.new(text: @hint)
17
+ render FieldHint.new(text: @hint, id: @hint_id)
18
18
  end
19
19
  end
20
20
  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)
@@ -81,11 +81,39 @@ module PhlexForms
81
81
  options = fo.apply_validations(options)
82
82
  choices ||= materialize_choices(inferred.choices)
83
83
 
84
- render fo.control(label: label_text, hint:, required: req) do
84
+ # A checkbox_group renders div[role="group"], which a plain <label for> can't
85
+ # name. Give the Control's label/hint stable ids (control_opts) and point the
86
+ # group at them with a passed-through aria: { labelledby:, describedby: }
87
+ # (group_opts), reusing the Control's own visible chrome (issue #17).
88
+ control_opts, group_opts = group_aria(fo, inferred.as, label_text, hint)
89
+ options = options.merge(group_opts)
90
+
91
+ render fo.control(label: label_text, hint:, required: req, **control_opts) do
85
92
  render_field_input(fo, inferred.name, inferred.as, modifiers, choices:, required: req, **options)
86
93
  end
87
94
  end
88
95
 
96
+ # For a checkbox_group field: the stable ids to stamp on the Control's
97
+ # label/hint (control_opts), and a group `aria:` hash pointing back at them
98
+ # (group_opts) — passed through to the group div, no special leaf API. Returns
99
+ # [{}, {}] for every other field type (a label associates via for/id, no aria
100
+ # needed) and when neither label nor hint is present.
101
+ def group_aria(fo, as, label_text, hint)
102
+ return [{}, {}] unless as == :checkbox_group
103
+
104
+ control_opts = {}
105
+ aria = {}
106
+ if label_text
107
+ control_opts[:label_id] = "#{fo.field_id}_label"
108
+ aria[:labelledby] = control_opts[:label_id]
109
+ end
110
+ if hint
111
+ control_opts[:hint_id] = "#{fo.field_id}_hint"
112
+ aria[:describedby] = control_opts[:hint_id]
113
+ end
114
+ [control_opts, aria.empty? ? {} : { aria: }]
115
+ end
116
+
89
117
  # ------------------------------------------------------------------
90
118
  # Layout helpers
91
119
  # ------------------------------------------------------------------
@@ -165,6 +193,9 @@ module PhlexForms
165
193
  when :textarea then render fo.textarea(*modifiers, required:, **)
166
194
  when :toggle then render fo.toggle(*modifiers, required:, **)
167
195
  when :checkbox then render fo.checkbox(*modifiers, required:, **)
196
+ # required: doesn't apply to a group of checkboxes sharing one array name;
197
+ # validate the selection server-side instead.
198
+ when :checkbox_group then render_checkbox_group(fo, **)
168
199
  when :file then render fo.file(*modifiers, required:, **)
169
200
  when :hidden then render fo.hidden(**)
170
201
  when :rich_textarea then render fo.rich_textarea(*modifiers, **)
@@ -177,6 +208,14 @@ module PhlexForms
177
208
  end
178
209
  end
179
210
 
211
+ # `f.field :tag_ids, as: :checkbox_group, collection: Tag.all, value:, label:`.
212
+ # collection: names the enumerable; the rest (value:/label:/variant:/size:)
213
+ # passes through to Field#checkbox_group. (choices:/required: are consumed by
214
+ # render_field_input's own signature, so they never reach here.)
215
+ def render_checkbox_group(fo, collection: [], **)
216
+ render fo.checkbox_group(collection, **)
217
+ end
218
+
180
219
  def materialize_choices(choices)
181
220
  choices.respond_to?(:call) ? choices.call : choices
182
221
  end
@@ -55,6 +55,7 @@ module PhlexForms
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,
@@ -72,6 +73,7 @@ module PhlexForms
72
73
  input: Forms::Plain::Input, select: Forms::Plain::Select, choices_select: Forms::Plain::Select,
73
74
  textarea: Forms::Plain::Textarea, rich_textarea: Forms::Plain::Textarea,
74
75
  checkbox: Forms::Plain::Checkbox, toggle: Forms::Plain::Checkbox, radio: Forms::Plain::Radio,
76
+ checkbox_group: Forms::Plain::CheckboxGroup,
75
77
  file: Forms::Plain::FileInput, wrapped_input: Forms::Plain::WrappedInput,
76
78
  control: Forms::Plain::Control, label: Forms::Plain::Label,
77
79
  field_error: Forms::Plain::FieldError, field_hint: Forms::Plain::FieldHint,
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PhlexForms
4
- VERSION = "0.2.3"
4
+ VERSION = "0.2.5"
5
5
  end
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.3
4
+ version: 0.2.5
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