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,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module StimulusPlumbers
|
|
4
|
+
module Form
|
|
5
|
+
module Fields
|
|
6
|
+
module Inputs
|
|
7
|
+
module Password
|
|
8
|
+
# Reveal toggle: input group wrapper, toggle button, and its icon pair.
|
|
9
|
+
module Revealable
|
|
10
|
+
STIMULUS_CONTROLLER = "input-revealable"
|
|
11
|
+
STIMULUS_ACTION = "click->#{STIMULUS_CONTROLLER}#toggle".freeze
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def render_revealable_password(error, floating: nil, &block)
|
|
16
|
+
render_input_group(
|
|
17
|
+
error: error,
|
|
18
|
+
floating: floating,
|
|
19
|
+
trailing: method(:reveal_button),
|
|
20
|
+
**merge_html_options(
|
|
21
|
+
theme.resolve(:form_field_input_reveal, error: error),
|
|
22
|
+
data: reveal_data
|
|
23
|
+
)
|
|
24
|
+
) { @template.capture(&block) }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def reveal_button
|
|
28
|
+
build_reveal_button do
|
|
29
|
+
@template.safe_join(
|
|
30
|
+
[password_icon("reveal", "revealIcon"), password_icon("conceal", "concealIcon", hidden: true)]
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def reveal_data
|
|
36
|
+
{
|
|
37
|
+
controller: STIMULUS_CONTROLLER,
|
|
38
|
+
input_revealable_reveal_label_value: I18n.t("stimulus_plumbers.form.password.show", default: "Show password"),
|
|
39
|
+
input_revealable_conceal_label_value: I18n.t("stimulus_plumbers.form.password.hide", default: "Hide password")
|
|
40
|
+
}
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def password_icon(name, target, hidden: false)
|
|
44
|
+
Components::Icon.new(@template).render(
|
|
45
|
+
name,
|
|
46
|
+
size: :sm,
|
|
47
|
+
aria: { hidden: "true" },
|
|
48
|
+
data: { input_revealable_target: target },
|
|
49
|
+
hidden: hidden,
|
|
50
|
+
**theme.resolve(:button_icon)
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def build_reveal_button(&block)
|
|
55
|
+
@template.content_tag(
|
|
56
|
+
:button,
|
|
57
|
+
**merge_html_options(
|
|
58
|
+
theme.resolve(:form_field_input_button_reveal),
|
|
59
|
+
{
|
|
60
|
+
type: "button",
|
|
61
|
+
aria: { label: I18n.t("stimulus_plumbers.form.password.show", default: "Show password") },
|
|
62
|
+
data: { input_revealable_target: "toggle", action: STIMULUS_ACTION }
|
|
63
|
+
}
|
|
64
|
+
)
|
|
65
|
+
) { @template.capture(&block) }
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module StimulusPlumbers
|
|
4
|
+
module Form
|
|
5
|
+
module Fields
|
|
6
|
+
module Inputs
|
|
7
|
+
module Password
|
|
8
|
+
module Strength
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def apply_strength_wiring(html_options, input_id)
|
|
12
|
+
described = [html_options.dig(:aria, :describedby), Components::PasswordStrength.rules_id_for(input_id)]
|
|
13
|
+
.compact.join(" ")
|
|
14
|
+
merge_html_options(
|
|
15
|
+
html_options,
|
|
16
|
+
aria: { describedby: described },
|
|
17
|
+
data: {
|
|
18
|
+
password_strength_target: "input", action: "input->password-strength#score"
|
|
19
|
+
}
|
|
20
|
+
)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def wrap_with_strength(input, input_id)
|
|
24
|
+
Components::PasswordStrength.new(@template).render(input: input, input_id: input_id, config: @password_requirements)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "password/revealable"
|
|
4
|
+
require_relative "password/strength"
|
|
5
|
+
|
|
3
6
|
module StimulusPlumbers
|
|
4
7
|
module Form
|
|
5
8
|
module Fields
|
|
6
9
|
module Inputs
|
|
7
10
|
module Password
|
|
8
|
-
|
|
9
|
-
|
|
11
|
+
include Revealable
|
|
12
|
+
include Strength
|
|
13
|
+
|
|
10
14
|
DEFAULT_AUTOCOMPLETE = "current-password"
|
|
11
15
|
|
|
12
16
|
def password_field(attribute, floating: nil, revealable: false, **options)
|
|
@@ -24,78 +28,43 @@ module StimulusPlumbers
|
|
|
24
28
|
end
|
|
25
29
|
end
|
|
26
30
|
|
|
27
|
-
|
|
31
|
+
# Requirements collected from a `field` block, held for the renderer.
|
|
32
|
+
attr_reader :password_requirements
|
|
28
33
|
|
|
29
|
-
|
|
30
|
-
html_options = merge_html_options(
|
|
31
|
-
theme.resolve(:form_field_input, floating: floating, error: error),
|
|
32
|
-
opts,
|
|
33
|
-
html_opts,
|
|
34
|
-
kwargs,
|
|
35
|
-
{ autocomplete: kwargs.delete(:autocomplete) || DEFAULT_AUTOCOMPLETE }
|
|
36
|
-
)
|
|
37
|
-
if revealable
|
|
38
|
-
render_revealable_password(error, floating: floating) do
|
|
39
|
-
revealable_html_options = merge_html_options(html_options, { data: { input_revealable_target: "input" } })
|
|
40
|
-
@template.password_field(@object_name, attribute, objectify_options(revealable_html_options))
|
|
41
|
-
end
|
|
42
|
-
else
|
|
43
|
-
@template.password_field(@object_name, attribute, objectify_options(html_options))
|
|
44
|
-
end
|
|
45
|
-
end
|
|
34
|
+
private
|
|
46
35
|
|
|
47
|
-
def
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
36
|
+
def render_password_input(attribute, html_opts, opts, error, floating: nil, revealable: false, **kwargs, &block)
|
|
37
|
+
# A shared `requirements:` object (spec Section C) takes precedence over a field block.
|
|
38
|
+
@password_requirements = kwargs.delete(:requirements) || build_password_requirements(&block)
|
|
39
|
+
input_id = html_opts[:id]
|
|
40
|
+
html_options = password_html_options(html_opts, opts, error, floating, kwargs)
|
|
41
|
+
html_options = apply_strength_wiring(html_options, input_id) if @password_requirements.active?
|
|
42
|
+
input = if revealable
|
|
43
|
+
render_revealable_password(error, floating: floating) do
|
|
44
|
+
revealable_html_options = merge_html_options(html_options, { data: { input_revealable_target: "input" } })
|
|
45
|
+
@template.password_field(@object_name, attribute, objectify_options(revealable_html_options))
|
|
46
|
+
end
|
|
47
|
+
else
|
|
48
|
+
@template.password_field(@object_name, attribute, objectify_options(html_options))
|
|
49
|
+
end
|
|
50
|
+
return input unless @password_requirements.active?
|
|
58
51
|
|
|
59
|
-
|
|
60
|
-
build_reveal_button do
|
|
61
|
-
@template.safe_join(
|
|
62
|
-
[password_icon("reveal", "revealIcon"), password_icon("conceal", "concealIcon", hidden: true)]
|
|
63
|
-
)
|
|
64
|
-
end
|
|
52
|
+
wrap_with_strength(input, input_id)
|
|
65
53
|
end
|
|
66
54
|
|
|
67
|
-
def
|
|
68
|
-
|
|
69
|
-
controller: STIMULUS_CONTROLLER,
|
|
70
|
-
input_revealable_reveal_label_value: I18n.t("stimulus_plumbers.form.password.show", default: "Show password"),
|
|
71
|
-
input_revealable_conceal_label_value: I18n.t("stimulus_plumbers.form.password.hide", default: "Hide password")
|
|
72
|
-
}
|
|
55
|
+
def build_password_requirements(&block)
|
|
56
|
+
StimulusPlumbers::Password::Requirements.build(&block)
|
|
73
57
|
end
|
|
74
58
|
|
|
75
|
-
def
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
**theme.resolve(:button_icon)
|
|
59
|
+
def password_html_options(html_opts, opts, error, floating, kwargs)
|
|
60
|
+
merge_html_options(
|
|
61
|
+
theme.resolve(:form_field_input, floating: floating, error: error),
|
|
62
|
+
opts,
|
|
63
|
+
html_opts,
|
|
64
|
+
kwargs,
|
|
65
|
+
{ autocomplete: kwargs.delete(:autocomplete) || DEFAULT_AUTOCOMPLETE }
|
|
83
66
|
)
|
|
84
67
|
end
|
|
85
|
-
|
|
86
|
-
def build_reveal_button(&block)
|
|
87
|
-
@template.content_tag(
|
|
88
|
-
:button,
|
|
89
|
-
**merge_html_options(
|
|
90
|
-
theme.resolve(:form_field_input_button_reveal),
|
|
91
|
-
{
|
|
92
|
-
type: "button",
|
|
93
|
-
aria: { label: I18n.t("stimulus_plumbers.form.password.show", default: "Show password") },
|
|
94
|
-
data: { input_revealable_target: "toggle", action: STIMULUS_ACTION }
|
|
95
|
-
}
|
|
96
|
-
)
|
|
97
|
-
) { @template.capture(&block) }
|
|
98
|
-
end
|
|
99
68
|
end
|
|
100
69
|
end
|
|
101
70
|
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module StimulusPlumbers
|
|
4
|
+
module Form
|
|
5
|
+
module Fields
|
|
6
|
+
module Inputs
|
|
7
|
+
module Progress
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
# A progressbar submits nothing and is never invalid — it reads its value from the model
|
|
11
|
+
# attribute, and `floating:` is dropped so it can't leak onto the element as an attribute.
|
|
12
|
+
def render_progress(attribute, html_opts, opts, _error, segments: nil, format: nil, readout: :inside, **kwargs)
|
|
13
|
+
html_options = merge_html_options(
|
|
14
|
+
theme.resolve(:form_field_input_progress), opts, html_opts, kwargs.except(:floating)
|
|
15
|
+
)
|
|
16
|
+
value = object.public_send(attribute)
|
|
17
|
+
component = Components::ProgressBar.new(@template)
|
|
18
|
+
if segments
|
|
19
|
+
raise ArgumentError, "format: is not supported with segments:" unless format.nil?
|
|
20
|
+
|
|
21
|
+
component.render_segmented(value: value, segments: segments, **html_options)
|
|
22
|
+
else
|
|
23
|
+
component.render(value: value, format: format, readout: readout, **html_options)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module StimulusPlumbers
|
|
4
|
+
module Form
|
|
5
|
+
module Fields
|
|
6
|
+
module Inputs
|
|
7
|
+
# A range is a track and a thumb, not a text box, so it takes none of the text input's
|
|
8
|
+
# chrome. With `format:` it grows a readout driven by the progress controller.
|
|
9
|
+
module Range
|
|
10
|
+
include Components::Progress::Shared
|
|
11
|
+
|
|
12
|
+
def range_field(attribute, **options)
|
|
13
|
+
super(attribute, merge_html_options(theme.resolve(:form_field_input_range), options.except(:floating)))
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
# `floating:` is dropped — a label can't sit inside a slider.
|
|
19
|
+
def render_range_input(attribute, html_opts, opts, _error, format: nil, min: 0, max: 100, **kwargs)
|
|
20
|
+
validate_format!(format)
|
|
21
|
+
current = clamp(numeric(object.public_send(attribute)), min, max)
|
|
22
|
+
input = range_input(attribute, current, min, max, format, html_opts, opts, kwargs.except(:floating))
|
|
23
|
+
return input if format.nil?
|
|
24
|
+
|
|
25
|
+
range_group(input, current, min, max, format)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# No readout to contain, so the input hosts the controller itself.
|
|
29
|
+
def range_input(attribute, current, min, max, format, html_opts, opts, kwargs)
|
|
30
|
+
wired = format.nil? ? range_stimulus_data(current, min, max, format) : { data: { "progress-target": "input" } }
|
|
31
|
+
html_options = merge_html_options(
|
|
32
|
+
theme.resolve(:form_field_input_range),
|
|
33
|
+
opts,
|
|
34
|
+
html_opts,
|
|
35
|
+
kwargs,
|
|
36
|
+
wired,
|
|
37
|
+
# The input carries the track gradient, so the fill percentage lands here, not on
|
|
38
|
+
# the wrapper. Server-rendered so the fill is right before the controller connects.
|
|
39
|
+
{ min: min, max: max, style: "--sp-progress-percent: #{integral(percent(current, min, max).round(2))}" }
|
|
40
|
+
)
|
|
41
|
+
@template.range_field(@object_name, attribute, objectify_options(html_options))
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# A Stimulus target must be a descendant of its controller element, so a readout
|
|
45
|
+
# forces a wrapper local to the input row.
|
|
46
|
+
def range_group(input, current, min, max, format)
|
|
47
|
+
html_options = merge_html_options(
|
|
48
|
+
theme.resolve(:form_field_input_range_group),
|
|
49
|
+
range_stimulus_data(current, min, max, format)
|
|
50
|
+
)
|
|
51
|
+
body = @template.safe_join([input, range_value(format, current, min, max)])
|
|
52
|
+
@template.content_tag(:div, body, **html_options)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# aria-hidden: the native input already announces its own value.
|
|
56
|
+
def range_value(format, current, min, max)
|
|
57
|
+
@template.content_tag(
|
|
58
|
+
:span,
|
|
59
|
+
value_text(format, current, min, max),
|
|
60
|
+
**merge_html_options(
|
|
61
|
+
theme.resolve(:form_field_input_range_value),
|
|
62
|
+
{ data: { "progress-target": "value" }, aria: { hidden: true } }
|
|
63
|
+
)
|
|
64
|
+
)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def range_stimulus_data(current, min, max, format)
|
|
68
|
+
progress_stimulus_data(
|
|
69
|
+
value: current,
|
|
70
|
+
min: min,
|
|
71
|
+
max: max,
|
|
72
|
+
variant: "range",
|
|
73
|
+
action: "input->progress#refresh",
|
|
74
|
+
**(format.nil? ? {} : { "progress-format-value": format })
|
|
75
|
+
)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -5,6 +5,7 @@ module StimulusPlumbers
|
|
|
5
5
|
module Fields
|
|
6
6
|
module Inputs
|
|
7
7
|
module Text
|
|
8
|
+
# `range` is not here — see inputs/range.rb; it shares none of the text input chrome.
|
|
8
9
|
TEXT_FIELD_METHODS = {
|
|
9
10
|
text: :text_field,
|
|
10
11
|
email: :email_field,
|
|
@@ -14,7 +15,6 @@ module StimulusPlumbers
|
|
|
14
15
|
color: :color_field,
|
|
15
16
|
month: :month_field,
|
|
16
17
|
week: :week_field,
|
|
17
|
-
range: :range_field,
|
|
18
18
|
datetime_local: :datetime_local_field
|
|
19
19
|
}.freeze
|
|
20
20
|
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../field"
|
|
4
|
+
|
|
3
5
|
module StimulusPlumbers
|
|
4
6
|
module Form
|
|
5
7
|
module Fields
|
|
@@ -18,6 +20,7 @@ module StimulusPlumbers
|
|
|
18
20
|
text_area: :render_text_area_input,
|
|
19
21
|
file: :render_file_input,
|
|
20
22
|
password: :render_password_input,
|
|
23
|
+
progress: :render_progress,
|
|
21
24
|
code: :render_code_input,
|
|
22
25
|
credit_card: :render_credit_card_input,
|
|
23
26
|
date: :render_combobox_date,
|
|
@@ -26,6 +29,44 @@ module StimulusPlumbers
|
|
|
26
29
|
search: :render_combobox_typeahead
|
|
27
30
|
}.freeze
|
|
28
31
|
|
|
32
|
+
# Require every field type to declare its caption association.
|
|
33
|
+
LABEL_MODE = {
|
|
34
|
+
text: :native,
|
|
35
|
+
email: :native,
|
|
36
|
+
number: :native,
|
|
37
|
+
url: :native,
|
|
38
|
+
tel: :native,
|
|
39
|
+
color: :native,
|
|
40
|
+
month: :native,
|
|
41
|
+
week: :native,
|
|
42
|
+
range: :native,
|
|
43
|
+
datetime_local: :native,
|
|
44
|
+
text_area: :native,
|
|
45
|
+
file: :native,
|
|
46
|
+
password: :native,
|
|
47
|
+
progress: :aria,
|
|
48
|
+
code: :native,
|
|
49
|
+
credit_card: :native,
|
|
50
|
+
date: :native,
|
|
51
|
+
time: :native,
|
|
52
|
+
select: :native,
|
|
53
|
+
search: :native
|
|
54
|
+
}.freeze
|
|
55
|
+
|
|
56
|
+
missing = FIELD.keys - LABEL_MODE.keys
|
|
57
|
+
raise ArgumentError, "field types missing a label_mode: #{missing.join(", ")}" if missing.any?
|
|
58
|
+
|
|
59
|
+
extra = LABEL_MODE.keys - FIELD.keys
|
|
60
|
+
raise ArgumentError, "label_mode declared for unknown field types: #{extra.join(", ")}" if extra.any?
|
|
61
|
+
|
|
62
|
+
LABEL_MODE.each_value { |mode| Field.validate_label_mode!(mode) }
|
|
63
|
+
|
|
64
|
+
class << self
|
|
65
|
+
def label_mode(as)
|
|
66
|
+
LABEL_MODE.fetch(as)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
29
70
|
COLLECTION = {
|
|
30
71
|
collection_select: :render_collection_combobox_dropdown,
|
|
31
72
|
grouped_collection_select: :render_grouped_collection_combobox_dropdown
|
|
@@ -3,12 +3,22 @@
|
|
|
3
3
|
module StimulusPlumbers
|
|
4
4
|
module Helpers
|
|
5
5
|
module ProgressHelper
|
|
6
|
-
def sp_progress_bar(value:, min: 0, max: 100, indeterminate: false, **kwargs)
|
|
7
|
-
Components::ProgressBar.new(self).render(
|
|
6
|
+
def sp_progress_bar(value:, min: 0, max: 100, indeterminate: false, format: nil, readout: :inside, **kwargs)
|
|
7
|
+
Components::ProgressBar.new(self).render(
|
|
8
|
+
value: value, min: min, max: max, indeterminate: indeterminate, format: format, readout: readout, **kwargs
|
|
9
|
+
)
|
|
8
10
|
end
|
|
9
11
|
|
|
10
|
-
def
|
|
11
|
-
Components::
|
|
12
|
+
def sp_progress_segmented(value:, segments:, min: 0, max: 100, mode: :discrete, indeterminate: false, ramp: nil, **kwargs)
|
|
13
|
+
Components::ProgressBar.new(self).render_segmented(
|
|
14
|
+
value: value, segments: segments, min: min, max: max, mode: mode, indeterminate: indeterminate, ramp: ramp, **kwargs
|
|
15
|
+
)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def sp_progress_ring(value:, min: 0, max: 100, indeterminate: false, size: nil, **kwargs)
|
|
19
|
+
Components::ProgressRing.new(self).render(
|
|
20
|
+
value: value, min: min, max: max, indeterminate: indeterminate, size: size, **kwargs
|
|
21
|
+
)
|
|
12
22
|
end
|
|
13
23
|
|
|
14
24
|
def sp_progress_meter(value:, min: 0, max: 100, low: nil, high: nil, optimum: nil, **kwargs)
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module StimulusPlumbers
|
|
4
|
+
module Password
|
|
5
|
+
# Declarative password rule set: DSL + evaluation + serialization. Consumed by
|
|
6
|
+
# the form renderer, PasswordStrengthValidator, and the JS controller (via to_stimulus).
|
|
7
|
+
class Requirements
|
|
8
|
+
LEVEL_KEYS = %i[weak fine strong].freeze
|
|
9
|
+
BUILTIN_KEYS = %i[length uppercase lowercase digit symbol].freeze
|
|
10
|
+
DEFAULT_THRESHOLDS = { low: 34, high: 100, optimum: 100 }.freeze
|
|
11
|
+
|
|
12
|
+
BUILTIN_PATTERNS = {
|
|
13
|
+
uppercase: "[A-Z]",
|
|
14
|
+
lowercase: "[a-z]",
|
|
15
|
+
digit: "\\d",
|
|
16
|
+
symbol: "[^A-Za-z0-9]"
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
def build
|
|
21
|
+
new.tap { |req| yield req if block_given? }
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def initialize
|
|
26
|
+
@enforced_options = nil
|
|
27
|
+
@custom = {}
|
|
28
|
+
@overrides = {}
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def enforce(**options)
|
|
32
|
+
@enforced_options = options
|
|
33
|
+
nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def rule(key, label = nil, pattern: nil, min: nil, max: nil, negate: false)
|
|
37
|
+
key = key.to_sym
|
|
38
|
+
if pattern
|
|
39
|
+
if negate
|
|
40
|
+
min = 0
|
|
41
|
+
max = 0
|
|
42
|
+
end
|
|
43
|
+
@custom[key] = { pattern: source_of(pattern), min: min.nil? ? 1 : min, max: max, label: label }
|
|
44
|
+
else
|
|
45
|
+
raise ArgumentError, unknown_rule_message(key) unless BUILTIN_KEYS.include?(key)
|
|
46
|
+
|
|
47
|
+
@overrides[key] = label
|
|
48
|
+
end
|
|
49
|
+
nil
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def enforced?
|
|
53
|
+
!@enforced_options.nil?
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Any rule present — built-ins (only when enforced?) or a custom rule. Drives whether
|
|
57
|
+
# the form renders the strength UI; enforced? alone misses custom-only rule sets.
|
|
58
|
+
def active?
|
|
59
|
+
descriptors.any?
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def rules
|
|
63
|
+
descriptors.to_h { |descriptor| [descriptor[:key], descriptor[:label]] }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def thresholds
|
|
67
|
+
DEFAULT_THRESHOLDS.merge((@enforced_options || {}).slice(*DEFAULT_THRESHOLDS.keys))
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def evaluate(password)
|
|
71
|
+
password = password.to_s
|
|
72
|
+
results = descriptors.to_h { |descriptor| [descriptor[:key], satisfies?(descriptor, password)] }
|
|
73
|
+
satisfied = results.values.count(true)
|
|
74
|
+
{ rules: results, value: score(satisfied, results.size), level: level_for(satisfied, results.size) }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def valid?(password)
|
|
78
|
+
result = evaluate(password)
|
|
79
|
+
result[:rules].any? && result[:rules].values.all?
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def to_stimulus
|
|
83
|
+
{ rules: descriptors.map { |descriptor| serialize(descriptor) }, options: stimulus_options, labels: level_labels }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
def descriptors
|
|
89
|
+
list = enforced? ? BUILTIN_KEYS.filter_map { |key| builtin_descriptor(key) } : []
|
|
90
|
+
@custom.each do |key, spec|
|
|
91
|
+
list << { key: key, pattern: spec[:pattern], min: spec[:min], max: spec[:max], label: spec[:label] }
|
|
92
|
+
end
|
|
93
|
+
apply_overrides(list)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def builtin_descriptor(key)
|
|
97
|
+
options = @enforced_options
|
|
98
|
+
return length_descriptor(options) if key == :length && (options.key?(:min_length) || options.key?(:max_length))
|
|
99
|
+
|
|
100
|
+
bounds = parse_count(options[key])
|
|
101
|
+
return unless bounds
|
|
102
|
+
|
|
103
|
+
label = builtin_label(key, bounds[:min])
|
|
104
|
+
{ key: key, pattern: BUILTIN_PATTERNS[key], min: bounds[:min], max: bounds[:max], label: label }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def length_descriptor(options)
|
|
108
|
+
unless options.key?(:min_length) && options.key?(:max_length)
|
|
109
|
+
raise ArgumentError, "length rule requires both min_length and max_length"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
label = builtin_label(:length, options[:min_length])
|
|
113
|
+
{ key: :length, min: options[:min_length], max: options[:max_length], label: label }
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def parse_count(value)
|
|
117
|
+
case value
|
|
118
|
+
when nil, false then nil
|
|
119
|
+
when true then { min: 1, max: nil }
|
|
120
|
+
when Integer then { min: value, max: nil }
|
|
121
|
+
when Range then { min: value.begin, max: value.end }
|
|
122
|
+
else raise ArgumentError, "invalid occurrence count #{value.inspect}"
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def satisfies?(descriptor, password)
|
|
127
|
+
n = descriptor[:pattern] ? password.scan(Regexp.new(descriptor[:pattern])).size : password.length
|
|
128
|
+
min = descriptor[:min] || 0
|
|
129
|
+
max = descriptor[:max] || Float::INFINITY
|
|
130
|
+
n.between?(min, max)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def score(satisfied, total)
|
|
134
|
+
total.zero? ? 0 : (satisfied.to_f / total * 100).round
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def level_for(satisfied, total)
|
|
138
|
+
return "strong" if total.positive? && satisfied == total
|
|
139
|
+
|
|
140
|
+
score(satisfied, total) < thresholds[:low] ? "weak" : "fine"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def serialize(descriptor)
|
|
144
|
+
data = { key: descriptor[:key].to_s, label: descriptor[:label], min: descriptor[:min] }
|
|
145
|
+
data[:max] = descriptor[:max] unless descriptor[:max].nil?
|
|
146
|
+
data[:pattern] = descriptor[:pattern] if descriptor[:pattern]
|
|
147
|
+
data
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def stimulus_options
|
|
151
|
+
{ "low" => thresholds[:low] }
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def level_labels
|
|
155
|
+
LEVEL_KEYS.index_with { |key| I18n.t("stimulus_plumbers.password.levels.#{key}") }.transform_keys(&:to_s)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def apply_overrides(list)
|
|
159
|
+
return list if @overrides.empty?
|
|
160
|
+
|
|
161
|
+
list.map do |descriptor|
|
|
162
|
+
next descriptor unless @overrides.key?(descriptor[:key])
|
|
163
|
+
|
|
164
|
+
descriptor.merge(label: @overrides[descriptor[:key]])
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def builtin_label(key, count)
|
|
169
|
+
I18n.t("stimulus_plumbers.password.rules.#{key}", count: count)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def source_of(pattern)
|
|
173
|
+
pattern.is_a?(Regexp) ? pattern.source : pattern.to_s
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def unknown_rule_message(key)
|
|
177
|
+
known = BUILTIN_KEYS.map(&:inspect).join(", ")
|
|
178
|
+
"unknown rule key: #{key.inspect} (known keys: #{known}), or pass pattern: for a custom rule"
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "password/requirements"
|
|
4
|
+
|
|
5
|
+
# Top-level so `validates :attr, password_strength: {...}` resolves via Rails' symbol lookup.
|
|
6
|
+
class PasswordStrengthValidator < ActiveModel::EachValidator
|
|
7
|
+
def validate_each(record, attribute, value)
|
|
8
|
+
requirements = options[:with] || build_requirements
|
|
9
|
+
result = requirements.evaluate(value.to_s)
|
|
10
|
+
return if result[:rules].any? && result[:rules].values.all?
|
|
11
|
+
|
|
12
|
+
unmet_messages(requirements, result).each { |message| record.errors.add(attribute, message) }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def build_requirements
|
|
18
|
+
StimulusPlumbers::Password::Requirements.build do |req|
|
|
19
|
+
req.enforce(**options.except(:with, :message, :on, :if, :unless, :allow_nil, :allow_blank))
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def unmet_messages(requirements, result)
|
|
24
|
+
return [options[:message]] if options[:message]
|
|
25
|
+
|
|
26
|
+
labels = requirements.rules
|
|
27
|
+
result[:rules].reject { |_key, ok| ok }.keys.map { |key| labels[key] }
|
|
28
|
+
end
|
|
29
|
+
end
|