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,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
- STIMULUS_CONTROLLER = "input-revealable"
9
- STIMULUS_ACTION = "click->#{STIMULUS_CONTROLLER}#toggle".freeze
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
- private
31
+ # Requirements collected from a `field` block, held for the renderer.
32
+ attr_reader :password_requirements
28
33
 
29
- def render_password_input(attribute, html_opts, opts, error, floating: nil, revealable: false, **kwargs)
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 render_revealable_password(error, floating: nil, &block)
48
- render_input_group(
49
- error: error,
50
- floating: floating,
51
- trailing: method(:reveal_button),
52
- **merge_html_options(
53
- theme.resolve(:form_field_input_reveal, error: error),
54
- data: reveal_data
55
- )
56
- ) { @template.capture(&block) }
57
- end
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
- def reveal_button
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 reveal_data
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 password_icon(name, target, hidden: false)
76
- Components::Icon.new(@template).render(
77
- name,
78
- size: :sm,
79
- aria: { hidden: "true" },
80
- data: { input_revealable_target: target },
81
- hidden: hidden,
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(value: value, min: min, max: max, indeterminate: indeterminate, **kwargs)
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 sp_progress_ring(value:, min: 0, max: 100, indeterminate: false, **kwargs)
11
- Components::ProgressRing.new(self).render(value: value, min: min, max: max, indeterminate: indeterminate, **kwargs)
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