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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +15 -0
- data/README.md +3 -1
- data/app/assets/javascripts/stimulus-plumbers/controllers.manifest.json +63 -1
- data/app/assets/javascripts/stimulus-plumbers/index.es.js +345 -198
- data/app/assets/javascripts/stimulus-plumbers/index.es.js.map +1 -1
- data/app/assets/javascripts/stimulus-plumbers/index.umd.js +1 -1
- data/app/assets/javascripts/stimulus-plumbers/index.umd.js.map +1 -1
- data/config/locales/en.yml +12 -0
- data/docs/component/combobox.md +3 -2
- data/docs/component/form.md +85 -18
- data/docs/component/indicator.md +1 -1
- data/docs/component/plumber.md +40 -1
- data/docs/component/progress.md +51 -16
- data/docs/guide.md +63 -4
- data/lib/stimulus_plumbers/components/card/slots.rb +0 -2
- data/lib/stimulus_plumbers/components/combobox/{builder.rb → config.rb} +7 -11
- data/lib/stimulus_plumbers/components/combobox.rb +12 -12
- data/lib/stimulus_plumbers/components/password_strength.rb +103 -0
- data/lib/stimulus_plumbers/components/progress/shared.rb +74 -0
- data/lib/stimulus_plumbers/components/progress_bar.rb +127 -25
- data/lib/stimulus_plumbers/components/progress_meter.rb +16 -14
- data/lib/stimulus_plumbers/components/progress_ring.rb +9 -23
- data/lib/stimulus_plumbers/components/timeline/event/slots.rb +0 -1
- data/lib/stimulus_plumbers/form/builder.rb +35 -8
- data/lib/stimulus_plumbers/form/field.rb +34 -6
- data/lib/stimulus_plumbers/form/fields/inputs/password/revealable.rb +72 -0
- data/lib/stimulus_plumbers/form/fields/inputs/password/strength.rb +31 -0
- data/lib/stimulus_plumbers/form/fields/inputs/password.rb +34 -65
- data/lib/stimulus_plumbers/form/fields/inputs/progress.rb +30 -0
- data/lib/stimulus_plumbers/form/fields/inputs/range.rb +81 -0
- data/lib/stimulus_plumbers/form/fields/inputs/text.rb +1 -1
- data/lib/stimulus_plumbers/form/fields/renderer.rb +41 -0
- data/lib/stimulus_plumbers/helpers/progress_helper.rb +14 -4
- data/lib/stimulus_plumbers/password/requirements.rb +182 -0
- data/lib/stimulus_plumbers/password_strength_validator.rb +29 -0
- data/lib/stimulus_plumbers/plumber/config.rb +37 -0
- data/lib/stimulus_plumbers/plumber/dispatcher/callable_inspector.rb +4 -0
- data/lib/stimulus_plumbers/plumber/slots.rb +2 -2
- data/lib/stimulus_plumbers/themes/schema/progress/ranges.rb +13 -0
- data/lib/stimulus_plumbers/themes/schema.rb +23 -4
- data/lib/stimulus_plumbers/version.rb +1 -1
- data/lib/stimulus_plumbers.rb +7 -1
- data/vendor/ARIA.md +10 -0
- data/vendor/component/manifest.json +16 -2
- data/vendor/controller/docs/password-strength.md +38 -0
- data/vendor/controller/docs/progress.md +100 -23
- data/vendor/controller/guide.md +61 -7
- data/vendor/controller/manifest.json +63 -1
- 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
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
17
|
-
{ role: "progressbar", aria:
|
|
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
|
|
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
|
-
:
|
|
25
|
-
|
|
26
|
-
**merge_html_options(
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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
|
|
24
|
-
data = {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
17
|
-
|
|
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
|
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|