stimulus_plumbers 0.4.14 → 0.4.16

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 (50) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +15 -0
  3. data/README.md +3 -1
  4. data/app/assets/javascripts/stimulus-plumbers/controllers.manifest.json +63 -1
  5. data/app/assets/javascripts/stimulus-plumbers/index.es.js +345 -198
  6. data/app/assets/javascripts/stimulus-plumbers/index.es.js.map +1 -1
  7. data/app/assets/javascripts/stimulus-plumbers/index.umd.js +1 -1
  8. data/app/assets/javascripts/stimulus-plumbers/index.umd.js.map +1 -1
  9. data/config/locales/en.yml +12 -0
  10. data/docs/component/combobox.md +3 -2
  11. data/docs/component/form.md +85 -18
  12. data/docs/component/indicator.md +1 -1
  13. data/docs/component/plumber.md +40 -1
  14. data/docs/component/progress.md +51 -16
  15. data/docs/guide.md +63 -4
  16. data/lib/stimulus_plumbers/components/card/slots.rb +0 -2
  17. data/lib/stimulus_plumbers/components/combobox/{builder.rb → config.rb} +7 -11
  18. data/lib/stimulus_plumbers/components/combobox.rb +12 -12
  19. data/lib/stimulus_plumbers/components/password_strength.rb +103 -0
  20. data/lib/stimulus_plumbers/components/progress/shared.rb +74 -0
  21. data/lib/stimulus_plumbers/components/progress_bar.rb +127 -25
  22. data/lib/stimulus_plumbers/components/progress_meter.rb +16 -14
  23. data/lib/stimulus_plumbers/components/progress_ring.rb +9 -23
  24. data/lib/stimulus_plumbers/components/timeline/event/slots.rb +0 -1
  25. data/lib/stimulus_plumbers/form/builder.rb +35 -8
  26. data/lib/stimulus_plumbers/form/field.rb +34 -6
  27. data/lib/stimulus_plumbers/form/fields/inputs/password/revealable.rb +72 -0
  28. data/lib/stimulus_plumbers/form/fields/inputs/password/strength.rb +31 -0
  29. data/lib/stimulus_plumbers/form/fields/inputs/password.rb +34 -65
  30. data/lib/stimulus_plumbers/form/fields/inputs/progress.rb +30 -0
  31. data/lib/stimulus_plumbers/form/fields/inputs/range.rb +81 -0
  32. data/lib/stimulus_plumbers/form/fields/inputs/text.rb +1 -1
  33. data/lib/stimulus_plumbers/form/fields/renderer.rb +41 -0
  34. data/lib/stimulus_plumbers/helpers/progress_helper.rb +14 -4
  35. data/lib/stimulus_plumbers/password/requirements.rb +182 -0
  36. data/lib/stimulus_plumbers/password_strength_validator.rb +29 -0
  37. data/lib/stimulus_plumbers/plumber/config.rb +37 -0
  38. data/lib/stimulus_plumbers/plumber/dispatcher/callable_inspector.rb +4 -0
  39. data/lib/stimulus_plumbers/plumber/slots.rb +2 -2
  40. data/lib/stimulus_plumbers/themes/schema/progress/ranges.rb +13 -0
  41. data/lib/stimulus_plumbers/themes/schema.rb +23 -4
  42. data/lib/stimulus_plumbers/version.rb +1 -1
  43. data/lib/stimulus_plumbers.rb +7 -1
  44. data/vendor/ARIA.md +10 -0
  45. data/vendor/component/manifest.json +16 -2
  46. data/vendor/controller/docs/password-strength.md +38 -0
  47. data/vendor/controller/docs/progress.md +100 -23
  48. data/vendor/controller/guide.md +61 -7
  49. data/vendor/controller/manifest.json +63 -1
  50. metadata +13 -2
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StimulusPlumbers
4
+ module Components
5
+ class PasswordStrength < Plumber::Base
6
+ LEVEL_KEYS = %i[weak fine strong].freeze
7
+
8
+ class << self
9
+ def meter_id_for(input_id)
10
+ "#{input_id}_meter"
11
+ end
12
+
13
+ def rules_id_for(input_id)
14
+ "#{input_id}_rules"
15
+ end
16
+ end
17
+
18
+ def render(input:, input_id:, config:)
19
+ @config = config
20
+ template.content_tag(:div, **wrapper_options(config, input_id)) do
21
+ template.safe_join([input, render_meter(input_id), render_level, render_rules_heading, render_rules(input_id)])
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ def wrapper_options(config, input_id)
28
+ stimulus = config.to_stimulus
29
+ merge_html_options(
30
+ theme.resolve(:password_strength_wrapper),
31
+ data: {
32
+ controller: "password-strength",
33
+ password_strength_rules_value: stimulus[:rules].to_json,
34
+ password_strength_options_value: stimulus[:options].to_json,
35
+ password_strength_labels_value: stimulus[:labels].to_json,
36
+ password_strength_progress_outlet: "##{self.class.meter_id_for(input_id)}"
37
+ }
38
+ )
39
+ end
40
+
41
+ def render_meter(input_id)
42
+ ProgressMeter.new(template).render(value: 0, id: self.class.meter_id_for(input_id), **@config.thresholds)
43
+ end
44
+
45
+ def render_level
46
+ template.content_tag(
47
+ :p,
48
+ level_labels[:weak],
49
+ **merge_html_options(
50
+ theme.resolve(:password_strength_level),
51
+ data: { password_strength_target: "level" },
52
+ aria: { live: "polite" }
53
+ )
54
+ )
55
+ end
56
+
57
+ def level_labels
58
+ LEVEL_KEYS.index_with { |key| I18n.t("stimulus_plumbers.password.levels.#{key}") }
59
+ end
60
+
61
+ def render_rules_heading
62
+ template.content_tag(
63
+ :p,
64
+ I18n.t("stimulus_plumbers.password.rules_heading"),
65
+ **theme.resolve(:password_strength_rules_heading)
66
+ )
67
+ end
68
+
69
+ def render_rules(input_id)
70
+ html_options = merge_html_options(
71
+ theme.resolve(:password_strength_rules),
72
+ id: self.class.rules_id_for(input_id)
73
+ )
74
+ template.content_tag(:ul, **html_options) do
75
+ template.safe_join(@config.rules.map { |key, label| render_rule(key, label) })
76
+ end
77
+ end
78
+
79
+ def render_rule(key, label)
80
+ template.content_tag(
81
+ :li,
82
+ **merge_html_options(
83
+ theme.resolve(:password_strength_rule),
84
+ data: {
85
+ password_strength_target: "rule", rule: key, satisfied: "false"
86
+ }
87
+ )
88
+ ) { template.safe_join([rule_icon("check", "checkIcon", hidden: true), rule_icon("close", "closeIcon"), label]) }
89
+ end
90
+
91
+ def rule_icon(name, target, hidden: false)
92
+ Icon.new(template).render(
93
+ name,
94
+ size: :sm,
95
+ aria: { hidden: "true" },
96
+ data: { password_strength_target: target },
97
+ hidden: hidden,
98
+ **theme.resolve(:password_strength_rule_icon)
99
+ )
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StimulusPlumbers
4
+ module Components
5
+ module Progress
6
+ # Shared stimulus/ARIA wiring and value formatting for the progress variants
7
+ # (bar, segmented, ring, meter) and for the range form field, which reuses the readout.
8
+ module Shared
9
+ FORMATS = %i[percent value value_max].freeze
10
+
11
+ private
12
+
13
+ def progress_stimulus_data(value:, min:, max:, variant:, **extra)
14
+ {
15
+ data: {
16
+ controller: "progress",
17
+ "progress-variant-value": variant,
18
+ "progress-current-value": value,
19
+ "progress-min-value": min,
20
+ "progress-max-value": max,
21
+ **extra
22
+ }
23
+ }
24
+ end
25
+
26
+ def progress_aria(value:, min:, max:, indeterminate: false, valuetext: nil)
27
+ aria = { valuemin: min, valuemax: max }
28
+ aria[:valuenow] = value unless indeterminate
29
+ aria[:valuetext] = valuetext if valuetext
30
+ aria
31
+ end
32
+
33
+ def validate_format!(format)
34
+ return if format.nil? || (format.respond_to?(:to_sym) && FORMATS.include?(format.to_sym))
35
+
36
+ raise ArgumentError, "unknown format: #{format.inspect} (expected one of #{FORMATS.join(", ")})"
37
+ end
38
+
39
+ # Keep in sync with the progress controller's formattedValue().
40
+ def value_text(format, current, min, max)
41
+ case format&.to_sym
42
+ when :percent then "#{percent(current, min, max).round}%"
43
+ when :value then integral(current).to_s
44
+ when :value_max then "#{integral(current)} / #{integral(max)}"
45
+ end
46
+ end
47
+
48
+ # Comparable#clamp raises when min > max; the JS returns max there, and percent() then yields 0%.
49
+ def clamp(value, min, max)
50
+ return max if max < min
51
+
52
+ value.clamp(min, max)
53
+ end
54
+
55
+ def percent(current, min, max)
56
+ range = max - min
57
+ range <= 0 ? 0 : (current - min).fdiv(range) * 100
58
+ end
59
+
60
+ # A `step: 0.1` range holds 45.5 — to_i would paint a 45 fill under a 45.5 thumb.
61
+ def numeric(value)
62
+ return value if value.is_a?(Numeric)
63
+
64
+ Float(value, exception: false) || 0
65
+ end
66
+
67
+ # 45.0 renders as "45" — JS has no Float/Integer distinction to mirror.
68
+ def integral(number)
69
+ (number % 1).zero? ? number.to_i : number
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -3,47 +3,149 @@
3
3
  module StimulusPlumbers
