Skip to content

Commit 54094b7

Browse files
authored
Merge pull request #779 from lcreid/763-collection-controls-not-aria-accessible
Make Collection Controls Aria Accessible
2 parents aae5040 + 3f0ce6e commit 54094b7

File tree

13 files changed

+2364
-733
lines changed

13 files changed

+2364
-733
lines changed

.yarnrc

Lines changed: 0 additions & 5 deletions
This file was deleted.

README.md

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,8 @@ This generates the following HTML:
120120
</form>
121121
```
122122

123+
Note: All examples in this README are generated with the configuration option `group_around_collections` set to `true`. See the [Configuration](#configuration) section.
124+
123125
### bootstrap_form_tag
124126

125127
If your form is not backed by a model, use the `bootstrap_form_tag`. Usage of this helper is the same as `bootstrap_form_for`, except no model object is passed in as the first argument. Here's an example:
@@ -233,6 +235,7 @@ The current configuration options are:
233235
| Option | Default value | Description |
234236
|---------------------------|------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
235237
| `default_form_attributes` | {} | `bootstrap_form` versions 3 and 4 added a role="form" attribute to all forms. The W3C validator will raise a **warning** on forms with a role="form" attribute. `bootstrap_form` version 5 drops this attribute by default. Set this option to `{ role: "form" }` to make forms non-compliant with W3C, but generate the `role="form"` attribute like `bootstrap_form` versions 3 and 4. |
238+
| `group_around_collections` | false | Historically, `bootstrap_form` generated a wrapper around `collection_checkboxes` and `collection_radio_buttons` using the same `form_group` as individual controls used. This markup caused accessibility problems. Setting `group_around_collections = true` will generate collections of checkboxes and radio buttons wrapper in a `<fieldset>` with the text as a `<legend>` (https://www.w3.org/WAI/tutorials/forms/grouping/). This _will_ make visible changes to pages that use the collection methods.<br/><br/>The default for this option will be changed to `true` in a future version. |
236239

237240
Example:
238241

@@ -781,8 +784,8 @@ This generates:
781784
This generates:
782785

783786
```html
784-
<div class="mb-3">
785-
<label class="form-label" for="user_skill_level">Skill level</label>
787+
<div aria-labelledby="user_skill_level" class="mb-3" role="group">
788+
<div class="form-label" id="user_skill_level">Skill level</div>
786789
<div class="form-check">
787790
<input class="form-check-input" id="user_skill_level_1" name="user[skill_level]" type="radio" value="1">
788791
<label class="form-check-label" for="user_skill_level_1">Mind reading</label>
@@ -793,8 +796,8 @@ This generates:
793796
</div>
794797
</div>
795798
<input id="user_skills" name="user[skills][]" type="hidden" value="">
796-
<div class="mb-3">
797-
<label class="form-label" for="user_skills">Skills</label>
799+
<div aria-labelledby="user_skills" class="mb-3" role="group">
800+
<div class="form-label" id="user_skills">Skills</div>
798801
<div class="form-check">
799802
<input class="form-check-input" id="user_skills_1" name="user[skills][]" type="checkbox" value="1">
800803
<label class="form-check-label" for="user_skills_1">Mind reading</label>
@@ -829,8 +832,8 @@ To add `data-` attributes to a collection of radio buttons, map your models to a
829832
This generates:
830833

831834
```html
832-
<div class="mb-3">
833-
<label class="form-label" for="user_misc">Misc</label>
835+
<div aria-labelledby="user_misc" class="mb-3" role="group">
836+
<div class="form-label" id="user_misc">Misc</div>
834837
<div class="form-check">
835838
<input class="form-check-input" id="user_misc_1" name="user[misc]" type="radio" value="1">
836839
<label class="form-check-label" for="user_misc_1">Foo</label>
@@ -1417,7 +1420,7 @@ This generates:
14171420
</form>
14181421
```
14191422

1420-
A form-level `layout: :inline` can't be overridden because of the way Bootstrap 4 implements in-line layouts. One possible work-around is to leave the form-level layout as default, and specify the individual fields as `layout: :inline`, except for the fields(s) that should be other than in-line.
1423+
A form-level `layout: :inline` can't be overridden because of the way Bootstrap implements in-line layouts. One possible work-around is to leave the form-level layout as default, and specify the individual fields as `layout: :inline`, except for the fields(s) that should be other than in-line.
14211424

14221425
### Floating Labels
14231426

