phlex-forms 0.2.2 → 0.2.3
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/README.md +62 -1
- data/lib/forms/field.rb +11 -0
- data/lib/forms/live/field.rb +24 -0
- data/lib/forms/live.rb +39 -1
- 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/phlex_forms/builder.rb +3 -0
- data/lib/phlex_forms/theme.rb +21 -6
- data/lib/phlex_forms/version.rb +1 -1
- data/lib/phlex_forms.rb +6 -1
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 536c63a40e17d971808a285a30d6bd8a96a0f0049ecdd1fc2472e39d2359337b
|
|
4
|
+
data.tar.gz: 81460e049eccf72d1181420e705834b111bcd64aa5e4865f04fa811350c423f1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1479141adae7a0f38ed945d1cd572b6a1ad6c0504964d6690e0f4db1ed1a901f8f9ba99c6e2af497020bce01adbca8fbe1ab36c46f59815296c6b46bc894f482
|
|
7
|
+
data.tar.gz: a72f1a2367fc0e0ddcc155241805e5c70943c7d11ebe5a08ad88f2e33f2094e7a03b14ecbd61fc4f205072f0473f3e6ba9cf5413ed3ce3a8313a920db5309d1e
|
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:
|
data/lib/forms/field.rb
CHANGED
|
@@ -91,6 +91,17 @@ module Forms
|
|
|
91
91
|
end
|
|
92
92
|
end
|
|
93
93
|
|
|
94
|
+
# A model-bound tag/chip input (phlex-reactive client-only primitives).
|
|
95
|
+
# suggestions: an Array of tags or a Hash of tag => haystack (synonyms the
|
|
96
|
+
# filter matches). Submits one comma-joined param under the field name.
|
|
97
|
+
def tag_field(*modifiers, suggestions: [], **)
|
|
98
|
+
theme[:tag_field].new(
|
|
99
|
+
*modifiers,
|
|
100
|
+
name: field_name, id: field_id, value: field_value,
|
|
101
|
+
suggestions:, error: invalid?, **
|
|
102
|
+
)
|
|
103
|
+
end
|
|
104
|
+
|
|
94
105
|
def label(text = nil, *modifiers, **, &block)
|
|
95
106
|
theme[:label].new(*modifiers, text: text || (block ? nil : field_label), for: field_id, **, &block)
|
|
96
107
|
end
|
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,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
|
data/lib/phlex_forms/builder.rb
CHANGED
|
@@ -168,6 +168,9 @@ module PhlexForms
|
|
|
168
168
|
when :file then render fo.file(*modifiers, required:, **)
|
|
169
169
|
when :hidden then render fo.hidden(**)
|
|
170
170
|
when :rich_textarea then render fo.rich_textarea(*modifiers, **)
|
|
171
|
+
# The tag widget's value lives in a hidden field; `required` on it can't be
|
|
172
|
+
# satisfied by the browser, so drop it (validate server-side instead).
|
|
173
|
+
when :tags then render fo.tag_field(*modifiers, **)
|
|
171
174
|
else
|
|
172
175
|
type = kind == :datetime ? :"datetime-local" : kind
|
|
173
176
|
render fo.input(*(modifiers - INPUT_TYPE_MODIFIERS), type:, required:, **)
|
data/lib/phlex_forms/theme.rb
CHANGED
|
@@ -51,29 +51,44 @@ 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
58
|
file: Forms::FileInput, wrapped_input: Forms::WrappedInput,
|
|
59
59
|
control: Forms::FormControl, label: Forms::Label,
|
|
60
60
|
field_error: Forms::FieldError, field_hint: Forms::FieldHint,
|
|
61
|
-
submit: Forms::Submit, row: Forms::Row, group: Forms::Group
|
|
62
|
-
|
|
61
|
+
submit: Forms::Submit, row: Forms::Row, group: Forms::Group,
|
|
62
|
+
# tag_field is phlex-reactive-gated (ClientBindings); only registered
|
|
63
|
+
# when the soft dep is present, else :tag_field raises a clear KeyError.
|
|
64
|
+
**reactive_roles(Forms::TagField, Forms::RootlessTagField)
|
|
65
|
+
})
|
|
63
66
|
end
|
|
64
67
|
|
|
65
68
|
# choices_select degrades to the native plain select (no choices.js) and
|
|
66
69
|
# rich_textarea to a plain textarea; toggle renders as a checkbox.
|
|
67
70
|
def plain
|
|
68
|
-
@plain ||= new(
|
|
71
|
+
@plain ||= new({
|
|
69
72
|
input: Forms::Plain::Input, select: Forms::Plain::Select, choices_select: Forms::Plain::Select,
|
|
70
73
|
textarea: Forms::Plain::Textarea, rich_textarea: Forms::Plain::Textarea,
|
|
71
74
|
checkbox: Forms::Plain::Checkbox, toggle: Forms::Plain::Checkbox, radio: Forms::Plain::Radio,
|
|
72
75
|
file: Forms::Plain::FileInput, wrapped_input: Forms::Plain::WrappedInput,
|
|
73
76
|
control: Forms::Plain::Control, label: Forms::Plain::Label,
|
|
74
77
|
field_error: Forms::Plain::FieldError, field_hint: Forms::Plain::FieldHint,
|
|
75
|
-
submit: Forms::Plain::Submit, row: Forms::Plain::Row, group: Forms::Plain::Group
|
|
76
|
-
|
|
78
|
+
submit: Forms::Plain::Submit, row: Forms::Plain::Row, group: Forms::Plain::Group,
|
|
79
|
+
**reactive_roles(Forms::Plain::TagField, Forms::Plain::RootlessTagField)
|
|
80
|
+
})
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
# phlex-reactive-gated roles: mapped only when the soft dep is present. When
|
|
86
|
+
# absent, the leaf classes aren't autoloaded, so these roles are simply not
|
|
87
|
+
# registered (fetching one raises the theme's own KeyError with the list).
|
|
88
|
+
def reactive_roles(tag_field_class, rootless_tag_field_class)
|
|
89
|
+
return {} unless defined?(Phlex::Reactive)
|
|
90
|
+
|
|
91
|
+
{ tag_field: tag_field_class, rootless_tag_field: rootless_tag_field_class }
|
|
77
92
|
end
|
|
78
93
|
end
|
|
79
94
|
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.3
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Mikael Henriksson
|
|
@@ -141,17 +141,21 @@ files:
|
|
|
141
141
|
- lib/forms/plain/input.rb
|
|
142
142
|
- lib/forms/plain/label.rb
|
|
143
143
|
- lib/forms/plain/radio.rb
|
|
144
|
+
- lib/forms/plain/rootless_tag_field.rb
|
|
144
145
|
- lib/forms/plain/row.rb
|
|
145
146
|
- lib/forms/plain/select.rb
|
|
146
147
|
- lib/forms/plain/submit.rb
|
|
148
|
+
- lib/forms/plain/tag_field.rb
|
|
147
149
|
- lib/forms/plain/textarea.rb
|
|
148
150
|
- lib/forms/plain/wrapped_input.rb
|
|
149
151
|
- lib/forms/radio.rb
|
|
150
152
|
- lib/forms/range.rb
|
|
151
153
|
- lib/forms/rich_textarea.rb
|
|
154
|
+
- lib/forms/rootless_tag_field.rb
|
|
152
155
|
- lib/forms/row.rb
|
|
153
156
|
- lib/forms/select.rb
|
|
154
157
|
- lib/forms/submit.rb
|
|
158
|
+
- lib/forms/tag_field.rb
|
|
155
159
|
- lib/forms/textarea.rb
|
|
156
160
|
- lib/forms/time_zone_select.rb
|
|
157
161
|
- lib/forms/toggle.rb
|