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.
Files changed (49) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +29 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +194 -0
  5. data/app/assets/stylesheets/form_components/application.css +9 -0
  6. data/app/assets/tailwind/form_components/engine.css +6 -0
  7. data/app/components/form_components/base/component.rb +76 -0
  8. data/app/components/form_components/check_box/component.html.slim +13 -0
  9. data/app/components/form_components/check_box/component.rb +26 -0
  10. data/app/components/form_components/collection/component.html.slim +10 -0
  11. data/app/components/form_components/collection/component.rb +76 -0
  12. data/app/components/form_components/collection_check_boxes/component.rb +12 -0
  13. data/app/components/form_components/collection_radio_buttons/component.rb +12 -0
  14. data/app/components/form_components/color_field/component.css +69 -0
  15. data/app/components/form_components/color_field/component.html.slim +8 -0
  16. data/app/components/form_components/color_field/component.rb +26 -0
  17. data/app/components/form_components/color_field/component_controller.js +25 -0
  18. data/app/components/form_components/combobox/component.html.slim +8 -0
  19. data/app/components/form_components/combobox/component.rb +28 -0
  20. data/app/components/form_components/field/component.rb +58 -0
  21. data/app/components/form_components/file_field/component.html.slim +10 -0
  22. data/app/components/form_components/file_field/component.rb +25 -0
  23. data/app/components/form_components/label/component.rb +31 -0
  24. data/app/components/form_components/nested_form/component.html.slim +20 -0
  25. data/app/components/form_components/nested_form/component.rb +71 -0
  26. data/app/components/form_components/nested_form/component_controller.js +36 -0
  27. data/app/components/form_components/radio_button/component.html.slim +12 -0
  28. data/app/components/form_components/radio_button/component.rb +24 -0
  29. data/app/components/form_components/rating_field/component.rb +39 -0
  30. data/app/components/form_components/scale_field/component.rb +44 -0
  31. data/app/components/form_components/select/component.html.slim +8 -0
  32. data/app/components/form_components/select/component.rb +35 -0
  33. data/app/components/form_components/submit/component.rb +21 -0
  34. data/app/components/form_components/text_area/component.html.slim +14 -0
  35. data/app/components/form_components/text_area/component.rb +22 -0
  36. data/app/components/form_components/text_field/component.html.slim +16 -0
  37. data/app/components/form_components/text_field/component.rb +33 -0
  38. data/app/components/form_components/validation.rb +41 -0
  39. data/app/form_builders/form_components/form_builder.rb +133 -0
  40. data/app/form_builders/form_components/validation_context.rb +17 -0
  41. data/app/javascript/form_components/application.js +12 -0
  42. data/config/importmap.rb +6 -0
  43. data/lib/form_components/engine.rb +23 -0
  44. data/lib/form_components/version.rb +5 -0
  45. data/lib/form_components.rb +13 -0
  46. data/vendor/javascript/form_components/jscolorpicker/LICENSE +21 -0
  47. data/vendor/javascript/form_components/jscolorpicker/colorpicker.css +409 -0
  48. data/vendor/javascript/form_components/jscolorpicker/colorpicker.js +2410 -0
  49. 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(", ")