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
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
module FormComponents
|
|
2
|
+
module Combobox
|
|
3
|
+
# hotwire_combobox's combobox (form.combobox), with our label above it:
|
|
4
|
+
# its own stays hidden, as it is without a label: text
|
|
5
|
+
class Component < FormComponents::Field::Component
|
|
6
|
+
attr_reader :options_or_src, :block
|
|
7
|
+
|
|
8
|
+
# As hotwire_combobox's combobox(method, options_or_src = [], **options, &block)
|
|
9
|
+
def initialize(form, object, method, options_or_src = [], options = {}, &block)
|
|
10
|
+
super(form, object, method, options)
|
|
11
|
+
@options_or_src = options_or_src
|
|
12
|
+
@block = block
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Its own options, with the aria attributes; a class given replaces its
|
|
16
|
+
# input's, as with hotwire_combobox
|
|
17
|
+
def input
|
|
18
|
+
given = input_options(nil)
|
|
19
|
+
given.delete(:class) if html_options[:class].blank?
|
|
20
|
+
rails(:combobox, method, options_or_src, **given, &block)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def tag_id(value = nil)
|
|
24
|
+
html_options[:id] || form.field_id(method)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
module FormComponents
|
|
2
|
+
module Field
|
|
3
|
+
# A field with a label, a description and its errors around Rails' input
|
|
4
|
+
class Component < FormComponents::Base::Component
|
|
5
|
+
self.extras = %i[label description]
|
|
6
|
+
|
|
7
|
+
# A text, or false for no label
|
|
8
|
+
option_reader :label
|
|
9
|
+
# Shown below the field
|
|
10
|
+
option_reader :description
|
|
11
|
+
|
|
12
|
+
def has_error?
|
|
13
|
+
all_errors.present?
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def all_errors
|
|
17
|
+
return [] if object.nil? || !object.respond_to?(:errors)
|
|
18
|
+
|
|
19
|
+
if /_id$/.match?(method.to_s)
|
|
20
|
+
object.errors[method].presence || object.errors[method.to_s.delete_suffix("_id").to_sym]
|
|
21
|
+
else
|
|
22
|
+
object.errors[method]
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def aria_described_by(id = tag_id)
|
|
27
|
+
if has_error?
|
|
28
|
+
"#{id}_error"
|
|
29
|
+
elsif description.present?
|
|
30
|
+
"#{id}_description"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def aria_invalid
|
|
35
|
+
has_error? ? "true" : "false"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Rails' options with our classes added to the given ones, and the aria
|
|
39
|
+
# attributes unless given
|
|
40
|
+
def input_options(classes, id: tag_id, from: html_options)
|
|
41
|
+
given_aria = from.fetch(:aria, {}).transform_keys(&:to_s)
|
|
42
|
+
aria = {}
|
|
43
|
+
aria["aria-invalid"] = aria_invalid unless from.key?(:"aria-invalid") || given_aria.key?("invalid")
|
|
44
|
+
described_by = aria_described_by(id)
|
|
45
|
+
aria["aria-describedby"] = described_by if described_by && !from.key?(:"aria-describedby") && !given_aria.key?("describedby")
|
|
46
|
+
from.merge(class: helpers.class_names(classes, from[:class])).merge(aria)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# The label of the field's input, unless label: false. With a value, as
|
|
50
|
+
# Rails' label(method, value:) for a radio button: also its translation.
|
|
51
|
+
def field_label(id = tag_id, value: nil)
|
|
52
|
+
return if label == false
|
|
53
|
+
|
|
54
|
+
form.label(method, label.presence, {for: id, value: value}.compact)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
.form-component
|
|
2
|
+
= field_label
|
|
3
|
+
- if preview?
|
|
4
|
+
= image_tag helpers.main_app.url_for(preview.representation(resize_to_limit: [300, 120])), class: "mt-2 max-h-20 rounded", alt: ""
|
|
5
|
+
.mt-2
|
|
6
|
+
= input
|
|
7
|
+
- if description.present?
|
|
8
|
+
p id=tag_id+"_description" class="mt-2 text-sm text-gray-500 auto-dark:text-gray-400" = description
|
|
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,25 @@
|
|
|
1
|
+
module FormComponents
|
|
2
|
+
module FileField
|
|
3
|
+
# Rails' file field, which also makes the form multipart
|
|
4
|
+
class Component < FormComponents::Field::Component
|
|
5
|
+
self.extras = superclass.extras + %i[preview]
|
|
6
|
+
|
|
7
|
+
# An attached image to show above the field, as what's there now
|
|
8
|
+
option_reader :preview
|
|
9
|
+
|
|
10
|
+
# Not for a file just uploaded with a form that didn't save, as it isn't stored yet
|
|
11
|
+
def preview?
|
|
12
|
+
preview.respond_to?(:attached?) && preview.attached? && preview.blob.persisted? && preview.representable?
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def input
|
|
16
|
+
rails(:file_field, method, input_options(file_field_classes))
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def file_field_classes
|
|
20
|
+
class_names("block w-full text-sm text-gray-500 file:mr-4 file:rounded-md file:border-0 file:bg-primary-50 file:px-3 file:py-2 file:text-sm file:font-semibold file:text-primary-700 hover:file:bg-primary-100 auto-dark:text-gray-400 auto-dark:file:bg-primary-500/10 auto-dark:file:text-primary-400 auto-dark:hover:file:bg-primary-500/20",
|
|
21
|
+
{"text-red-900 auto-dark:text-red-300" => has_error?})
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
module FormComponents
|
|
2
|
+
module Label
|
|
3
|
+
# Rails' label, with its text marked when the attribute is required
|
|
4
|
+
class Component < FormComponents::Base::Component
|
|
5
|
+
CLASSES = "block text-sm/6 font-medium text-gray-900 auto-dark:text-white"
|
|
6
|
+
|
|
7
|
+
attr_reader :text, :block
|
|
8
|
+
|
|
9
|
+
# As Rails' label(method, text = nil, options = {}, &block)
|
|
10
|
+
def initialize(form, object, method, text = nil, options = {}, &block)
|
|
11
|
+
text, options = nil, text if text.is_a?(Hash)
|
|
12
|
+
super(form, object, method, options)
|
|
13
|
+
@text = text
|
|
14
|
+
@block = block
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def call
|
|
18
|
+
rails(:label, method, text, html_options.merge(class: helpers.class_names(CLASSES, html_options[:class]))) do |builder|
|
|
19
|
+
content = block ? helpers.capture(builder, &block) : (text.presence || builder.translation)
|
|
20
|
+
safe_join([content, (asterisk if required?)])
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def asterisk
|
|
27
|
+
tag.span(class: "fas fa-asterisk fa-xs text-red-500 ml-1", aria: {hidden: true})
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/ ─── Stimulus form-components--nested-form controller ─────────────────────
|
|
2
|
+
/ data-form-components--nested-form-placeholder-value — token replaced with a timestamp on "Add"
|
|
3
|
+
/ data-form-components--nested-form-wrapper-selector-value — CSS selector for remove-button scope
|
|
4
|
+
div data-controller="form-components--nested-form" data-form-components--nested-form-placeholder-value=placeholder data-form-components--nested-form-wrapper-selector-value=wrapper_selector
|
|
5
|
+
|
|
6
|
+
template data-form-components--nested-form-target="template"
|
|
7
|
+
= form.fields_for(method, new_record_object, child_index: placeholder, &item_block)
|
|
8
|
+
|
|
9
|
+
/ ── Collection ──────────────────────────────────────────────────────────────
|
|
10
|
+
= form.fields_for(method, &item_block)
|
|
11
|
+
|
|
12
|
+
div data-form-components--nested-form-target="insertion"
|
|
13
|
+
|
|
14
|
+
span data-action="click->form-components--nested-form#add"
|
|
15
|
+
- if add_button?
|
|
16
|
+
= add_button
|
|
17
|
+
- else
|
|
18
|
+
button.mt-4.inline-flex.items-center.gap-x-2.rounded-md.border.border-dashed.border-primary-300.px-4.py-3.text-sm.font-medium.text-primary-600.hover:border-primary-500.hover:bg-primary-50 type="button"
|
|
19
|
+
i.fal.fa-plus
|
|
20
|
+
span = add_label
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FormComponents
|
|
4
|
+
module NestedForm
|
|
5
|
+
# ViewComponent that encapsulates the Stimulus-driven nested-form pattern.
|
|
6
|
+
#
|
|
7
|
+
# Usage (in a Slim template with a FormComponents::FormBuilder):
|
|
8
|
+
#
|
|
9
|
+
# = f.nested_form :sections do |nf|
|
|
10
|
+
# - nf.with_item do |sf|
|
|
11
|
+
# .section-wrapper data-new-record=sf.object.new_record?.to_s
|
|
12
|
+
# = sf.hidden_field :_destroy
|
|
13
|
+
# = sf.text_field :title
|
|
14
|
+
# - nf.with_add_button
|
|
15
|
+
# button type="button" data-action="form-components--nested-form#add" Add section
|
|
16
|
+
#
|
|
17
|
+
# The same +with_item+ block is used for both:
|
|
18
|
+
# • the hidden HTML <template> tag (rendered with a new record so that
|
|
19
|
+
# Stimulus can clone it when the user clicks "Add"), and
|
|
20
|
+
# • each existing / in-memory record returned by fields_for.
|
|
21
|
+
#
|
|
22
|
+
# Options (passed as keyword arguments to f.nested_form):
|
|
23
|
+
# :placeholder – Stimulus placeholder string, default "NEW_<SINGULAR>"
|
|
24
|
+
# :wrapper_selector – CSS selector the remove handler uses to find the
|
|
25
|
+
# wrapping element, default ".<singular>-wrapper"
|
|
26
|
+
# :new_record – object used as the template record (default: association klass.new)
|
|
27
|
+
# :add_label – fallback label for the auto-generated add button
|
|
28
|
+
class Component < FormComponents::Base::Component
|
|
29
|
+
renders_one :add_button
|
|
30
|
+
|
|
31
|
+
attr_reader :item_block
|
|
32
|
+
|
|
33
|
+
def with_item(&block)
|
|
34
|
+
@item_block = block
|
|
35
|
+
self
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Execute the outer (slot-collecting) block so that with_item and
|
|
39
|
+
# with_add_button are called and their content registered before
|
|
40
|
+
# ViewComponent renders the template.
|
|
41
|
+
def render_in(view_context, &block)
|
|
42
|
+
block.call(self) if block_given?
|
|
43
|
+
super(view_context)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Stimulus placeholder token, e.g. "NEW_SECTION" for :sections.
|
|
47
|
+
def placeholder
|
|
48
|
+
options.fetch(:placeholder) { "NEW_#{method.to_s.singularize.upcase}" }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# CSS selector used by the Stimulus remove action to locate the wrapper
|
|
52
|
+
# element, e.g. ".section-wrapper" for :sections.
|
|
53
|
+
def wrapper_selector
|
|
54
|
+
options.fetch(:wrapper_selector) { ".#{method.to_s.singularize}-wrapper" }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Label for the fallback add button when no with_add_button slot is given.
|
|
58
|
+
def add_label
|
|
59
|
+
options.fetch(:add_label) { "Add #{method.to_s.singularize.humanize.downcase}" }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# An unsaved instance of the associated model used as the record inside
|
|
63
|
+
# the HTML <template> tag so Stimulus can clone it on "Add".
|
|
64
|
+
def new_record_object
|
|
65
|
+
options.fetch(:new_record) do
|
|
66
|
+
object.class.reflect_on_association(method)&.klass&.new
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus";
|
|
2
|
+
|
|
3
|
+
export default class extends Controller {
|
|
4
|
+
static targets = ["insertion", "template"];
|
|
5
|
+
static values = {
|
|
6
|
+
wrapperSelector: { type: String, default: ".nested-form-wrapper" },
|
|
7
|
+
placeholder: { type: String, default: "NEW_RECORD" },
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
add(e) {
|
|
11
|
+
e.preventDefault();
|
|
12
|
+
const content = this.templateTarget.innerHTML.replace(
|
|
13
|
+
new RegExp(this.placeholderValue, "g"),
|
|
14
|
+
new Date().getTime().toString(),
|
|
15
|
+
);
|
|
16
|
+
this.insertionTarget.insertAdjacentHTML("beforebegin", content);
|
|
17
|
+
this.element.dispatchEvent(
|
|
18
|
+
new CustomEvent("nested-form:add", { bubbles: true }),
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
remove(e) {
|
|
23
|
+
e.preventDefault();
|
|
24
|
+
const wrapper = e.target.closest(this.wrapperSelectorValue);
|
|
25
|
+
if (wrapper.dataset.newRecord === "true") {
|
|
26
|
+
wrapper.remove();
|
|
27
|
+
} else {
|
|
28
|
+
wrapper.style.display = "none";
|
|
29
|
+
const input = wrapper.querySelector("input[name*='_destroy']");
|
|
30
|
+
input.value = "1";
|
|
31
|
+
}
|
|
32
|
+
this.element.dispatchEvent(
|
|
33
|
+
new CustomEvent("nested-form:remove", { bubbles: true }),
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
.form-component.relative.flex.items-start
|
|
2
|
+
.flex.h-6.items-center
|
|
3
|
+
= input
|
|
4
|
+
.ml-3.text-sm/6
|
|
5
|
+
- if label != false
|
|
6
|
+
div.flex.justify-between
|
|
7
|
+
= field_label(input_id, value: tag_value)
|
|
8
|
+
- if description.present?
|
|
9
|
+
label.text-gray-500.auto-dark:text-gray-400 for=input_id id=input_id+"_description"
|
|
10
|
+
= description
|
|
11
|
+
- if has_error?
|
|
12
|
+
p id=input_id+"_error" class="mt-2 text-sm text-red-600 auto-dark:text-red-400" = all_errors.join(", ")
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
module FormComponents
|
|
2
|
+
module RadioButton
|
|
3
|
+
# Rails' radio button
|
|
4
|
+
class Component < FormComponents::Field::Component
|
|
5
|
+
CLASSES = "relative size-4 appearance-none rounded-full border border-gray-300 bg-white before:absolute before:inset-1 before:rounded-full before: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 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:before:bg-gray-400 forced-colors:appearance-auto forced-colors:before:hidden [&:not(:checked)]:before:hidden hover:ring-primary-600 focus:ring-primary-600 focus:bg-primary-600 checked:focus:bg-primary-600"
|
|
6
|
+
|
|
7
|
+
attr_reader :tag_value
|
|
8
|
+
|
|
9
|
+
# As Rails' radio_button(method, tag_value, options = {})
|
|
10
|
+
def initialize(form, object, method, tag_value, options = {})
|
|
11
|
+
super(form, object, method, options)
|
|
12
|
+
@tag_value = tag_value
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def input
|
|
16
|
+
rails(:radio_button, method, tag_value, input_options(CLASSES, id: input_id))
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def input_id
|
|
20
|
+
tag_id(tag_value)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
module FormComponents
|
|
2
|
+
module RatingField
|
|
3
|
+
# Stars to rate with, 1 to max: (5 unless given): Rails'
|
|
4
|
+
# collection_radio_buttons of their numbers, a star for each. A star
|
|
5
|
+
# lights up when its number or a higher one is checked or hovered.
|
|
6
|
+
class Component < FormComponents::CollectionRadioButtons::Component
|
|
7
|
+
self.extras = superclass.extras + %i[max]
|
|
8
|
+
|
|
9
|
+
ITEM_CLASSES = "cursor-pointer text-gray-300 hover:text-amber-300 [&:has(~label:hover)]:text-amber-300 [&:has(~input:checked)]:text-amber-400 auto-dark:text-white/20"
|
|
10
|
+
|
|
11
|
+
STAR = "M10.788 3.21c.448-1.077 1.976-1.077 2.424 0l2.082 5.006 5.404.434c1.164.093 1.636 1.545.749 2.305l-4.117 3.527 1.257 5.273c.271 1.136-.964 2.033-1.96 1.425L12 18.354 7.373 21.18c-.996.608-2.231-.29-1.96-1.425l1.257-5.273-4.117-3.527c-.887-.76-.415-2.212.749-2.305l5.404-.434 2.082-5.005Z"
|
|
12
|
+
|
|
13
|
+
# As FormBuilder#rating_field(method, options = {})
|
|
14
|
+
def initialize(form, object, method, options = {})
|
|
15
|
+
options = (options || {}).symbolize_keys
|
|
16
|
+
super(form, object, method, (1..options.fetch(:max, 5)).to_a, :itself, :itself, options)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def items_classes
|
|
20
|
+
"flex w-fit gap-1 rounded-md has-[:focus-visible]:outline has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-primary-600"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
# Its label first, so a star can see the inputs after it
|
|
26
|
+
def item(builder)
|
|
27
|
+
input = builder.radio_button(class: "sr-only", "aria-invalid": aria_invalid)
|
|
28
|
+
label = builder.label(class: ITEM_CLASSES) do
|
|
29
|
+
safe_join([star, tag.span(builder.text, class: "sr-only")])
|
|
30
|
+
end
|
|
31
|
+
safe_join([label, input])
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def star
|
|
35
|
+
tag.svg(tag.path(d: STAR, "fill-rule": "evenodd", "clip-rule": "evenodd"), viewBox: "0 0 24 24", fill: "currentColor", class: "size-8", aria: {hidden: true})
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
module FormComponents
|
|
2
|
+
module ScaleField
|
|
3
|
+
# A scale to pick a number on, in: 1..5 unless given, with min_label: and
|
|
4
|
+
# max_label: at its ends: Rails' collection_radio_buttons of its numbers,
|
|
5
|
+
# as buttons
|
|
6
|
+
class Component < FormComponents::CollectionRadioButtons::Component
|
|
7
|
+
self.extras = superclass.extras + %i[in min_label max_label]
|
|
8
|
+
|
|
9
|
+
option_reader :min_label
|
|
10
|
+
option_reader :max_label
|
|
11
|
+
|
|
12
|
+
ITEM_CLASSES = "flex h-10 cursor-pointer items-center justify-center rounded-md bg-white text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 peer-checked:bg-primary-600 peer-checked:text-white peer-checked:ring-0 peer-checked:hover:bg-primary-700 peer-focus-visible:outline peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-primary-600 auto-dark:bg-white/5 auto-dark:text-white auto-dark:ring-white/10 auto-dark:hover:bg-white/10"
|
|
13
|
+
|
|
14
|
+
# As FormBuilder#scale_field(method, options = {})
|
|
15
|
+
def initialize(form, object, method, options = {})
|
|
16
|
+
options = (options || {}).symbolize_keys
|
|
17
|
+
super(form, object, method, numbers(options), :itself, :itself, options)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def items_classes
|
|
21
|
+
"flex gap-1"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def after_items
|
|
25
|
+
return if min_label.blank? && max_label.blank?
|
|
26
|
+
|
|
27
|
+
tag.div(class: "mt-2 flex justify-between gap-4 text-sm text-gray-500 auto-dark:text-gray-400") do
|
|
28
|
+
safe_join([tag.span(min_label), tag.span(max_label, class: "text-right")])
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def numbers(options)
|
|
35
|
+
options.fetch(:in, 1..5).to_a
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def item(builder)
|
|
39
|
+
input = builder.radio_button(class: "peer sr-only", "aria-invalid": aria_invalid)
|
|
40
|
+
tag.div(safe_join([input, builder.label(class: ITEM_CLASSES)]), class: "min-w-0 flex-1")
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -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(", ")
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
module FormComponents
|
|
2
|
+
module Select
|
|
3
|
+
# Rails' select, or another of its kind (helper: :collection_select, …),
|
|
4
|
+
# given the arguments Rails takes between method and options. Its arrow
|
|
5
|
+
# is @tailwindcss/forms'.
|
|
6
|
+
class Component < FormComponents::Field::Component
|
|
7
|
+
attr_reader :helper, :arguments, :select_html_options, :block
|
|
8
|
+
|
|
9
|
+
# Our options can be in either hash
|
|
10
|
+
def initialize(form, object, method, arguments, options = {}, html_options = {}, helper: :select, &block)
|
|
11
|
+
html_options = (html_options || {}).symbolize_keys
|
|
12
|
+
super(form, object, method, (options || {}).merge(html_options.slice(*extras)))
|
|
13
|
+
@arguments = arguments
|
|
14
|
+
@select_html_options = html_options.except(*extras)
|
|
15
|
+
@helper = helper
|
|
16
|
+
@block = block
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def input
|
|
20
|
+
rails(helper, method, *arguments, html_options, input_options(select_classes, from: select_html_options), &block)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Rails takes a select's id from its html_options
|
|
24
|
+
def id_options
|
|
25
|
+
select_html_options
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def select_classes
|
|
29
|
+
class_names("block w-full rounded-md bg-white py-1.5 pl-3 pr-10 text-base text-gray-900 outline outline-1 -outline-offset-1 focus:outline focus:outline-2 focus:-outline-offset-1 sm:text-sm/6 auto-dark:bg-white/5 auto-dark:text-white",
|
|
30
|
+
{"outline-gray-300 focus:outline-primary-600 auto-dark:outline-white/10" => !has_error?},
|
|
31
|
+
{"text-red-900 outline-red-300 focus:outline-red-600 auto-dark:text-red-300 auto-dark:outline-red-500/50" => has_error?})
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
module FormComponents
|
|
2
|
+
module Submit
|
|
3
|
+
# Rails' submit button, named after the record as Rails names it
|
|
4
|
+
class Component < FormComponents::Base::Component
|
|
5
|
+
CLASSES = "rounded-md bg-primary-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-primary-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-600"
|
|
6
|
+
|
|
7
|
+
attr_reader :value
|
|
8
|
+
|
|
9
|
+
# As Rails' submit(value = nil, options = {})
|
|
10
|
+
def initialize(form, object, value = nil, options = {})
|
|
11
|
+
value, options = nil, value if value.is_a?(Hash)
|
|
12
|
+
super(form, object, nil, options)
|
|
13
|
+
@value = value
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call
|
|
17
|
+
rails(:submit, value, html_options.merge(class: helpers.class_names(CLASSES, html_options[:class])))
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
.form-component
|
|
2
|
+
- if label != false || corner_hint.present?
|
|
3
|
+
div.flex.justify-between
|
|
4
|
+
= field_label
|
|
5
|
+
span.text-sm/6.text-gray-500.auto-dark:text-gray-400
|
|
6
|
+
= corner_hint
|
|
7
|
+
div.mt-2 class="grid grid-cols-1"
|
|
8
|
+
= input
|
|
9
|
+
- if has_error?
|
|
10
|
+
.fas.fa-circle-exclamation class="pointer-events-none col-start-1 row-start-1 mr-3 size-5 self-center justify-self-end text-red-500 sm:size-4"
|
|
11
|
+
- if description.present?
|
|
12
|
+
p id=tag_id+"_description" class="mt-2 text-sm text-gray-500 auto-dark:text-gray-400" = description
|
|
13
|
+
- if has_error?
|
|
14
|
+
p id=tag_id+"_error" class="mt-2 text-sm text-red-600 auto-dark:text-red-400" = all_errors.join(", ")
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
module FormComponents
|
|
2
|
+
module TextArea
|
|
3
|
+
# Rails' text area
|
|
4
|
+
class Component < FormComponents::Field::Component
|
|
5
|
+
self.extras = superclass.extras + %i[corner_hint]
|
|
6
|
+
|
|
7
|
+
# Next to the label, above the field
|
|
8
|
+
option_reader :corner_hint
|
|
9
|
+
|
|
10
|
+
def input
|
|
11
|
+
rails(:text_area, method, input_options(text_area_classes))
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def text_area_classes
|
|
15
|
+
class_names("block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline outline-1 -outline-offset-1 placeholder:text-gray-400 focus:outline focus:outline-2 focus:-outline-offset-1 sm:text-sm/6 auto-dark:bg-white/5 auto-dark:text-white auto-dark:placeholder:text-gray-500",
|
|
16
|
+
{"col-start-1 row-start-1 pr-3" => has_error?},
|
|
17
|
+
{"outline-gray-300 focus:outline-primary-600 auto-dark:outline-white/10" => !has_error?},
|
|
18
|
+
{"text-red-900 outline-red-300 placeholder:text-red-300 focus:outline-red-600 auto-dark:text-red-300 auto-dark:outline-red-500/50" => has_error?})
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
.form-component
|
|
2
|
+
- if label != false || corner_hint.present?
|
|
3
|
+
div.flex.justify-between
|
|
4
|
+
= field_label
|
|
5
|
+
span.text-sm/6.text-gray-500.auto-dark:text-gray-400
|
|
6
|
+
= corner_hint
|
|
7
|
+
div.mt-2 class="grid grid-cols-1"
|
|
8
|
+
= input
|
|
9
|
+
- if leading_icon.present?
|
|
10
|
+
span class=class_names("pointer-events-none col-start-1 row-start-1 ml-3 size-5 self-center text-gray-400 sm:size-4", leading_icon)
|
|
11
|
+
- if has_error?
|
|
12
|
+
.fas.fa-circle-exclamation class="pointer-events-none col-start-1 row-start-1 mr-3 size-5 self-center justify-self-end text-red-500 sm:size-4"
|
|
13
|
+
- if description.present?
|
|
14
|
+
p id=tag_id+"_description" class="mt-2 text-sm text-gray-500 auto-dark:text-gray-400" = description
|
|
15
|
+
- if has_error?
|
|
16
|
+
p id=tag_id+"_error" class="mt-2 text-sm text-red-600 auto-dark:text-red-400" = all_errors.join(", ")
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
module FormComponents
|
|
2
|
+
module TextField
|
|
3
|
+
# Rails' text field, or another of its kind (helper: :email_field, …)
|
|
4
|
+
class Component < FormComponents::Field::Component
|
|
5
|
+
self.extras = superclass.extras + %i[leading_icon corner_hint]
|
|
6
|
+
|
|
7
|
+
# Inside the field at its start, e.g. "fas fa-envelope"
|
|
8
|
+
option_reader :leading_icon
|
|
9
|
+
# Next to the label, above the field
|
|
10
|
+
option_reader :corner_hint
|
|
11
|
+
|
|
12
|
+
attr_reader :helper
|
|
13
|
+
|
|
14
|
+
# Options given as keywords count as options too
|
|
15
|
+
def initialize(form, object, method, options = {}, helper: :text_field, **more)
|
|
16
|
+
super(form, object, method, options.merge(more))
|
|
17
|
+
@helper = helper
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def input
|
|
21
|
+
rails(helper, method, input_options(text_field_classes))
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def text_field_classes
|
|
25
|
+
class_names("block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline outline-1 -outline-offset-1 placeholder:text-gray-400 focus:outline focus:outline-2 focus:-outline-offset-1 sm:text-sm/6 auto-dark:bg-white/5 auto-dark:text-white auto-dark:placeholder:text-gray-500",
|
|
26
|
+
{"col-start-1 row-start-1 pl-10 pr-3" => leading_icon.present?},
|
|
27
|
+
{"col-start-1 row-start-1 pr-3" => has_error?},
|
|
28
|
+
{"outline-gray-300 focus:outline-primary-600 auto-dark:outline-white/10" => !has_error?},
|
|
29
|
+
{"text-red-900 outline-red-300 placeholder:text-red-300 focus:outline-red-600 auto-dark:text-red-300 auto-dark:outline-red-500/50" => has_error?})
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
module FormComponents
|
|
2
|
+
module Validation
|
|
3
|
+
extend ActiveSupport::Concern
|
|
4
|
+
|
|
5
|
+
included do
|
|
6
|
+
delegate :validation_context, to: :form
|
|
7
|
+
|
|
8
|
+
def optional?(context: validation_context)
|
|
9
|
+
return false if object.nil?
|
|
10
|
+
|
|
11
|
+
!required?(context: context)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def required?(context: validation_context)
|
|
15
|
+
return false if object.nil?
|
|
16
|
+
|
|
17
|
+
validators(context: context).any?(ActiveModel::Validations::PresenceValidator)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def validators(context: validation_context)
|
|
21
|
+
method_validators.select do |validator|
|
|
22
|
+
if context.nil?
|
|
23
|
+
validator.options[:on].blank?
|
|
24
|
+
else
|
|
25
|
+
Array(validator.options[:on]).include?(context&.to_sym)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def method_validators
|
|
33
|
+
@method_validators ||= if object.nil?
|
|
34
|
+
[]
|
|
35
|
+
else
|
|
36
|
+
object.class.validators_on(method)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|