diff --git a/Gemfile.lock b/Gemfile.lock index 8156dd4..0f00e9b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - rails_bootstrap_form (0.2.3) + rails_bootstrap_form (0.3.0) GEM remote: http://rubygems.org/ diff --git a/app/assets/images/exclamation-triangle.svg b/app/assets/images/exclamation-triangle.svg new file mode 100644 index 0000000..9526c89 --- /dev/null +++ b/app/assets/images/exclamation-triangle.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/app/assets/stylesheets/rails_bootstrap_form.css b/app/assets/stylesheets/rails_bootstrap_form.css index 2c8a441..ef50248 100644 --- a/app/assets/stylesheets/rails_bootstrap_form.css +++ b/app/assets/stylesheets/rails_bootstrap_form.css @@ -8,3 +8,9 @@ label.required::after { top: -2px; font-weight: bolder; } +.form-control.is-invalid, .form-select.is-invalid { + background-image: url("exclamation-triangle.svg") !important; + background-repeat: no-repeat !important; + background-position: right 0.5rem center, center right 2rem !important; + background-size: 24px 24px, calc(0.75em + 0.375rem) calc(0.75em + 0.375rem) !important; +} diff --git a/lib/rails_bootstrap_form.rb b/lib/rails_bootstrap_form.rb index be24e8c..4f6881f 100644 --- a/lib/rails_bootstrap_form.rb +++ b/lib/rails_bootstrap_form.rb @@ -13,8 +13,9 @@ module RailsBootstrapForm autoload :Configuration autoload :BootstrapFormOptions autoload :BootstrapFormBuilder - autoload :FieldWrapperBuilder autoload :Components + autoload :FieldWrapperBuilder + autoload :InputGroupBuilder autoload :Inputs end diff --git a/lib/rails_bootstrap_form/bootstrap_form_builder.rb b/lib/rails_bootstrap_form/bootstrap_form_builder.rb index 4396d28..fde7888 100644 --- a/lib/rails_bootstrap_form/bootstrap_form_builder.rb +++ b/lib/rails_bootstrap_form/bootstrap_form_builder.rb @@ -7,6 +7,7 @@ class BootstrapFormBuilder < ActionView::Helpers::FormBuilder include RailsBootstrapForm::FieldWrapperBuilder include RailsBootstrapForm::Components + include RailsBootstrapForm::InputGroupBuilder include RailsBootstrapForm::Inputs delegate :capture, :concat, :tag, to: :@template diff --git a/lib/rails_bootstrap_form/bootstrap_form_options.rb b/lib/rails_bootstrap_form/bootstrap_form_options.rb index b16d17a..8d3c13d 100644 --- a/lib/rails_bootstrap_form/bootstrap_form_options.rb +++ b/lib/rails_bootstrap_form/bootstrap_form_options.rb @@ -35,12 +35,15 @@ class BootstrapFormOptions attr_accessor :help_text # An option to override automatically generated label text. + # Default is `nil`. attr_accessor :label_text # An option to custmize whether the label is to be displayed or not. + # Default is `false`. attr_accessor :skip_label # An option to customize whether the label is only visible to screen readers. + # Default is `false`. attr_accessor :hide_label # The CSS class that will be used when the label is only accessible by screen @@ -52,9 +55,33 @@ class BootstrapFormOptions attr_accessor :label_class # An additional CSS class that will be added along with the existing - # `label_class` of the label. Default is nil. + # `label_class` of the label. Default is `nil`. attr_accessor :additional_label_class + # Input group specific options. Input groups allow prepending and appending + # arbitrary html or text to the field. + # + # Example: + # + # form.text_field :dollars, bootstrap_form: {input_group: {prepend: "$", append: ".00"}} + # form.text_field :search, bootstrap_form: {input_group: {append: button_tag("Go", type: :submit, class: "btn btn-secondary")}} + # + # Raw or HTML content to be prepended to the field. + # Default is `nil`. + attr_accessor :prepend + + # Raw or HTML content to be appended to the field. + # Default is `nil`. + attr_accessor :append + + # Append additional CSS class added to the input group wrapper. + # Default is `nil`. + attr_accessor :additional_input_group_class + + # Option to control whether the field should have a floating label. + # Default is false. + attr_accessor :floating + def initialize(options = {}) set_defaults set_bootstrap_form_options(options) @@ -105,12 +132,14 @@ def set_defaults @help_text = nil - @label_text = "" + @label_text = nil @skip_label = false @hide_label = false @hide_class = "visually-hidden" @label_class = "form-label" @additional_label_class = nil + + @floating = false end private :set_defaults diff --git a/lib/rails_bootstrap_form/components.rb b/lib/rails_bootstrap_form/components.rb index 8a73735..94079c2 100644 --- a/lib/rails_bootstrap_form/components.rb +++ b/lib/rails_bootstrap_form/components.rb @@ -9,9 +9,11 @@ module Components autoload :HelpText autoload :Labels autoload :RequiredField + autoload :Errors include HelpText include Labels include RequiredField + include Errors end end diff --git a/lib/rails_bootstrap_form/components/errors.rb b/lib/rails_bootstrap_form/components/errors.rb new file mode 100644 index 0000000..2288a47 --- /dev/null +++ b/lib/rails_bootstrap_form/components/errors.rb @@ -0,0 +1,67 @@ +# -*- encoding: utf-8 -*- +# -*- frozen_string_literal: true -*- +# -*- warn_indent: true -*- + +module RailsBootstrapForm + module Components + module Errors + extend ActiveSupport::Concern + + def self.included(base_class) + def is_invalid?(attribute) + (attribute && object.respond_to?(:errors) && object.errors[attribute].any?) || + has_association_error?(attribute) + end + + def input_with_error(attribute, &block) + input = capture(&block) + input << generate_error(attribute) + input + end + + def generate_error(attribute) + if is_invalid?(attribute) + error_text = error_messages(attribute) + error_klass = "invalid-feedback" + + tag.div(error_text, class: error_klass) + end + end + + def has_association_error?(attribute) + object.class.reflections.any? do |association_name, association| + next unless is_belongs_to_association?(association) + next unless is_association_same?(attribute, association) + + object.errors[association_name].any? + end + end + + def error_messages(attribute) + messages = object.errors[attribute] + + object.class.reflections.each do |association_name, association| + next unless is_belongs_to_association?(association) + next unless is_association_same?(attribute, association) + + messages << object.errors[association_name] + end + + messages.flatten.to_sentence + end + + def is_belongs_to_association?(association) + association.is_a?(ActiveRecord::Reflection::BelongsToReflection) + end + + def is_association_same?(attribute, association) + (association.foreign_key == attribute.to_s) + end + + private :is_invalid?, :input_with_error, :generate_error, + :has_association_error?, :error_messages, + :is_belongs_to_association?, :is_association_same? + end + end + end +end diff --git a/lib/rails_bootstrap_form/components/labels.rb b/lib/rails_bootstrap_form/components/labels.rb index 151b728..8d3fe7f 100644 --- a/lib/rails_bootstrap_form/components/labels.rb +++ b/lib/rails_bootstrap_form/components/labels.rb @@ -21,6 +21,7 @@ def label_classes(attribute, bootstrap_options) classes = [bootstrap_options.label_class, bootstrap_options.additional_label_class] classes << bootstrap_options.hide_class if bootstrap_options.hide_label classes << "required" if is_attribute_required?(attribute) + classes << "is-invalid" if is_invalid?(attribute) classes.flatten.compact end diff --git a/lib/rails_bootstrap_form/components/required_field.rb b/lib/rails_bootstrap_form/components/required_field.rb index fa100e0..048ba59 100644 --- a/lib/rails_bootstrap_form/components/required_field.rb +++ b/lib/rails_bootstrap_form/components/required_field.rb @@ -18,7 +18,7 @@ def is_field_required?(attribute, options) end end - def required_field_options(options, attribute) + def required_field_options(attribute, options) required = is_field_required?(attribute, options) {}.tap do |option| diff --git a/lib/rails_bootstrap_form/field_wrapper_builder.rb b/lib/rails_bootstrap_form/field_wrapper_builder.rb index c0c5fb1..48ff325 100644 --- a/lib/rails_bootstrap_form/field_wrapper_builder.rb +++ b/lib/rails_bootstrap_form/field_wrapper_builder.rb @@ -16,10 +16,24 @@ def field_wrapper(attribute, bootstrap_options, options, &block) label = label(attribute, bootstrap_options) help_text = help_text(attribute, bootstrap_options) - tag.div(class: field_wrapper_classes) do - concat(label) - concat(capture(&block)) - concat(help_text) + if bootstrap_options.floating + tag.div(class: field_wrapper_classes) do + concat(input_group_wrapper(attribute, bootstrap_options) do + tag.div(class: floating_label_classes(attribute)) do + concat(capture(&block)) + concat(label) + end + end) + concat(help_text) + end + else + tag.div(class: field_wrapper_classes) do + concat(label) + concat(input_group_wrapper(attribute, bootstrap_options) do + capture(&block) + end) + concat(help_text) + end end end @@ -42,12 +56,28 @@ def field_css_options(attribute, bootstrap_options, options, html_options) bootstrap_options.field_class, bootstrap_options.additional_field_class ] + field_classes << "is-invalid" if is_invalid?(attribute) + css_options[:class] = field_classes.flatten.compact + css_options.merge!(required_field_options(attribute, options)) + + if bootstrap_options.floating + css_options[:placeholder] ||= label_text(attribute, bootstrap_options) + end + css_options end + def floating_label_classes(attribute) + classes = Array("form-floating") + # Floating label fields with input group requires `is-invalid` class in + # order to display error messages. + classes << "is-invalid" if is_invalid?(attribute) + classes + end + private :field_wrapper, :field_wrapper_classes, :form_wrapper_default_class, - :field_css_options + :field_css_options, :floating_label_classes end end diff --git a/lib/rails_bootstrap_form/input_group_builder.rb b/lib/rails_bootstrap_form/input_group_builder.rb new file mode 100644 index 0000000..2ff07cc --- /dev/null +++ b/lib/rails_bootstrap_form/input_group_builder.rb @@ -0,0 +1,49 @@ +# -*- encoding: utf-8 -*- +# -*- frozen_string_literal: true -*- +# -*- warn_indent: true -*- + +module RailsBootstrapForm + module InputGroupBuilder + extend ActiveSupport::Concern + + def self.included(base_class) + def input_group_wrapper(attribute, bootstrap_options, &block) + input = capture(&block) || ActiveSupport::SafeBuffer.new + + prepend = attach_input(bootstrap_options, :prepend) + append = attach_input(bootstrap_options, :append) + + input = prepend + input + append + input += generate_error(attribute) + + input = tag.div(input, class: input_group_classes(attribute, bootstrap_options)) + + input + end + + def input_group_classes(attribute, bootstrap_options) + classes = ["input-group", bootstrap_options.additional_input_group_class] + # Require `has-validation` class if field has errors. + classes << "has-validation" if is_invalid?(attribute) + classes.flatten.compact + end + + def attach_input(bootstrap_options, key) + tags = [*bootstrap_options.send(key)].map do |item| + input_group_content(item) + end + + ActiveSupport::SafeBuffer.new(tags.join) + end + + def input_group_content(content) + return content if /button|submit/.match?(content) + + tag.span(content.html_safe, class: "input-group-text") + end + + private :input_group_wrapper, :input_group_classes, :attach_input, + :input_group_content + end + end +end diff --git a/lib/rails_bootstrap_form/version.rb b/lib/rails_bootstrap_form/version.rb index 22cb0fe..baf4df7 100644 --- a/lib/rails_bootstrap_form/version.rb +++ b/lib/rails_bootstrap_form/version.rb @@ -3,6 +3,6 @@ # -*- warn_indent: true -*- module RailsBootstrapForm - VERSION = "0.2.3".freeze + VERSION = "0.3.0".freeze REQUIRED_RAILS_VERSION = "~> 7.0".freeze end diff --git a/spec/rails_bootstrap_form_spec.rb b/spec/rails_bootstrap_form_spec.rb index 35c4210..2be3b56 100644 --- a/spec/rails_bootstrap_form_spec.rb +++ b/spec/rails_bootstrap_form_spec.rb @@ -6,7 +6,7 @@ RSpec.describe RailsBootstrapForm do it "has a valid version number" do - expect(RailsBootstrapForm::VERSION).to eq("0.2.3") + expect(RailsBootstrapForm::VERSION).to eq("0.3.0") end it "has a valid rails version number" do