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 +4 -4
- data/CHANGELOG.md +45 -0
- data/README.md +38 -1
- data/app/javascript/phlex_forms/controllers/validations/base_controller.js +5 -5
- data/app/javascript/phlex_forms/controllers/validations/form_controller.js +3 -3
- data/app/javascript/phlex_forms/controllers/validations/length_controller.js +3 -3
- data/lib/forms/checkbox_group.rb +101 -0
- data/lib/forms/field.rb +33 -1
- data/lib/forms/field_hint.rb +1 -1
- data/lib/forms/form.rb +26 -2
- data/lib/forms/form_control.rb +9 -3
- data/lib/forms/plain/checkbox_group.rb +27 -0
- data/lib/forms/plain/control.rb +2 -2
- data/lib/forms/validations/introspector.rb +9 -5
- data/lib/phlex_forms/builder.rb +40 -1
- data/lib/phlex_forms/theme.rb +2 -0
- data/lib/phlex_forms/version.rb +1 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '0038bad89aee72fb6b4e162bfc83dc9afbaadbc2280f3ea23be2985e81774d41'
|
|
4
|
+
data.tar.gz: 9b35b71658c4ffbb14667a79e6e85e4a0437d6953b64921272ae629a443a0ac2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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-
|
|
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:
|
|
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:
|
|
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-
|
|
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-
|
|
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:
|
|
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("
|
|
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:
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
-
**
|
|
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
|
data/lib/forms/field_hint.rb
CHANGED
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
|
|
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
|
|
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
|
|
data/lib/forms/form_control.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
data/lib/forms/plain/control.rb
CHANGED
|
@@ -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
|
-
|
|
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: "
|
|
63
|
-
#
|
|
64
|
-
#
|
|
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-
|
|
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)
|
data/lib/phlex_forms/builder.rb
CHANGED
|
@@ -81,11 +81,39 @@ module PhlexForms
|
|
|
81
81
|
options = fo.apply_validations(options)
|
|
82
82
|
choices ||= materialize_choices(inferred.choices)
|
|
83
83
|
|
|
84
|
-
|
|
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
|
data/lib/phlex_forms/theme.rb
CHANGED
|
@@ -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,
|
data/lib/phlex_forms/version.rb
CHANGED
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.
|
|
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
|