@@ -1493,8 +1496,8 @@ Generated HTML:
14931496
<input class="form-control is-invalid" id="user_email" name="user[email]" required="required" type="email" value="steve.example.com">
14941497
<div class="invalid-feedback">is invalid</div>
14951498
</div>
1496-
<div class="mb-3">
1497-
<label class="form-label" for="user_misc">Misc</label>
1499+
<div aria-labelledby="user_misc" class="mb-3" role="group">
1500+
<div class="form-label" id="user_misc">Misc</div>
14981501
<div class="form-check">
14991502
<input checked class="form-check-input is-invalid" id="user_misc_1" name="user[misc]" type="radio" value="1">
15001503
<label class="form-check-label" for="user_misc_1">Mind reading</label>
@@ -1506,8 +1509,8 @@ Generated HTML:
15061509
</div>
15071510
</div>
15081511
<input id="user_preferences" name="user[preferences][]" type="hidden" value="">
1509-
<div class="mb-3">
1510-
<label class="form-label" for="user_preferences">Preferences</label>
1512+
<div aria-labelledby="user_preferences" class="mb-3" role="group">
1513+
<div class="form-label" id="user_preferences">Preferences</div>
15111514
<div class="form-check">
15121515
<input checked class="form-check-input is-invalid" id="user_preferences_1" name="user[preferences][]" type="checkbox" value="1">
15131516
<label class="form-check-label" for="user_preferences_1">Good</label>
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
BootstrapForm.configure do |config|
2+
config.group_around_collections = true
3+
end

demo/test/system/bootstrap_test.rb

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,14 @@
44
require "capybara_screenshot_diff/minitest"
55

66
class BootstrapTest < ApplicationSystemTestCase
7-
setup { screenshot_section :bootstrap }
7+
setup do
8+
screenshot_section :bootstrap
9+
Rails.application.config.bootstrap_form.group_around_collections = true
10+
end
11+
12+
teardown do
13+
Rails.application.config.bootstrap_form.group_around_collections = false
14+
end
815

916
test "visiting the index" do
1017
screenshot_group :index

lib/bootstrap_form/components/labels.rb

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,8 @@ module Labels
1010
def generate_label(id, name, options, custom_label_col, group_layout)
1111
return if options.blank?
1212

13-
# id is the caller's options[:id] at the only place this method is called.
14-
# The options argument is a small subset of the options that might have
15-
# been passed to generate_label's caller, and definitely doesn't include
16-
# :id.
17-
options[:for] = id if acts_like_form_tag
18-
19-
options[:class] = label_classes(name, options, custom_label_col, group_layout)
20-
options.delete(:class) if options[:class].none?
21-
22-
label(name, label_text(name, options), options.except(:text))
13+
prepare_label_options(id, name, options, custom_label_col, group_layout)
14+
label(name, label_text(name, options[:text]), options.except(:text))
2315
end
2416

2517
def label_classes(name, options, custom_label_col, group_layout)
@@ -42,14 +34,25 @@ def label_layout_classes(custom_label_col, group_layout)
4234
end
4335
end
4436

45-
def label_text(name, options)
46-
label = options[:text] || object&.class&.try(:human_attribute_name, name)&.html_safe # rubocop:disable Rails/OutputSafety, Style/SafeNavigationChainLength
37+
def label_text(name, text)
38+
label = text || object&.class&.try(:human_attribute_name, name)&.html_safe # rubocop:disable Rails/OutputSafety, Style/SafeNavigationChainLength
4739
if label_errors && error?(name)
4840
(" ".html_safe + get_error_messages(name)).prepend(label)
4941
else
5042
label
5143
end
5244
end
45+
46+
def prepare_label_options(id, name, options, custom_label_col, group_layout)
47+
# id is the caller's options[:id] at the only place this method is called.
48+
# The options argument is a small subset of the options that might have
49+
# been passed to generate_label's caller, and definitely doesn't include
50+
# :id.
51+
options[:for] = id if acts_like_form_tag
52+
53+
options[:class] = label_classes(name, options, custom_label_col, group_layout)
54+
options.delete(:class) if options[:class].none?
55+
end
5356
end
5457
end
5558
end

lib/bootstrap_form/engine.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ class Engine < Rails::Engine
99

1010
config.bootstrap_form = BootstrapForm.config
1111
config.bootstrap_form.default_form_attributes ||= {}
12+
config.bootstrap_form.group_around_collections = Rails.env.development? if config.bootstrap_form.group_around_collections.nil?
1213

1314
initializer "bootstrap_form.configure" do |app|
1415
BootstrapForm.config = app.config.bootstrap_form

lib/bootstrap_form/form_group_builder.rb

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,16 @@ module FormGroupBuilder
77
private
88

