diff --git a/README.md b/README.md index b6b3d637..4c44423b 100644 --- a/README.md +++ b/README.md @@ -1493,38 +1493,38 @@ Generated HTML:
``` @@ -1554,8 +1554,8 @@ Generated HTML: ```html ``` @@ -1652,7 +1652,7 @@ Which outputs: ```html ``` @@ -1673,7 +1673,7 @@ Which outputs: ```html ``` @@ -1692,7 +1692,7 @@ Which outputs: ```html ``` diff --git a/lib/bootstrap_form/components/labels.rb b/lib/bootstrap_form/components/labels.rb index d8f03655..fe6eec97 100644 --- a/lib/bootstrap_form/components/labels.rb +++ b/lib/bootstrap_form/components/labels.rb @@ -52,6 +52,7 @@ def prepare_label_options(id, name, options, custom_label_col, group_layout) options[:class] = label_classes(name, options, custom_label_col, group_layout) options.delete(:class) if options[:class].none? + options[:id] = aria_feedback_id(name:, id:) if error?(name) && label_errors end end end diff --git a/lib/bootstrap_form/components/validation.rb b/lib/bootstrap_form/components/validation.rb index affba190..48d1dfcb 100644 --- a/lib/bootstrap_form/components/validation.rb +++ b/lib/bootstrap_form/components/validation.rb @@ -61,14 +61,14 @@ def inline_error?(name) error?(name) && inline_errors end - def generate_error(name) + def generate_error(name, id) return unless inline_error?(name) help_text = get_error_messages(name) help_klass = "invalid-feedback" help_tag = :div - content_tag(help_tag, help_text, class: help_klass) + content_tag(help_tag, help_text, class: help_klass, id: aria_feedback_id(id:, name:)) end def get_error_messages(name) @@ -84,6 +84,10 @@ def get_error_messages(name) safe_join(object.errors[name], ", ") end # rubocop:enable Metrics/AbcSize + + def aria_feedback_id(name:, id: nil) + id.present? ? "#{id}_feedback" : field_id(name, :feedback) + end end end end diff --git a/lib/bootstrap_form/form_group.rb b/lib/bootstrap_form/form_group.rb index c244f4b2..aeda4a76 100644 --- a/lib/bootstrap_form/form_group.rb +++ b/lib/bootstrap_form/form_group.rb @@ -23,7 +23,7 @@ def form_group_content_tag(name, field_name, without_field_name, options, html_o html_class = control_specific_class(field_name) html_class = "#{html_class} col-auto g-3" if @layout == :horizontal && options[:skip_inline].blank? tag.div(class: html_class) do - input_with_error(name) do + input_with_error(name, options[:id]) do send(without_field_name, name, options, html_options) end end diff --git a/lib/bootstrap_form/form_group_builder.rb b/lib/bootstrap_form/form_group_builder.rb index 36e7155a..8e4989c6 100644 --- a/lib/bootstrap_form/form_group_builder.rb +++ b/lib/bootstrap_form/form_group_builder.rb @@ -98,7 +98,10 @@ def form_group_css_options(method, html_options, options) # Add control_class; allow it to be overridden by :control_class option control_classes = css_options.delete(:control_class) { control_class } css_options[:class] = safe_join([control_classes, css_options[:class]].compact, " ") - css_options[:class] << " is-invalid" if error?(method) + if error?(method) + css_options[:class] << " is-invalid" + css_options[:aria] = { labelledby: aria_feedback_id(id: options[:id], name: method) } + end css_options[:placeholder] = form_group_placeholder(options, method) if options[:label_as_placeholder] css_options end diff --git a/lib/bootstrap_form/helpers/bootstrap.rb b/lib/bootstrap_form/helpers/bootstrap.rb index 2048c448..1cf39ce4 100644 --- a/lib/bootstrap_form/helpers/bootstrap.rb +++ b/lib/bootstrap_form/helpers/bootstrap.rb @@ -34,7 +34,10 @@ def errors_on(name, options={}) hide_attribute_name = options[:hide_attribute_name] || false custom_class = options[:custom_class] || false - tag.div class: custom_class || "invalid-feedback" do + tag.div( + class: custom_class || "invalid-feedback", + id: aria_feedback_id(id: options[:id], name:) + ) do errors = if hide_attribute_name object.errors[name] else @@ -66,20 +69,21 @@ def custom_control(*args, &) end def prepend_and_append_input(name, options, &) + id = options[:id] options = options.extract!(:prepend, :append, :input_group_class).compact input = capture(&) || ActiveSupport::SafeBuffer.new input = attach_input(options, :prepend) + input + attach_input(options, :append) - input << generate_error(name) + input << generate_error(name, id) options.present? && input = tag.div(input, class: ["input-group", options[:input_group_class]].compact) input end - def input_with_error(name, &) + def input_with_error(name, id, &) input = capture(&) - input << generate_error(name) + input << generate_error(name, id) end def input_group_content(content) diff --git a/lib/bootstrap_form/inputs/base.rb b/lib/bootstrap_form/inputs/base.rb index 94833bd9..759c19be 100644 --- a/lib/bootstrap_form/inputs/base.rb +++ b/lib/bootstrap_form/inputs/base.rb @@ -24,6 +24,9 @@ def bootstrap_field(field_name) def bootstrap_select_group(field_name) define_method(:"#{field_name}_with_bootstrap") do |name, options={}, html_options={}| + # Specifying the id for a select doesn't work. The Rails helpers need to generate + # what they generate, and that includes the ids for each select option. + options.delete(:id) html_options = html_options.reverse_merge(control_class: "form-select") form_group_builder(name, options, html_options) do form_group_content_tag(name, field_name, "#{field_name}_without_bootstrap", options, html_options) diff --git a/lib/bootstrap_form/inputs/check_box.rb b/lib/bootstrap_form/inputs/check_box.rb index fca776d0..840b84ed 100644 --- a/lib/bootstrap_form/inputs/check_box.rb +++ b/lib/bootstrap_form/inputs/check_box.rb @@ -13,7 +13,7 @@ def check_box_with_bootstrap(name, options={}, checked_value="1", unchecked_valu content = tag.div(class: check_box_wrapper_class(options), **options[:wrapper].to_h.except(:class)) do html = check_box_without_bootstrap(name, check_box_options(name, options), checked_value, unchecked_value) html << check_box_label(name, options, checked_value, &block) unless options[:skip_label] - html << generate_error(name) if options[:error_message] + html << generate_error(name, options[:id]) if options[:error_message] html end wrapper(content, options) @@ -41,6 +41,7 @@ def check_box_options(name, options) :inline, :label, :label_class, :label_col, :layout, :skip_label, :switch, :wrapper, :wrapper_class) check_box_options[:class] = check_box_classes(name, options) + check_box_options[:aria] = { labelledby: aria_feedback_id(id: options[:id], name:) } if error?(name) check_box_options.merge!(required_field_options(options, name)) end diff --git a/lib/bootstrap_form/inputs/collection_check_boxes.rb b/lib/bootstrap_form/inputs/collection_check_boxes.rb index 6b18edb7..4c028427 100644 --- a/lib/bootstrap_form/inputs/collection_check_boxes.rb +++ b/lib/bootstrap_form/inputs/collection_check_boxes.rb @@ -9,6 +9,7 @@ module CollectionCheckBoxes included do def collection_check_boxes_with_bootstrap(*args) + args[4]&.delete(:id) html = inputs_collection(*args) do |name, value, options| options[:multiple] = true check_box(name, options, value, nil) diff --git a/lib/bootstrap_form/inputs/collection_radio_buttons.rb b/lib/bootstrap_form/inputs/collection_radio_buttons.rb index be07db35..d6d6d5c5 100644 --- a/lib/bootstrap_form/inputs/collection_radio_buttons.rb +++ b/lib/bootstrap_form/inputs/collection_radio_buttons.rb @@ -9,6 +9,7 @@ module CollectionRadioButtons included do def collection_radio_buttons_with_bootstrap(*args) + args[4]&.delete(:id) inputs_collection(*args) do |name, value, options| radio_button(name, value, options) end diff --git a/lib/bootstrap_form/inputs/inputs_collection.rb b/lib/bootstrap_form/inputs/inputs_collection.rb index 2b5dc808..9d742255 100644 --- a/lib/bootstrap_form/inputs/inputs_collection.rb +++ b/lib/bootstrap_form/inputs/inputs_collection.rb @@ -89,7 +89,7 @@ def field_group(name, options, &) :add_control_col_class, :append, :control_col, :floating, :help, :icon, :id, :input_group_class, :label, :label_col, :layout, :prepend ), - aria: { labelledby: options[:id] || default_id(name) }, + aria: { labelledby: group_label_div_id(id: options[:id], name:) }, role: :group ) do group_label_div = generate_group_label_div(name, options) @@ -100,15 +100,14 @@ def field_group(name, options, &) def generate_group_label_div(name, options) group_label_div_class = options.dig(:label, :class) || "form-label" - id = options[:id] || default_id(name) tag.div( **{ class: group_label_div_class }.compact, - id: + id: group_label_div_id(id: options[:id], name:) ) { label_text(name, options.dig(:label, :text)) } end - def default_id(name) = raw("#{object_name}_#{name}") # rubocop:disable Rails/OutputSafety + def group_label_div_id(id:, name:) = id || field_id(name) end end end diff --git a/lib/bootstrap_form/inputs/radio_button.rb b/lib/bootstrap_form/inputs/radio_button.rb index d78e0e11..ad7ddbe5 100644 --- a/lib/bootstrap_form/inputs/radio_button.rb +++ b/lib/bootstrap_form/inputs/radio_button.rb @@ -14,7 +14,7 @@ def radio_button_with_bootstrap(name, value, *args) tag.div(**wrapper_attributes) do html = radio_button_without_bootstrap(name, value, radio_button_options(name, options)) html << radio_button_label(name, value, options) unless options[:skip_label] - html << generate_error(name) if options[:error_message] + html << generate_error(name, options[:id]) if options[:error_message] html end end @@ -28,6 +28,7 @@ def radio_button_options(name, options) radio_button_options = options.except(:class, :label, :label_class, :error_message, :help, :inline, :hide_label, :skip_label, :wrapper, :wrapper_class) radio_button_options[:class] = radio_button_classes(name, options) + radio_button_options[:aria] = { labelledby: aria_feedback_id(id: options[:id], name:) } if error?(name) radio_button_options.merge!(required_field_options(options, name)) end diff --git a/lib/bootstrap_form/inputs/time_zone_select.rb b/lib/bootstrap_form/inputs/time_zone_select.rb index 555c2902..e174ce9b 100644 --- a/lib/bootstrap_form/inputs/time_zone_select.rb +++ b/lib/bootstrap_form/inputs/time_zone_select.rb @@ -10,7 +10,7 @@ module TimeZoneSelect def time_zone_select_with_bootstrap(method, priority_zones=nil, options={}, html_options={}) html_options = html_options.reverse_merge(control_class: "form-select") form_group_builder(method, options, html_options) do - input_with_error(method) do + input_with_error(method, options[:id]) do time_zone_select_without_bootstrap(method, priority_zones, options, html_options) end end diff --git a/test/bootstrap_checkbox_test.rb b/test/bootstrap_checkbox_test.rb index 121ba276..95692b82 100644 --- a/test/bootstrap_checkbox_test.rb +++ b/test/bootstrap_checkbox_test.rb @@ -195,11 +195,11 @@ class BootstrapCheckboxTest < ActionView::TestCase HTML @@ -209,6 +209,26 @@ class BootstrapCheckboxTest < ActionView::TestCase assert_equivalent_html expected, actual end + test "check_box renders error when asked with specified id:" do + @user.errors.add(:terms, "You must accept the terms.") + expected = <<~HTML + + HTML + actual = bootstrap_form_for(@user) do |f| + f.check_box(:terms, label: "I agree to the terms", error_message: true, id: "custom-id") + end + assert_equivalent_html expected, actual + end + test "check box with custom wrapper class" do expected = <<~HTMLBar
'.html_safe - unless @user.errors[:email].empty? - html << tag.div(@user.errors[:email].join(", "), class: "invalid-feedback", - style: "display: block;") - end - html - end - - expected = <<~HTML -Bar
-