form_components 0.1.1
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 +7 -0
- data/CHANGELOG.md +29 -0
- data/MIT-LICENSE +20 -0
- data/README.md +194 -0
- data/app/assets/stylesheets/form_components/application.css +9 -0
- data/app/assets/tailwind/form_components/engine.css +6 -0
- data/app/components/form_components/base/component.rb +76 -0
- data/app/components/form_components/check_box/component.html.slim +13 -0
- data/app/components/form_components/check_box/component.rb +26 -0
- data/app/components/form_components/collection/component.html.slim +10 -0
- data/app/components/form_components/collection/component.rb +76 -0
- data/app/components/form_components/collection_check_boxes/component.rb +12 -0
- data/app/components/form_components/collection_radio_buttons/component.rb +12 -0
- data/app/components/form_components/color_field/component.css +69 -0
- data/app/components/form_components/color_field/component.html.slim +8 -0
- data/app/components/form_components/color_field/component.rb +26 -0
- data/app/components/form_components/color_field/component_controller.js +25 -0
- data/app/components/form_components/combobox/component.html.slim +8 -0
- data/app/components/form_components/combobox/component.rb +28 -0
- data/app/components/form_components/field/component.rb +58 -0
- data/app/components/form_components/file_field/component.html.slim +10 -0
- data/app/components/form_components/file_field/component.rb +25 -0
- data/app/components/form_components/label/component.rb +31 -0
- data/app/components/form_components/nested_form/component.html.slim +20 -0
- data/app/components/form_components/nested_form/component.rb +71 -0
- data/app/components/form_components/nested_form/component_controller.js +36 -0
- data/app/components/form_components/radio_button/component.html.slim +12 -0
- data/app/components/form_components/radio_button/component.rb +24 -0
- data/app/components/form_components/rating_field/component.rb +39 -0
- data/app/components/form_components/scale_field/component.rb +44 -0
- data/app/components/form_components/select/component.html.slim +8 -0
- data/app/components/form_components/select/component.rb +35 -0
- data/app/components/form_components/submit/component.rb +21 -0
- data/app/components/form_components/text_area/component.html.slim +14 -0
- data/app/components/form_components/text_area/component.rb +22 -0
- data/app/components/form_components/text_field/component.html.slim +16 -0
- data/app/components/form_components/text_field/component.rb +33 -0
- data/app/components/form_components/validation.rb +41 -0
- data/app/form_builders/form_components/form_builder.rb +133 -0
- data/app/form_builders/form_components/validation_context.rb +17 -0
- data/app/javascript/form_components/application.js +12 -0
- data/config/importmap.rb +6 -0
- data/lib/form_components/engine.rb +23 -0
- data/lib/form_components/version.rb +5 -0
- data/lib/form_components.rb +13 -0
- data/vendor/javascript/form_components/jscolorpicker/LICENSE +21 -0
- data/vendor/javascript/form_components/jscolorpicker/colorpicker.css +409 -0
- data/vendor/javascript/form_components/jscolorpicker/colorpicker.js +2410 -0
- metadata +145 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 8263e5728d9eb7435fc73aaaca11069d712a916f81dd78274ae6f64d962856da
|
|
4
|
+
data.tar.gz: 532ef72e4ae348a6c4e9efc0df796175a68a01b5daaa0757253ba83a1207a918
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: ba582fffbc42304b5e9f3b67ad99aad8ffaf0c0c1f7fa1a3a3e651fd2ae846b8812e55bbe9bf3e98684f945914072e0b6b2809672d27f3e9213a3a2556d9d8c5
|
|
7
|
+
data.tar.gz: 4ec8d95dcde0c417e7d8197b80461d60d97657fd7120a629d289223b02f5b7af661b057a46948cd3b789555afdc671e782f8fd13c996d66c67dab6d37db1bc5e
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
- Every helper takes Rails' arguments and renders Rails' own input, so
|
|
6
|
+
names, ids, values and options are Rails' (with `index:`, `namespace:`,
|
|
7
|
+
`fields_for`, a form without a record, …). Their extras (`label:`,
|
|
8
|
+
`description:`, …) aren't passed to Rails any more.
|
|
9
|
+
- `label` is Rails' label: its block, its options and a hash as options, and
|
|
10
|
+
its text from `helpers.label`.
|
|
11
|
+
- Collections show each item's text (`text_method`), not its value; they are a
|
|
12
|
+
fieldset with the label as its legend, and show the description and errors
|
|
13
|
+
once, not with every item.
|
|
14
|
+
- `check_box` names an array attribute's field `[]` only with `multiple: true`,
|
|
15
|
+
as Rails; `password_field` leaves out the value, as Rails.
|
|
16
|
+
- `telephone_field` and `phone_field`.
|
|
17
|
+
- `select`, `collection_select`, `grouped_collection_select`,
|
|
18
|
+
`time_zone_select` and `weekday_select`, with a label, description and
|
|
19
|
+
errors; until now they were Rails' plain selects.
|
|
20
|
+
- `combobox` (hotwire_combobox's) with a label, description and errors.
|
|
21
|
+
- `rating_field` (stars) and `scale_field` (a row of numbers).
|
|
22
|
+
- Check boxes have a size of their own, also in a collection, and a
|
|
23
|
+
collection's description is below its legend.
|
|
24
|
+
- A dummy app (`test/dummy`), and tests comparing each helper with Rails'.
|
|
25
|
+
|
|
26
|
+
## 0.1.0
|
|
27
|
+
|
|
28
|
+
- A form builder whose fields are view components, with Stimulus controllers
|
|
29
|
+
for nested forms and the color field, and a Tailwind entry point.
|
data/MIT-LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Copyright 2026 Tom de Grunt
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
4
|
+
a copy of this software and associated documentation files (the
|
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
9
|
+
the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be
|
|
12
|
+
included in all copies or substantial portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# Form components
|
|
2
|
+
|
|
3
|
+
A form builder for Rails whose fields are view components (ViewComponent,
|
|
4
|
+
Slim, Stimulus, Tailwind CSS v4), as a Rails engine: labels, text fields of
|
|
5
|
+
every type, text areas, check boxes, radio buttons and their collections,
|
|
6
|
+
color and file fields, a submit button and nested forms. Each field has its
|
|
7
|
+
label (marked when the attribute is required), a description, and its errors.
|
|
8
|
+
|
|
9
|
+
The fields are Rails' own: every helper takes the arguments Rails' form
|
|
10
|
+
builder takes, and renders its input with Rails' helper, so names, ids,
|
|
11
|
+
values, options, hidden fields and nested forms are exactly as with Rails.
|
|
12
|
+
The components add their look and their extras around it. Helpers without a
|
|
13
|
+
component (`select`, `hidden_field`, `range_field`, …) are Rails' as they are.
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
In the Gemfile:
|
|
18
|
+
|
|
19
|
+
```ruby
|
|
20
|
+
gem "form_components", github: "entdec/form_components"
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
The engine adds its pins to the importmap. Register the controllers with the
|
|
24
|
+
host's Stimulus application:
|
|
25
|
+
|
|
26
|
+
```js
|
|
27
|
+
import { registerFormComponents } from "form_components"
|
|
28
|
+
registerFormComponents(window.Stimulus)
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
And include the styles in the host's Tailwind CSS (tailwindcss-rails v4), in
|
|
32
|
+
`app/assets/tailwind/application.css`:
|
|
33
|
+
|
|
34
|
+
```css
|
|
35
|
+
@import "../builds/tailwind/form_components";
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### What the host provides
|
|
39
|
+
|
|
40
|
+
- A `primary` color in its Tailwind theme (`--color-primary-50` to `-950`).
|
|
41
|
+
- The `@tailwindcss/forms` plugin (`@plugin "@tailwindcss/forms";`), which
|
|
42
|
+
draws a select's arrow and a checked check box's checkmark.
|
|
43
|
+
- An `auto-dark` variant, for the components' dark mode, so the host decides
|
|
44
|
+
which pages have one. Follow the OS everywhere with:
|
|
45
|
+
|
|
46
|
+
```css
|
|
47
|
+
@custom-variant auto-dark (@media (prefers-color-scheme: dark));
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
or keep it off with `@custom-variant auto-dark (&:where(.never-dark, .never-dark *));`
|
|
51
|
+
- [Font Awesome](https://fontawesome.com), for the icons (`fas`, `fal`).
|
|
52
|
+
|
|
53
|
+
### Bundling with esbuild
|
|
54
|
+
|
|
55
|
+
Instead of the importmap, a bundler resolves these imports to the gem:
|
|
56
|
+
|
|
57
|
+
- `form_components` to `app/javascript/form_components/application.js`
|
|
58
|
+
- `form_components/components/<name>/component_controller` to
|
|
59
|
+
`app/components/form_components/<name>/component_controller.js`
|
|
60
|
+
- `form_components/jscolorpicker` to
|
|
61
|
+
`vendor/javascript/form_components/jscolorpicker/colorpicker.js`
|
|
62
|
+
|
|
63
|
+
## Usage
|
|
64
|
+
|
|
65
|
+
```slim
|
|
66
|
+
= form_with model: @post, builder: FormComponents::FormBuilder do |f|
|
|
67
|
+
= f.text_field :title, placeholder: "A title", description: "Shown on top", leading_icon: "fas fa-heading"
|
|
68
|
+
= f.email_field :email, corner_hint: "Optional"
|
|
69
|
+
= f.text_area :body, label: false
|
|
70
|
+
= f.check_box :published
|
|
71
|
+
= f.collection_radio_buttons :kind, Kind.all, :id, :name, {description_method: :hint}, {class: "mine"}
|
|
72
|
+
= f.color_field :color, swatches: ["#e6007e", "#1e40af"], default: "#e6007e"
|
|
73
|
+
= f.file_field :logo, accept: "image/*", preview: @post.logo
|
|
74
|
+
= f.submit
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### As the default form builder
|
|
78
|
+
|
|
79
|
+
To use it without `builder:`, for the whole app, in `config/application.rb`:
|
|
80
|
+
|
|
81
|
+
```ruby
|
|
82
|
+
config.action_view.default_form_builder = "FormComponents::FormBuilder"
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Or for a controller and its subclasses:
|
|
86
|
+
|
|
87
|
+
```ruby
|
|
88
|
+
class ApplicationController < ActionController::Base
|
|
89
|
+
default_form_builder FormComponents::FormBuilder
|
|
90
|
+
end
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
App-wide, it's also the builder for engines' views that don't name one, so
|
|
94
|
+
those forms change too; per controller, for engines' controllers that inherit
|
|
95
|
+
from it. `builder:` still picks another builder for a form, like
|
|
96
|
+
`ActionView::Helpers::FormBuilder` for Rails' own fields.
|
|
97
|
+
|
|
98
|
+
### Fields
|
|
99
|
+
|
|
100
|
+
Each helper takes Rails' arguments, and these extras in its options, which
|
|
101
|
+
Rails doesn't get:
|
|
102
|
+
|
|
103
|
+
- **Every field**: `label:` (a text, or false for none) and `description:`,
|
|
104
|
+
shown below it. A label is Rails' `label`, so its text comes from
|
|
105
|
+
`helpers.label.<model>.<attribute>` too.
|
|
106
|
+
- **Text fields**: `text_field`, `password_field`, `search_field`,
|
|
107
|
+
`telephone_field` (`phone_field`), `date_field`, `time_field`,
|
|
108
|
+
`datetime_field`, `datetime_local_field`, `month_field`, `week_field`,
|
|
109
|
+
`url_field`, `email_field` and `number_field`, with `leading_icon:` and
|
|
110
|
+
`corner_hint:`. `text_area` (`textarea`) with `corner_hint:`.
|
|
111
|
+
- **Check boxes and radio buttons**: `check_box` (`checkbox`), with its hidden
|
|
112
|
+
unchecked value, `multiple:` and the rest as Rails; `radio_button`.
|
|
113
|
+
- **Collections**: `collection_check_boxes` and `collection_radio_buttons` in a
|
|
114
|
+
fieldset, with the label as its legend and the description and errors once;
|
|
115
|
+
`description_method:` (a method name or a proc, as `value_method`) gives each
|
|
116
|
+
item a description of its own. With a block, the block renders the items, as
|
|
117
|
+
with Rails.
|
|
118
|
+
- **Selects**: `select`, `collection_select`, `grouped_collection_select`,
|
|
119
|
+
`time_zone_select` and `weekday_select`, as Rails'; the extras go in either
|
|
120
|
+
hash, `options` or `html_options`. The arrow is `@tailwindcss/forms`'.
|
|
121
|
+
- **Combobox**: `combobox`, [hotwire_combobox](https://hotwirecombobox.com)'s,
|
|
122
|
+
when the host has that gem, with its arguments; our label replaces its own.
|
|
123
|
+
- **Rating**: `rating_field` gives stars from 1 to `max:` (5), and
|
|
124
|
+
**scale**: `scale_field` a row of numbers, `in:` (1..5), with `min_label:`
|
|
125
|
+
and `max_label:` at its ends. Both are Rails' `collection_radio_buttons` of
|
|
126
|
+
their numbers, so they send a number, or an empty value when none is
|
|
127
|
+
picked, and need no JS.
|
|
128
|
+
- **Color field**: a color as `#rrggbb`, picked with JS Color Picker by
|
|
129
|
+
Zygomatic (vendored, MIT), with `swatches:` to pick with one click and
|
|
130
|
+
`default:` shown while it's empty. Unlike Rails' `color_field` it is a text
|
|
131
|
+
field (Rails' `text_field`, so its name, id and options are Rails'), and it
|
|
132
|
+
can be cleared: empty stays empty, where Rails sends `#000000`, so it can
|
|
133
|
+
mean the default color.
|
|
134
|
+
- **File field**: Rails' (which makes the form multipart), with `preview:`, an
|
|
135
|
+
Active Storage attachment to show above it.
|
|
136
|
+
- **Submit**: Rails' `submit`, named after the record as Rails names it.
|
|
137
|
+
|
|
138
|
+
A field with errors is wrapped as Rails wraps it
|
|
139
|
+
(`ActionView::Base.field_error_proc`); in a component the wrapper takes no
|
|
140
|
+
place (`.form-component .field_with_errors { display: contents }`).
|
|
141
|
+
|
|
142
|
+
### Required fields
|
|
143
|
+
|
|
144
|
+
A field's label is marked when its attribute has a presence validator. Pass
|
|
145
|
+
the validation context to the form to count those `on:` it:
|
|
146
|
+
|
|
147
|
+
```slim
|
|
148
|
+
= form_with model: @post, builder: FormComponents::FormBuilder, validation_context: :publish do |f|
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### Nested forms
|
|
152
|
+
|
|
153
|
+
For a has_many association with `accepts_nested_attributes_for`:
|
|
154
|
+
|
|
155
|
+
```slim
|
|
156
|
+
= f.nested_form :sections do |nf|
|
|
157
|
+
- nf.with_item do |sf|
|
|
158
|
+
.section-wrapper data-new-record=sf.object.new_record?.to_s
|
|
159
|
+
= sf.hidden_field :_destroy
|
|
160
|
+
= sf.text_field :title
|
|
161
|
+
button type="button" data-action="form-components--nested-form#remove" Remove
|
|
162
|
+
- nf.with_add_button
|
|
163
|
+
button type="button" Add section
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
The add button adds a new item from a template, the remove button removes a
|
|
167
|
+
new item, or hides a saved one and marks it to be destroyed. Options:
|
|
168
|
+
`placeholder:` (the token replaced in the template, `NEW_SECTION`),
|
|
169
|
+
`wrapper_selector:` (an item's wrapper, `.section-wrapper`), `new_record:`
|
|
170
|
+
(the template's record, the association's `new`) and `add_label:` (for the
|
|
171
|
+
default add button).
|
|
172
|
+
|
|
173
|
+
## Development
|
|
174
|
+
|
|
175
|
+
```sh
|
|
176
|
+
bundle install
|
|
177
|
+
bundle exec rake test
|
|
178
|
+
bundle exec standardrb
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
The tests run in the dummy app (`test/dummy`), which shows every component on
|
|
182
|
+
one page, and what its form sends:
|
|
183
|
+
|
|
184
|
+
```sh
|
|
185
|
+
cd test/dummy && bin/rails server
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
`test/form_builders/rails_parity_test.rb` renders each helper with Rails' own
|
|
189
|
+
form builder and with this one, and compares their form controls.
|
|
190
|
+
|
|
191
|
+
## License
|
|
192
|
+
|
|
193
|
+
MIT, see MIT-LICENSE. JS Color Picker is MIT too, see
|
|
194
|
+
`vendor/javascript/form_components/jscolorpicker/LICENSE`.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/* The components' styles (see app/assets/tailwind/form_components/engine.css) */
|
|
2
|
+
@import "../../../components/form_components/color_field/component.css";
|
|
3
|
+
|
|
4
|
+
/* Rails wraps a field with errors (ActionView::Base.field_error_proc, by
|
|
5
|
+
default a div.field_with_errors); in a component the wrapper takes no
|
|
6
|
+
place, so the field stays where the component's layout puts it */
|
|
7
|
+
.form-component .field_with_errors {
|
|
8
|
+
display: contents;
|
|
9
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/* Entry point for tailwindcss-rails v4 (Tailwind CSS v4).
|
|
2
|
+
The host app includes it with: @import "../builds/tailwind/form_components";
|
|
3
|
+
In the components layer, so utility classes override the components' styles */
|
|
4
|
+
@import "../../stylesheets/form_components/application.css" layer(components);
|
|
5
|
+
|
|
6
|
+
@source "../../..";
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
module FormComponents
|
|
2
|
+
module Base
|
|
3
|
+
# A form builder's field as a component. The input itself is Rails': each
|
|
4
|
+
# component renders it with ActionView::Helpers::FormBuilder's own helper
|
|
5
|
+
# (rails), so names, ids, values and options are exactly Rails', and adds
|
|
6
|
+
# its extras around it. Its own options (extras) are taken out first.
|
|
7
|
+
class Component < ViewComponent::Base
|
|
8
|
+
include FormComponents::Validation
|
|
9
|
+
|
|
10
|
+
# Options of ours, not passed on to Rails
|
|
11
|
+
class_attribute :extras, default: []
|
|
12
|
+
|
|
13
|
+
attr_reader :form, :object, :method, :options
|
|
14
|
+
|
|
15
|
+
def initialize(form, object, method, options = {})
|
|
16
|
+
@form = form
|
|
17
|
+
@object = object
|
|
18
|
+
@method = method
|
|
19
|
+
@options = (options || {}).symbolize_keys
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.option_reader(name, default = nil)
|
|
23
|
+
define_method(name) do
|
|
24
|
+
options.fetch(name, default)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# The options Rails gets
|
|
29
|
+
def html_options
|
|
30
|
+
options.except(*extras)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# The options Rails takes the id, index: and name from: the options,
|
|
34
|
+
# or html_options for helpers that have both, such as select
|
|
35
|
+
def id_options
|
|
36
|
+
html_options
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Renders a helper of Rails' own form builder, for this form
|
|
40
|
+
def rails(helper, *, **, &)
|
|
41
|
+
ActionView::Helpers::FormBuilder.instance_method(helper).bind_call(form, *, **, &)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# As Rails' label: helpers.label.<model>.<attribute>, else the
|
|
45
|
+
# attribute's human name, else the method humanized
|
|
46
|
+
def human_attribute_name
|
|
47
|
+
ActionView::Helpers::Tags::Translator.new(object, object_name.to_s, method.to_s, scope: "helpers.label").translate ||
|
|
48
|
+
method.to_s.humanize
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def object_name
|
|
52
|
+
form.object_name
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# The id Rails gives the input, also with index:, namespace: or a value
|
|
56
|
+
# (a radio button's, a check box's with multiple: true)
|
|
57
|
+
def tag_id(value = nil)
|
|
58
|
+
return id_options[:id] if id_options.key?(:id)
|
|
59
|
+
|
|
60
|
+
suffix = sanitized_value(value) unless value.nil?
|
|
61
|
+
form.field_id(method, *suffix, index: id_options.fetch(:index, form.options[:index]))
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def name
|
|
65
|
+
form.field_name(method, index: id_options.fetch(:index, form.options[:index]))
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
# As Rails makes a value part of an id
|
|
71
|
+
def sanitized_value(value)
|
|
72
|
+
value.to_s.gsub(/[\s.]/, "_").gsub(/[^-[[:word:]]]/, "").downcase
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
.form-component.flex.gap-3
|
|
2
|
+
.flex.h-6.shrink-0.items-center
|
|
3
|
+
.group.grid.size-4.grid-cols-1
|
|
4
|
+
= input
|
|
5
|
+
.text-sm/6
|
|
6
|
+
- if label != false
|
|
7
|
+
div.flex.justify-between
|
|
8
|
+
= field_label(input_id, value: (checked_value if html_options[:multiple]))
|
|
9
|
+
- if description.present?
|
|
10
|
+
label for=input_id class="text-gray-500 auto-dark:text-gray-400" id=input_id+"_description"
|
|
11
|
+
= description
|
|
12
|
+
- if has_error?
|
|
13
|
+
p id=input_id+"_error" class="mt-2 text-sm text-red-600 auto-dark:text-red-400" = all_errors.join(", ")
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
module FormComponents
|
|
2
|
+
module CheckBox
|
|
3
|
+
# Rails' check box, with its hidden unchecked value
|
|
4
|
+
class Component < FormComponents::Field::Component
|
|
5
|
+
CLASSES = "col-start-1 row-start-1 size-4 appearance-none rounded border border-gray-300 bg-white auto-dark:border-white/10 auto-dark:bg-white/5 checked:border-primary-600 checked:hover:bg-primary-600 checked:bg-primary-600 indeterminate:border-primary-600 indeterminate:bg-primary-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-600 disabled:border-gray-300 disabled:bg-gray-100 disabled:checked:bg-gray-100 forced-colors:appearance-auto hover:ring-primary-600 focus:ring-primary-600 checked:focus:bg-primary-600"
|
|
6
|
+
|
|
7
|
+
attr_reader :checked_value, :unchecked_value
|
|
8
|
+
|
|
9
|
+
# As Rails' check_box(method, options = {}, checked_value = "1", unchecked_value = "0")
|
|
10
|
+
def initialize(form, object, method, options = {}, checked_value = "1", unchecked_value = "0")
|
|
11
|
+
super(form, object, method, options)
|
|
12
|
+
@checked_value = checked_value
|
|
13
|
+
@unchecked_value = unchecked_value
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def input
|
|
17
|
+
rails(:check_box, method, input_options(CLASSES, id: input_id), checked_value, unchecked_value)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# With multiple: true the value is part of the id, as Rails has it
|
|
21
|
+
def input_id
|
|
22
|
+
tag_id(html_options[:multiple] ? checked_value : nil)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
fieldset.form-component aria-describedby=aria_described_by
|
|
2
|
+
- if label != false
|
|
3
|
+
legend class=legend_classes = legend
|
|
4
|
+
- if description.present?
|
|
5
|
+
p id=tag_id+"_description" class=class_names("text-sm/6 text-gray-500 auto-dark:text-gray-400", "mt-1" => label != false) = description
|
|
6
|
+
div class=class_names(items_classes, "mt-2" => label != false || description.present?)
|
|
7
|
+
= items
|
|
8
|
+
= after_items
|
|
9
|
+
- if has_error?
|
|
10
|
+
p id=tag_id+"_error" class="mt-2 text-sm text-red-600 auto-dark:text-red-400" = all_errors.join(", ")
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
module FormComponents
|
|
2
|
+
module Collection
|
|
3
|
+
# Rails' collection_check_boxes or collection_radio_buttons in a fieldset,
|
|
4
|
+
# its label as the legend, with the description and errors once. Each
|
|
5
|
+
# item is Rails' check box or radio button and label, with the item's own
|
|
6
|
+
# description (description_method), unless a block renders the items, as
|
|
7
|
+
# with Rails.
|
|
8
|
+
class Component < FormComponents::Field::Component
|
|
9
|
+
self.extras = superclass.extras + %i[description_method]
|
|
10
|
+
|
|
11
|
+
# Of an item, as value_method and text_method: a method name or a proc
|
|
12
|
+
option_reader :description_method
|
|
13
|
+
|
|
14
|
+
attr_reader :collection, :value_method, :text_method, :item_html_options, :block
|
|
15
|
+
|
|
16
|
+
# As Rails' collection helpers: (method, collection, value_method, text_method, options = {}, html_options = {}, &block)
|
|
17
|
+
def initialize(form, object, method, collection, value_method, text_method, options = {}, html_options = {}, &block)
|
|
18
|
+
super(form, object, method, options)
|
|
19
|
+
@collection = collection
|
|
20
|
+
@value_method = value_method
|
|
21
|
+
@text_method = text_method
|
|
22
|
+
@item_html_options = (html_options || {}).symbolize_keys
|
|
23
|
+
@block = block
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def items
|
|
27
|
+
each_item = block || ->(builder) { item(builder) }
|
|
28
|
+
rails(helper, method, collection, value_method, text_method, html_options, item_html_options, &each_item)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def legend
|
|
32
|
+
safe_join([label.presence || human_attribute_name, (tag.span(class: "fas fa-asterisk fa-xs text-red-500 ml-1", aria: {hidden: true}) if required?)])
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def legend_classes
|
|
36
|
+
FormComponents::Label::Component::CLASSES
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def items_classes
|
|
40
|
+
"space-y-2"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Shown below the items
|
|
44
|
+
def after_items
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def item(builder)
|
|
50
|
+
id = tag_id(builder.value)
|
|
51
|
+
description = item_description(builder.object)
|
|
52
|
+
aria = {"aria-invalid" => aria_invalid}
|
|
53
|
+
aria["aria-describedby"] = "#{id}_description" if description.present?
|
|
54
|
+
input = builder.public_send(input_helper, aria.merge(class: helpers.class_names(input_classes, item_html_options[:class])))
|
|
55
|
+
|
|
56
|
+
tag.div(class: "relative flex items-start") do
|
|
57
|
+
safe_join([
|
|
58
|
+
tag.div(input, class: "flex h-6 items-center"),
|
|
59
|
+
tag.div(class: "ml-3 text-sm/6") do
|
|
60
|
+
safe_join([
|
|
61
|
+
builder.label(class: FormComponents::Label::Component::CLASSES),
|
|
62
|
+
(tag.label(description, for: id, id: "#{id}_description", class: "text-gray-500 auto-dark:text-gray-400") if description.present?)
|
|
63
|
+
])
|
|
64
|
+
end
|
|
65
|
+
])
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def item_description(item)
|
|
70
|
+
return if description_method.blank?
|
|
71
|
+
|
|
72
|
+
description_method.respond_to?(:call) ? description_method.call(item) : item.public_send(description_method)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
module FormComponents
|
|
2
|
+
module CollectionCheckBoxes
|
|
3
|
+
# Rails' collection_check_boxes, see Collection
|
|
4
|
+
class Component < FormComponents::Collection::Component
|
|
5
|
+
def helper = :collection_check_boxes
|
|
6
|
+
|
|
7
|
+
def input_helper = :check_box
|
|
8
|
+
|
|
9
|
+
def input_classes = FormComponents::CheckBox::Component::CLASSES
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
module FormComponents
|
|
2
|
+
module CollectionRadioButtons
|
|
3
|
+
# Rails' collection_radio_buttons, see Collection
|
|
4
|
+
class Component < FormComponents::Collection::Component
|
|
5
|
+
def helper = :collection_radio_buttons
|
|
6
|
+
|
|
7
|
+
def input_helper = :radio_button
|
|
8
|
+
|
|
9
|
+
def input_classes = FormComponents::RadioButton::Component::CLASSES
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
@import "../../../../vendor/javascript/form_components/jscolorpicker/colorpicker.css";
|
|
2
|
+
|
|
3
|
+
/* JS Color Picker's toggle, looking like the other form fields */
|
|
4
|
+
.form-color-field {
|
|
5
|
+
--cp-size: 2.25rem;
|
|
6
|
+
--cp-border-color: var(--color-gray-300);
|
|
7
|
+
--cp-hover-color: var(--color-gray-400);
|
|
8
|
+
|
|
9
|
+
@variant auto-dark {
|
|
10
|
+
--cp-border-color: rgb(255 255 255 / 0.1);
|
|
11
|
+
--cp-hover-color: var(--color-gray-500);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
.form-color-field .color-picker.cp_wide {
|
|
16
|
+
display: flex;
|
|
17
|
+
width: 100%;
|
|
18
|
+
border: 0;
|
|
19
|
+
border-radius: var(--radius-md);
|
|
20
|
+
outline: 1px solid var(--color-gray-300);
|
|
21
|
+
outline-offset: -1px;
|
|
22
|
+
background-color: white !important;
|
|
23
|
+
|
|
24
|
+
@variant auto-dark {
|
|
25
|
+
outline-color: rgb(255 255 255 / 0.1);
|
|
26
|
+
background-color: rgb(255 255 255 / 0.05) !important;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.form-color-field .color-picker.cp_wide:focus-visible {
|
|
31
|
+
outline: 2px solid var(--color-primary-600);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.form-color-field--error .color-picker.cp_wide {
|
|
35
|
+
outline-color: var(--color-red-300);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.form-color-field .cp_input {
|
|
39
|
+
font-size: var(--text-sm);
|
|
40
|
+
color: var(--color-gray-900);
|
|
41
|
+
background: transparent;
|
|
42
|
+
cursor: pointer;
|
|
43
|
+
|
|
44
|
+
@variant auto-dark {
|
|
45
|
+
color: white;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.form-color-field .cp_input::placeholder {
|
|
50
|
+
color: var(--color-gray-400);
|
|
51
|
+
|
|
52
|
+
@variant auto-dark {
|
|
53
|
+
color: var(--color-gray-500);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.form-color-field .cp_wide .cp_input + .cp_button {
|
|
58
|
+
border-left: 0;
|
|
59
|
+
border-radius: 0 var(--radius-md) var(--radius-md) 0;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.form-color-field .cp_caret {
|
|
63
|
+
display: none;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/* Empty, with a default color: show that rather than a cross */
|
|
67
|
+
.form-color-field--default .cp_button.cp_unset {
|
|
68
|
+
background: var(--color-field-default);
|
|
69
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
.form-component
|
|
2
|
+
= field_label
|
|
3
|
+
.form-color-field.mt-2 class=class_names("form-color-field--error" => has_error?, "form-color-field--default" => default_color) style=(default_color && "--color-field-default: #{default_color}") data-controller="form-components--color-field" data-form-components--color-field-swatches-value=swatches.to_json
|
|
4
|
+
= input
|
|
5
|
+
- if description.present?
|
|
6
|
+
p id=tag_id+"_description" class="mt-2 text-sm text-gray-500 auto-dark:text-gray-400" = description
|
|
7
|
+
- if has_error?
|
|
8
|
+
p id=tag_id+"_error" class="mt-2 text-sm text-red-600 auto-dark:text-red-400" = all_errors.join(", ")
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
module FormComponents
|
|
2
|
+
module ColorField
|
|
3
|
+
# A color as "#rrggbb", picked with JS Color Picker (vendor/javascript/form_components/jscolorpicker).
|
|
4
|
+
#
|
|
5
|
+
# Unlike Rails' color_field, the input is a text field (Rails' text_field,
|
|
6
|
+
# so its name, id and options are Rails'), for the picker, and it can be
|
|
7
|
+
# cleared: an empty value stays empty, where Rails would send #000000, so
|
|
8
|
+
# leaving it empty can mean "the default color".
|
|
9
|
+
class Component < FormComponents::Field::Component
|
|
10
|
+
self.extras = superclass.extras + %i[swatches default]
|
|
11
|
+
|
|
12
|
+
# Colors to pick from with one click, like those of a logo
|
|
13
|
+
option_reader :swatches, []
|
|
14
|
+
# The color used while the field is empty, shown in it then
|
|
15
|
+
option_reader :default
|
|
16
|
+
|
|
17
|
+
def default_color
|
|
18
|
+
default if default.to_s.match?(/\A#\h{6}\z/)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def input
|
|
22
|
+
rails(:text_field, method, input_options(nil).merge(placeholder: default_color || html_options[:placeholder], data: (html_options[:data] || {}).merge("form-components--color-field-target": "input")).compact)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
import ColorPicker from "form_components/jscolorpicker"
|
|
3
|
+
|
|
4
|
+
// Turns the text input into a JS Color Picker that fills in "#rrggbb", or nothing when cleared
|
|
5
|
+
export default class extends Controller {
|
|
6
|
+
static targets = ["input"]
|
|
7
|
+
static values = { swatches: Array }
|
|
8
|
+
|
|
9
|
+
connect() {
|
|
10
|
+
this.picker = new ColorPicker(this.inputTarget, {
|
|
11
|
+
toggleStyle: "input",
|
|
12
|
+
enableAlpha: false,
|
|
13
|
+
formats: false,
|
|
14
|
+
defaultFormat: "hex",
|
|
15
|
+
swatches: this.swatchesValue.length > 0 ? this.swatchesValue : null,
|
|
16
|
+
showClearButton: true,
|
|
17
|
+
dialogPlacement: "bottom-start"
|
|
18
|
+
})
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
disconnect() {
|
|
22
|
+
this.picker?.destroy()
|
|
23
|
+
this.picker = null
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
.form-component
|
|
2
|
+
= field_label
|
|
3
|
+
div.mt-2
|
|
4
|
+
= input
|
|
5
|
+
- if description.present?
|
|
6
|
+
p id=tag_id+"_description" class="mt-2 text-sm text-gray-500 auto-dark:text-gray-400" = description
|
|
7
|
+
- if has_error?
|
|
8
|
+
p id=tag_id+"_error" class="mt-2 text-sm text-red-600 auto-dark:text-red-400" = all_errors.join(", ")
|