99
def form_group_builder(method, options, html_options=nil, &)
10+
form_group_builder_wrapper(method, options, html_options) do |form_group_options, no_wrapper|
11+
if no_wrapper
12+
yield
13+
else
14+
form_group(method, form_group_options, &)
15+
end
16+
end
17+
end
18+
19+
def form_group_builder_wrapper(method, options, html_options=nil)
1020
no_wrapper = options[:wrapper] == false
1121

1222
options = form_group_builder_options(options, method)
@@ -18,11 +28,7 @@ def form_group_builder(method, options, html_options=nil, &)
1828
:hide_label, :skip_required, :label_as_placeholder, :wrapper_class, :wrapper
1929
)
2030

21-
if no_wrapper
22-
yield
23-
else
24-
form_group(method, form_group_options, &)
25-
end
31+
yield(form_group_options, no_wrapper)
2632
end
2733

2834
def form_group_builder_options(options, method)

lib/bootstrap_form/inputs/inputs_collection.rb

Lines changed: 64 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,24 +7,20 @@ module InputsCollection
77

88
private
99

10-
def inputs_collection(name, collection, value, text, options={})
10+
def inputs_collection(name, collection, value, text, options={}, &)
1111
options[:label] ||= { class: group_label_class(field_layout(options)) }
1212
options[:inline] ||= layout_inline?(options[:layout])
1313

14-
form_group_builder(name, options) do
15-
inputs = ActiveSupport::SafeBuffer.new
16-
17-
collection.each_with_index do |obj, i|
18-
input_value = value.respond_to?(:call) ? value.call(obj) : obj.send(value)
19-
input_options = form_group_collection_input_options(options, text, obj, i, input_value, collection)
20-
inputs << yield(name, input_value, input_options)
21-
end
14+
return group_inputs_collection(name, collection, value, text, options, &) if BootstrapForm.config.group_around_collections
2215

23-
inputs
16+
form_group_builder(name, options) do
17+
render_collection(name, collection, value, text, options, &)
2418
end
2519
end
2620

27-
def field_layout(options) = options[:layout] || (:inline if options[:inline] == true)
21+
def field_layout(options)
22+
(:inline if options[:inline] == true) || options[:layout]
23+
end
2824

2925
def group_label_class(field_layout)
3026
if layout_horizontal?(field_layout)
@@ -56,6 +52,63 @@ def form_group_collection_input_checked?(checked, obj, input_value)
5652
checked == input_value || Array(checked).try(:include?, input_value) ||
5753
checked == obj || Array(checked).try(:include?, obj)
5854
end
55+
56+
def group_inputs_collection(name, collection, value, text, options={}, &)
57+
group_builder(name, options) do
58+
render_collection(name, collection, value, text, options, &)
59+
end
60+
end
61+
62+
def render_collection(name, collection, value, text, options={}, &)
63+
inputs = ActiveSupport::SafeBuffer.new
64+
65+
collection.each_with_index do |obj, i|
66+
input_value = value.respond_to?(:call) ? value.call(obj) : obj.send(value)
67+
input_options = form_group_collection_input_options(options, text, obj, i, input_value, collection)
68+
inputs << yield(name, input_value, input_options)
69+
end
70+
71+
inputs
72+
end
73+
74+
def group_builder(method, options, html_options=nil, &)
75+
form_group_builder_wrapper(method, options, html_options) do |form_group_options, no_wrapper|
76+
if no_wrapper
77+
yield
78+
else
79+
field_group(method, form_group_options, &)
80+
end
81+
end
82+
end
83+
84+
def field_group(name, options, &)
85+
options[:class] = form_group_classes(options)
86+
87+
tag.div(
88+
**options.except(
89+
:add_control_col_class, :append, :control_col, :floating, :help, :icon, :id,
90+
:input_group_class, :label, :label_col, :layout, :prepend
91+
),
92+
aria: { labelledby: options[:id] || default_id(name) },
93+
role: :group
94+
) do
95+
group_label_div = generate_group_label_div(name, options)
96+
prepare_label_options(options[:id], name, options[:label], options[:label_col], options[:layout])
97+
form_group_content(group_label_div, generate_help(name, options[:help]), options, &)
98+
end
99+
end
100+
101+
def generate_group_label_div(name, options)
102+
group_label_div_class = options.dig(:label, :class) || "form-label"
103+
id = options[:id] || default_id(name)
104+
105+
tag.div(
106+
**{ class: group_label_div_class }.compact,
107+
id:
108+
) { label_text(name, options.dig(:label, :text)) }
109+
end
110+
111+
def default_id(name) = raw("#{object_name}_#{name}") # rubocop:disable Rails/OutputSafety
59112
end
60113
end
61114
end

0 commit comments

Comments
 (0)