stimulus_plumbers 0.4.13 → 0.4.15
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 +19 -0
- data/README.md +3 -1
- data/app/assets/javascripts/stimulus-plumbers/controllers.manifest.json +52 -0
- data/app/assets/javascripts/stimulus-plumbers/index.es.js +295 -181
- 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 +36 -4
- data/docs/component/plumber.md +40 -1
- data/docs/component/progress.md +29 -9
- 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 +31 -0
- data/lib/stimulus_plumbers/components/progress_bar.rb +44 -20
- data/lib/stimulus_plumbers/components/progress_meter.rb +16 -14
- data/lib/stimulus_plumbers/components/progress_ring.rb +7 -22
- data/lib/stimulus_plumbers/components/timeline/event/slots.rb +0 -1
- data/lib/stimulus_plumbers/form/builder.rb +15 -4
- 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/helpers/progress_helper.rb +10 -2
- 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/icons/external.rb +1 -1
- data/lib/stimulus_plumbers/themes/schema/icon.rb +15 -4
- data/lib/stimulus_plumbers/themes/schema.rb +12 -4
- data/lib/stimulus_plumbers/version.rb +1 -1
- data/lib/stimulus_plumbers.rb +7 -1
- data/vendor/component/manifest.json +9 -0
- data/vendor/controller/docs/password-strength.md +38 -0
- data/vendor/controller/docs/progress.md +33 -16
- data/vendor/controller/manifest.json +52 -0
- metadata +10 -2
|
@@ -3,40 +3,25 @@
|
|
|
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)
|
|
13
15
|
icon_options = merge_html_options(
|
|
14
|
-
theme.resolve(:progress_ring),
|
|
16
|
+
theme.resolve(:progress_ring, size: size),
|
|
15
17
|
kwargs,
|
|
16
|
-
|
|
18
|
+
progress_stimulus_data(
|
|
19
|
+
value: value, min: min, max: max, variant: "ring", "progress-indeterminate-value": indeterminate
|
|
20
|
+
),
|
|
17
21
|
{ role: "progressbar", aria: progress_aria(value: value, min: min, max: max, indeterminate: indeterminate) }
|
|
18
22
|
)
|
|
19
23
|
Components::Icon.new(template).render("progress-ring", **icon_options)
|
|
20
24
|
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
25
|
end
|
|
41
26
|
end
|
|
42
27
|
end
|
|
@@ -31,6 +31,7 @@ module StimulusPlumbers
|
|
|
31
31
|
class Builder < ActionView::Helpers::FormBuilder
|
|
32
32
|
include Plumber::Options::Html
|
|
33
33
|
include Plumber::Options::Aria
|
|
34
|
+
include Plumber::Dispatcher::CallableInspector
|
|
34
35
|
include Fields::Inputs::Checkbox
|
|
35
36
|
include Fields::Inputs::Code
|
|
36
37
|
include Fields::Inputs::CreditCard
|
|
@@ -47,10 +48,10 @@ module StimulusPlumbers
|
|
|
47
48
|
include Fields::Inputs::Text
|
|
48
49
|
include Fields::Inputs::TextArea
|
|
49
50
|
|
|
50
|
-
def field(attribute, as:, **options)
|
|
51
|
+
def field(attribute, as:, **options, &block)
|
|
51
52
|
field_opts = options.slice(*Field::OPTIONS)
|
|
52
53
|
input_opts = options.except(*Field::OPTIONS)
|
|
53
|
-
render_field(as, attribute, field_opts, input_opts)
|
|
54
|
+
render_field(as, attribute, field_opts, input_opts, &block)
|
|
54
55
|
end
|
|
55
56
|
|
|
56
57
|
def collection_field(attribute, as:, collection:, value_method:, text_method:, **options)
|
|
@@ -71,9 +72,11 @@ module StimulusPlumbers
|
|
|
71
72
|
StimulusPlumbers.config.theme.current
|
|
72
73
|
end
|
|
73
74
|
|
|
74
|
-
def render_field(as, attribute, field_opts, input_opts)
|
|
75
|
+
def render_field(as, attribute, field_opts, input_opts, &block)
|
|
75
76
|
raise ArgumentError, "unknown field type: #{as.inspect}" unless Fields::Renderer::FIELD.key?(as)
|
|
76
77
|
|
|
78
|
+
validate_field_block!(as, block)
|
|
79
|
+
|
|
77
80
|
field = Field.new(@template, **field_opts)
|
|
78
81
|
field.render(object, attribute, input_id: field_id(attribute)) do |html_opts, opts, error|
|
|
79
82
|
Plumber::Dispatcher.build(
|
|
@@ -83,11 +86,19 @@ module StimulusPlumbers
|
|
|
83
86
|
opts,
|
|
84
87
|
error,
|
|
85
88
|
floating: field.floating,
|
|
86
|
-
**input_opts
|
|
89
|
+
**input_opts,
|
|
90
|
+
&block
|
|
87
91
|
).call(self)
|
|
88
92
|
end
|
|
89
93
|
end
|
|
90
94
|
|
|
95
|
+
def validate_field_block!(as, block)
|
|
96
|
+
return unless block
|
|
97
|
+
return if accepts_block?(method(Fields::Renderer::FIELD.fetch(as)))
|
|
98
|
+
|
|
99
|
+
raise ArgumentError, "field type #{as.inspect} does not accept a block"
|
|
100
|
+
end
|
|
101
|
+
|
|
91
102
|
def render_collection_field(as, attribute, field_opts, collection, value_method, text_method, input_opts)
|
|
92
103
|
raise ArgumentError, "unknown collection field type: #{as.inspect}" unless Fields::Renderer::COLLECTION.key?(as)
|
|
93
104
|
|
|
@@ -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
|
|
@@ -7,8 +7,16 @@ module StimulusPlumbers
|
|
|
7
7
|
Components::ProgressBar.new(self).render(value: value, min: min, max: max, indeterminate: indeterminate, **kwargs)
|
|
8
8
|
end
|
|
9
9
|
|
|
10
|
-
def
|
|
11
|
-
Components::
|
|
10
|
+
def sp_progress_segmented(value:, segments:, min: 0, max: 100, mode: :discrete, indeterminate: false, ramp: nil, **kwargs)
|
|
11
|
+
Components::ProgressBar.new(self).render_segmented(
|
|
12
|
+
value: value, segments: segments, min: min, max: max, mode: mode, indeterminate: indeterminate, ramp: ramp, **kwargs
|
|
13
|
+
)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def sp_progress_ring(value:, min: 0, max: 100, indeterminate: false, size: nil, **kwargs)
|
|
17
|
+
Components::ProgressRing.new(self).render(
|
|
18
|
+
value: value, min: min, max: max, indeterminate: indeterminate, size: size, **kwargs
|
|
19
|
+
)
|
|
12
20
|
end
|
|
13
21
|
|
|
14
22
|
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
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module StimulusPlumbers
|
|
4
|
+
module Plumber
|
|
5
|
+
# Sibling of Slots for block DSLs whose payload is configuration, not content.
|
|
6
|
+
# Slots captures blocks through the view; Config only stores values, and holds
|
|
7
|
+
# the template to pass on to renderers.
|
|
8
|
+
#
|
|
9
|
+
# The store is private. Slots publishes `resolve` because its payload is uniform
|
|
10
|
+
# content a renderer reads generically; a Config's settings are typed, so each
|
|
11
|
+
# subclass exposes its own named readers instead.
|
|
12
|
+
class Config
|
|
13
|
+
attr_reader :template
|
|
14
|
+
|
|
15
|
+
def initialize(template = nil)
|
|
16
|
+
@template = template
|
|
17
|
+
@config = {}
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
# Returns nil so a subclass's DSL method reads as a command, not a value.
|
|
23
|
+
def configure(name, value)
|
|
24
|
+
@config[name] = value
|
|
25
|
+
nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def config(name)
|
|
29
|
+
@config[name]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def configured?(name)
|
|
33
|
+
@config.key?(name)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -13,6 +13,10 @@ module StimulusPlumbers
|
|
|
13
13
|
def accepts_kwargs?(callable)
|
|
14
14
|
callable.parameters.any? { |type, _| %i[key keyreq keyrest].include?(type) }
|
|
15
15
|
end
|
|
16
|
+
|
|
17
|
+
def accepts_block?(callable)
|
|
18
|
+
callable.parameters.any? { |type, _| type == :block }
|
|
19
|
+
end
|
|
16
20
|
end
|
|
17
21
|
end
|
|
18
22
|
end
|
|
@@ -37,8 +37,10 @@ module StimulusPlumbers
|
|
|
37
37
|
@template ? @template.capture(&block) : block.call
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
+
# Returns nil so a subclass's `with_*` method reads as a command, not a value.
|
|
40
41
|
def set_slot(name, value, options = {})
|
|
41
42
|
@slots[name] = { value: value, options: options }
|
|
43
|
+
nil
|
|
42
44
|
end
|
|
43
45
|
|
|
44
46
|
class << self
|
|
@@ -55,7 +57,6 @@ module StimulusPlumbers
|
|
|
55
57
|
def define_flat_slot(name)
|
|
56
58
|
define_method(:"with_#{name}") do |value = nil, **opts, &block|
|
|
57
59
|
set_slot(name, block || value, opts)
|
|
58
|
-
nil
|
|
59
60
|
end
|
|
60
61
|
end
|
|
61
62
|
|
|
@@ -64,7 +65,6 @@ module StimulusPlumbers
|
|
|
64
65
|
sub = by.new(@template)
|
|
65
66
|
block&.call(sub)
|
|
66
67
|
set_slot(name, sub)
|
|
67
|
-
nil
|
|
68
68
|
end
|
|
69
69
|
end
|
|
70
70
|
|
|
@@ -31,7 +31,7 @@ module StimulusPlumbers
|
|
|
31
31
|
|
|
32
32
|
result = svg_defaults(key)
|
|
33
33
|
root.attributes.each_attribute do |attr|
|
|
34
|
-
result[SVG_RENAME.fetch(attr.name, attr.name.to_sym)] = attr.value
|
|
34
|
+
result[SVG_RENAME.fetch(attr.name, attr.name.tr("-", "_").to_sym)] = attr.value
|
|
35
35
|
end
|
|
36
36
|
|
|
37
37
|
elements = parse_elements(root)
|