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 +4 -4
- data/CHANGELOG.md +38 -0
- data/README.md +84 -2
- 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 +85 -0
- data/lib/forms/field.rb +44 -1
- data/lib/forms/form.rb +26 -2
- data/lib/forms/live/field.rb +24 -0
- data/lib/forms/live.rb +39 -1
- data/lib/forms/plain/checkbox_group.rb +27 -0
- data/lib/forms/plain/rootless_tag_field.rb +21 -0
- data/lib/forms/plain/tag_field.rb +24 -0
- data/lib/forms/rootless_tag_field.rb +23 -0
- data/lib/forms/tag_field.rb +133 -0
- data/lib/forms/validations/introspector.rb +9 -5
- data/lib/phlex_forms/builder.rb +14 -0
- data/lib/phlex_forms/theme.rb +23 -6
- data/lib/phlex_forms/version.rb +1 -1
- data/lib/phlex_forms.rb +6 -1
- metadata +7 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 74bd32547d30535c35060ea6c93f9cfd199614a9ced2cf1a95a7c32fe62eb3e6
|
|
4
|
+
data.tar.gz: cf0087d9bee7cb13366ef489fb8764c82867b406881934eb92cdb7dbc271d0f8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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-
|
|
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,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
|
-
**
|
|
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
|
|
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/live/field.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
@@ -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
|
data/lib/phlex_forms/theme.rb
CHANGED
|
@@ -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
|
data/lib/phlex_forms/version.rb
CHANGED
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
|
-
#
|
|
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.
|
|
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
|