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
@@ -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