4
4
  module Components
5
5
  class ProgressBar < Plumber::Base
6
+ include Progress::Shared
7
+
6
8
  def render(...)
7
9
  render_bar(...)
8
10
  end
9
11
 
12
+ def render_segmented(
13
+ value:,
14
+ segments:,
15
+ min: 0,
16
+ max: 100,
17
+ mode: :discrete,
18
+ indeterminate: false,
19
+ ramp: nil,
20
+ format: nil,
21
+ **kwargs
22
+ )
23
+ validate_segments!(segments, format)
24
+ current = clamp(value, min, max)
25
+ html_options = merge_html_options(
26
+ theme.resolve(:progress_segment_group),
27
+ kwargs,
28
+ segmented_stimulus_data(current, min, max, mode, indeterminate),
29
+ { role: "progressbar", aria: progress_aria(value: current, min: min, max: max, indeterminate: indeterminate) }
30
+ )
31
+ slots = ramp_intents(ramp, segments).map { |intent| render_segment(intent: intent) }
32
+ template.content_tag(:div, template.safe_join(slots), **html_options)
33
+ end
34
+
10
35
  private
11
36
 
12
- def render_bar(value:, min: 0, max: 100, indeterminate: false, **kwargs)
13
- html_options = merge_html_options(
14
- theme.resolve(:progress_bar),
37
+ def validate_segments!(segments, format)
38
+ raise ArgumentError, "format: is not supported with segments:" unless format.nil?
39
+ raise ArgumentError, "segments must be a positive integer" unless segments.is_a?(Integer) && segments.positive?
40
+ end
41
+
42
+ def segmented_stimulus_data(current, min, max, mode, indeterminate)
43
+ progress_stimulus_data(
44
+ value: current,
45
+ min: min,
46
+ max: max,
47
+ variant: "segmented",
48
+ "progress-segment-mode-value": mode,
49
+ "progress-indeterminate-value": indeterminate
50
+ )
51
+ end
52
+
53
+ def render_bar(value:, min: 0, max: 100, indeterminate: false, format: nil, readout: :inside, **kwargs)
54
+ validate_format!(format)
55
+ validate_readout!(readout)
56
+ current = clamp(value, min, max)
57
+ text = value_text(format, current, min, max) unless indeterminate
58
+ html_options = bar_html_options(current, min, max, indeterminate, format, readout, text, kwargs)
59
+ template.content_tag(:div, bar_body(format, readout, text), **html_options)
60
+ end
61
+
62
+ def validate_readout!(readout)
63
+ return if Themes::Schema::Progress::Ranges::PLACEMENT.include?(readout&.to_sym)
64
+
65
+ raise ArgumentError, "readout must be one of #{Themes::Schema::Progress::Ranges::PLACEMENT.join(", ")}"
66
+ end
67
+
68
+ # The track clips its overflow, so an outside readout needs the root to be a wrapper.
69
+ def bar_body(format, readout, text)
70
+ return render_fill if format.nil?
71
+ return template.safe_join([render_fill, render_value(text, :inside)]) if readout.to_sym == :inside
72
+
73
+ template.safe_join([render_track, render_value(text, :outside)])
74
+ end
75
+
76
+ def render_track
77
+ template.content_tag(:div, render_fill, **merge_html_options(theme.resolve(:progress_bar, labelled: false)))
78
+ end
79
+
80
+ def bar_html_options(current, min, max, indeterminate, format, readout, text, kwargs)
81
+ # `percent` omits aria-valuetext — AT derives the percentage from aria-valuenow itself.
82
+ valuetext = text unless format&.to_sym == :percent
83
+ aria = progress_aria(value: current, min: min, max: max, indeterminate: indeterminate, valuetext: valuetext)
84
+ merge_html_options(
85
+ bar_root_theme(format, readout),
15
86
  kwargs,
16
- stimulus_data(value: value, min: min, max: max, indeterminate: indeterminate),
17
- { role: "progressbar", aria: progress_aria(value: value, min: min, max: max, indeterminate: indeterminate) }
87
+ bar_stimulus_data(current, min, max, indeterminate, format),
88
+ { role: "progressbar", aria: aria }
18
89
  )
19
- template.content_tag(:div, render_fill, **html_options)
20
90
  end
21
91
 
22
- def render_fill
92
+ def bar_root_theme(format, readout)
93
+ return theme.resolve(:progress_bar_group) if format && readout.to_sym == :outside
94
+
95
+ theme.resolve(:progress_bar, labelled: !format.nil?)
96
+ end
97
+
98
+ def bar_stimulus_data(current, min, max, indeterminate, format)
99
+ progress_stimulus_data(
100
+ value: current,
101
+ min: min,
102
+ max: max,
103
+ variant: "bar",
104
+ "progress-indeterminate-value": indeterminate,
105
+ **(format.nil? ? {} : { "progress-format-value": format })
106
+ )
107
+ end
108
+
109
+ # aria-hidden: the value reaches AT via aria-valuenow/aria-valuetext, not this span.
110
+ def render_value(text, placement)
111
+ key = placement == :outside ? :progress_bar_value_outside : :progress_bar_value
23
112
  template.content_tag(
24
- :div,
25
- nil,
26
- **merge_html_options(theme.resolve(:progress_bar_fill), { data: { "progress-target": "fill" } })
113
+ :span,
114
+ text,
115
+ **merge_html_options(
116
+ theme.resolve(key),
117
+ { data: { "progress-target": "value" }, aria: { hidden: true } }
118
+ )
27
119
  )
28
120
  end
29
121
 
30
- def progress_aria(value:, min:, max:, indeterminate:)
31
- aria = { valuemin: min, valuemax: max }
32
- aria[:valuenow] = value unless indeterminate
33
- aria
122
+ # `data-intent` is a theme-independent styling hook the theme colors via attribute variants.
123
+ def render_fill(intent: nil, key: :progress_bar_fill)
124
+ data = { "progress-target": "fill" }
125
+ data[:intent] = intent if intent
126
+ template.content_tag(:div, nil, **merge_html_options(theme.resolve(key), { data: data }))
127
+ end
128
+
129
+ # Slots are aria-hidden — the container owns the progressbar ARIA.
130
+ def render_segment(intent: nil)
131
+ template.content_tag(
132
+ :div,
133
+ render_fill(intent: intent, key: :progress_segment_fill),
134
+ **merge_html_options(theme.resolve(:progress_segment), { aria: { hidden: true } })
135
+ )
34
136
  end
35
137
 
36
- def stimulus_data(value:, min:, max:, indeterminate:)
37
- {
38
- data: {
39
- controller: "progress",
40
- "progress-variant-value": "bar",
41
- "progress-current-value": value,
42
- "progress-min-value": min,
43
- "progress-max-value": max,
44
- "progress-indeterminate-value": indeterminate
45
- }
46
- }
138
+ # `:strength` buckets each slot into danger/warning/success thirds by position; nil = uncolored.
139
+ def ramp_intents(ramp, count)
140
+ return Array.new(count) unless ramp == :strength
141
+
142
+ Array.new(count) do |i|
143
+ case (i + 1).fdiv(count)
144
+ when ..(1.0 / 3) then :danger
145
+ when ..(2.0 / 3) then :warning
146
+ else :success
147
+ end
148
+ end
47
149
  end
48
150
  end
49
151
  end
@@ -3,6 +3,8 @@
3
3
  module StimulusPlumbers
4
4
  module Components
5
5
  class ProgressMeter < Plumber::Base
6
+ include Progress::Shared
7
+
6
8
  def render(...)
7
9
  render_meter(...)
8
10
  end
@@ -14,25 +16,25 @@ module StimulusPlumbers
14
16
  html_options = merge_html_options(
15
17
  theme.resolve(:progress_meter),
16
18
  kwargs,
17
- stimulus_data(value: value, min: min, max: max, low: low, high: high, optimum: optimum),
19
+ progress_stimulus_data(
20
+ value: value,
21
+ min: min,
22
+ max: max,
23
+ variant: "meter",
24
+ "progress-target": "meter",
25
+ **threshold_data(low: low, high: high, optimum: optimum)
26
+ ),
18
27
  attrs
19
28
  )
20
29
  template.content_tag(:meter, nil, **html_options)
21
30
  end
22
31
 
23
- def stimulus_data(value:, min:, max:, low:, high:, optimum:)
24
- data = {
25
- controller: "progress",
26
- "progress-target": "meter",
27
- "progress-variant-value": "meter",
28
- "progress-current-value": value,
29
- "progress-min-value": min,
30
- "progress-max-value": max
31
- }
32
- data["progress-low-value"] = low if low
33
- data["progress-high-value"] = high if high
34
- data["progress-optimum-value"] = optimum if optimum
35
- { data: data }
32
+ def threshold_data(low:, high:, optimum:)
33
+ data = {}
34
+ data[:"progress-low-value"] = low if low
35
+ data[:"progress-high-value"] = high if high
36
+ data[:"progress-optimum-value"] = optimum if optimum
37
+ data
36
38
  end
37
39
  end
38
40
  end
@@ -3,40 +3,26 @@
3
3
  module StimulusPlumbers
4
4
  module Components
5
5
  class ProgressRing < Plumber::Base
6
+ include Progress::Shared
7
+
6
8
  def render(...)
7
9
  render_ring(...)
8
10
  end
9
11
 
10
12
  private
11
13
 
12
- def render_ring(value:, max: 100, min: 0, indeterminate: false, **kwargs)
14
+ def render_ring(value:, max: 100, min: 0, indeterminate: false, size: nil, **kwargs)
15
+ current = clamp(value, min, max)
13
16
  icon_options = merge_html_options(
14
- theme.resolve(:progress_ring),
17
+ theme.resolve(:progress_ring, size: size),
15
18
  kwargs,
16
- stimulus_data(value: value, min: min, max: max, indeterminate: indeterminate),
17
- { role: "progressbar", aria: progress_aria(value: value, min: min, max: max, indeterminate: indeterminate) }
19
+ progress_stimulus_data(
20
+ value: current, min: min, max: max, variant: "ring", "progress-indeterminate-value": indeterminate
21
+ ),
22
+ { role: "progressbar", aria: progress_aria(value: current, min: min, max: max, indeterminate: indeterminate) }
18
23
  )
19
24
  Components::Icon.new(template).render("progress-ring", **icon_options)
20
25
  end
21
-
22
- def progress_aria(value:, min:, max:, indeterminate:)
23
- aria = { valuemin: min, valuemax: max }
24
- aria[:valuenow] = value unless indeterminate
25
- aria
26
- end
27
-
28
- def stimulus_data(value:, min:, max:, indeterminate:)
29
- {
30
- data: {
31
- controller: "progress",
32
- "progress-variant-value": "ring",
33
- "progress-current-value": value,
34
- "progress-min-value": min,
35
- "progress-max-value": max,
36
- "progress-indeterminate-value": indeterminate
37
- }
38
- }
39
- end
40
26
  end
41
27
  end
42
28
  end
@@ -9,7 +9,6 @@ module StimulusPlumbers
9
9
 
10
10
  def with_indicator(icon: nil)
11
11
  set_slot(:indicator, icon, { type: icon ? :icon : :dot, icon: icon })
12
- nil
13
12
  end
14
13
  end
15
14
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "action_view/version"
4
+ require "action_view/helpers/tags/translator"
4
5
 
5
6
  require_relative "../plumber/options/aria"
6
7
  require_relative "../plumber/options/html"
@@ -17,6 +18,8 @@ require_relative "fields/inputs/datetime"
17
18
  require_relative "fields/inputs/radio"
18
19
  require_relative "fields/inputs/file"
19
20
  require_relative "fields/inputs/password"
21
+ require_relative "fields/inputs/progress"
22
+ require_relative "fields/inputs/range"
20
23
  require_relative "fields/inputs/search"
21
24
  require_relative "fields/inputs/select"
22
25
  require_relative "fields/inputs/select/grouped"
@@ -31,6 +34,7 @@ module StimulusPlumbers
31
34
  class Builder < ActionView::Helpers::FormBuilder
32
35
  include Plumber::Options::Html
33
36
  include Plumber::Options::Aria
37
+ include Plumber::Dispatcher::CallableInspector
34
38
  include Fields::Inputs::Checkbox
35
39
  include Fields::Inputs::Code
36
40
  include Fields::Inputs::CreditCard
@@ -38,6 +42,8 @@ module StimulusPlumbers
38
42
  include Fields::Inputs::Radio
39
43
  include Fields::Inputs::File
40
44
  include Fields::Inputs::Password
45
+ include Fields::Inputs::Progress
46
+ include Fields::Inputs::Range
41
47
  include Fields::Inputs::Search
42
48
  include Fields::Inputs::Select
43
49
  include Fields::Inputs::Select::Grouped
@@ -47,34 +53,47 @@ module StimulusPlumbers
47
53
  include Fields::Inputs::Text
48
54
  include Fields::Inputs::TextArea
49
55
 
50
- def field(attribute, as:, **options)
51
- field_opts = options.slice(*Field::OPTIONS)
56
+ def field(attribute, as:, **options, &block)
57
+ field_opts = field_options(attribute, options)
52
58
  input_opts = options.except(*Field::OPTIONS)
53
- render_field(as, attribute, field_opts, input_opts)
59
+ render_field(as, attribute, field_opts, input_opts, &block)
54
60
  end
55
61
 
56
62
  def collection_field(attribute, as:, collection:, value_method:, text_method:, **options)
57
- field_opts = options.slice(*Field::OPTIONS)
63
+ field_opts = field_options(attribute, options)
58
64
  input_opts = options.except(*Field::OPTIONS)
59
65
  render_collection_field(as, attribute, field_opts, collection, value_method, text_method, input_opts)
60
66
  end
61
67
 
62
68
  def choice(attribute, as:, collection: nil, value_method: nil, text_method: nil, **options)
63
- field_opts = options.slice(*Field::OPTIONS)
69
+ field_opts = field_options(attribute, options)
64
70
  input_opts = options.except(*Field::OPTIONS)
65
71
  render_choice_field(as, attribute, field_opts, collection, value_method, text_method, input_opts)
66
72
  end
67
73
 
68
74
  private
69
75
 
76
+ def field_options(attribute, options)
77
+ options.slice(*Field::OPTIONS).tap { |opts| opts[:label] ||= default_label(attribute) }
78
+ end
79
+
80
+ # Mirrors f.label: helpers.label.* → human_attribute_name → humanize.
81
+ def default_label(attribute)
82
+ ActionView::Helpers::Tags::Translator.new(
83
+ object, @object_name.to_s, attribute.to_s, scope: "helpers.label"
84
+ ).translate.presence || attribute.to_s.humanize
85
+ end
86
+
70
87
  def theme
71
88
  StimulusPlumbers.config.theme.current
72
89
  end
73
90
 
74
- def render_field(as, attribute, field_opts, input_opts)
91
+ def render_field(as, attribute, field_opts, input_opts, &block)
75
92
  raise ArgumentError, "unknown field type: #{as.inspect}" unless Fields::Renderer::FIELD.key?(as)
76
93
 
77
- field = Field.new(@template, **field_opts)
94
+ validate_field_block!(as, block)
95
+
96
+ field = Field.new(@template, label_mode: Fields::Renderer.label_mode(as), **field_opts)
78
97
  field.render(object, attribute, input_id: field_id(attribute)) do |html_opts, opts, error|
79
98
  Plumber::Dispatcher.build(
80
99
  Fields::Renderer::FIELD.fetch(as),
@@ -83,11 +102,19 @@ module StimulusPlumbers
83
102
  opts,
84
103
  error,
85
104
  floating: field.floating,
86
- **input_opts
105
+ **input_opts,
106
+ &block
87
107
  ).call(self)
88
108
  end
89
109
  end
90
110
 
111
+ def validate_field_block!(as, block)
112
+ return unless block
113
+ return if accepts_block?(method(Fields::Renderer::FIELD.fetch(as)))
114
+
115
+ raise ArgumentError, "field type #{as.inspect} does not accept a block"
116
+ end
117
+
91
118
  def render_collection_field(as, attribute, field_opts, collection, value_method, text_method, input_opts)
92
119
  raise ArgumentError, "unknown collection field type: #{as.inspect}" unless Fields::Renderer::COLLECTION.key?(as)
93
120
 
@@ -13,26 +13,40 @@ module StimulusPlumbers
13
13
  COLLECTION_TYPES = %i[radio check_box collection_select grouped_collection_select].freeze
14
14
  OPTIONS = (Base::OPTIONS + %i[hide_label]).freeze
15
15
 
16
- attr_reader :hide_label
16
+ # :aria uses aria-labelledby because non-labelable components cannot use `<label for>`.
17
+ LABEL_MODES = %i[native aria].freeze
18
+
19
+ attr_reader :hide_label, :label_mode
17
20
 
18
21
  class << self
19
22
  def label_id(input_id)
20
23
  [input_id, "label"].compact.join("_")
21
24
  end
25
+
26
+ def validate_label_mode!(mode)
27
+ return mode if LABEL_MODES.include?(mode)
28
+
29
+ raise ArgumentError, "unknown label_mode: #{mode.inspect} (expected one of #{LABEL_MODES.join(", ")})"
30
+ end
22
31
  end
23
32
 
24
- def initialize(template, hide_label: false, **kwargs)
33
+ def initialize(template, hide_label: false, label_mode: :native, **kwargs)
25
34
  super(template, **kwargs)
26
35
  @hide_label = hide_label
36
+ @label_mode = self.class.validate_label_mode!(label_mode)
27
37
  end
28
38
 
29
39
  def label_hidden?
30
40
  @hide_label
31
41
  end
32
42
 
43
+ def native_label?
44
+ @label_mode == :native
45
+ end
46
+
33
47
  def render(object, attribute, input_id:, &block)
34
48
  @label ||= attribute.to_s.humanize
35
- case @floating
49
+ case native_label? ? @floating : nil
36
50
  when *StimulusPlumbers::Themes::Schema::Form::Floating::Ranges::TYPE
37
51
  render_floating_field(object, attribute, input_id, &block)
38
52
  else
@@ -42,6 +56,18 @@ module StimulusPlumbers
42
56
 
43
57
  private
44
58
 
59
+ def build_aria(object, attribute, input_id)
60
+ return super if native_label?
61
+
62
+ { describedby: described_by(object, attribute, input_id), labelledby: self.class.label_id(input_id) }.compact
63
+ end
64
+
65
+ def build_html_options(input_id, aria)
66
+ return super if native_label?
67
+
68
+ { id: input_id, aria: aria }
69
+ end
70
+
45
71
  def render_default_field(object, attribute, input_id, &block)
46
72
  error_override = error?(object, attribute)
47
73
  aria = build_aria(object, attribute, input_id)
@@ -59,13 +85,15 @@ module StimulusPlumbers
59
85
  end
60
86
  end
61
87
 
88
+ # hide_label is visual only; :aria fields cannot be required.
62
89
  def field_label(input_id)
63
90
  Fields::Label.new(@template).render(
64
91
  text: @label,
65
- for_id: input_id,
92
+ for_id: (input_id if native_label?),
66
93
  id: self.class.label_id(input_id),
67
- required: @required,
68
- hidden: @hide_label
94
+ required: native_label? && @required,
95
+ hidden: @hide_label,
96
+ tag: native_label? ? :label : :span
69
97
  )
70
98
  end
71
99