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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +19 -0
  3. data/README.md +3 -1
  4. data/app/assets/javascripts/stimulus-plumbers/controllers.manifest.json +52 -0
  5. data/app/assets/javascripts/stimulus-plumbers/index.es.js +295 -181
  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 +36 -4
  12. data/docs/component/plumber.md +40 -1
  13. data/docs/component/progress.md +29 -9
  14. data/lib/stimulus_plumbers/components/card/slots.rb +0 -2
  15. data/lib/stimulus_plumbers/components/combobox/{builder.rb → config.rb} +7 -11
  16. data/lib/stimulus_plumbers/components/combobox.rb +12 -12
  17. data/lib/stimulus_plumbers/components/password_strength.rb +103 -0
  18. data/lib/stimulus_plumbers/components/progress/shared.rb +31 -0
  19. data/lib/stimulus_plumbers/components/progress_bar.rb +44 -20
  20. data/lib/stimulus_plumbers/components/progress_meter.rb +16 -14
  21. data/lib/stimulus_plumbers/components/progress_ring.rb +7 -22
  22. data/lib/stimulus_plumbers/components/timeline/event/slots.rb +0 -1
  23. data/lib/stimulus_plumbers/form/builder.rb +15 -4
  24. data/lib/stimulus_plumbers/form/fields/inputs/password/revealable.rb +72 -0
  25. data/lib/stimulus_plumbers/form/fields/inputs/password/strength.rb +31 -0
  26. data/lib/stimulus_plumbers/form/fields/inputs/password.rb +34 -65
  27. data/lib/stimulus_plumbers/helpers/progress_helper.rb +10 -2
  28. data/lib/stimulus_plumbers/password/requirements.rb +182 -0
  29. data/lib/stimulus_plumbers/password_strength_validator.rb +29 -0
  30. data/lib/stimulus_plumbers/plumber/config.rb +37 -0
  31. data/lib/stimulus_plumbers/plumber/dispatcher/callable_inspector.rb +4 -0
  32. data/lib/stimulus_plumbers/plumber/slots.rb +2 -2
  33. data/lib/stimulus_plumbers/themes/icons/external.rb +1 -1
  34. data/lib/stimulus_plumbers/themes/schema/icon.rb +15 -4
  35. data/lib/stimulus_plumbers/themes/schema.rb +12 -4
  36. data/lib/stimulus_plumbers/version.rb +1 -1
  37. data/lib/stimulus_plumbers.rb +7 -1
  38. data/vendor/component/manifest.json +9 -0
  39. data/vendor/controller/docs/password-strength.md +38 -0
  40. data/vendor/controller/docs/progress.md +33 -16
  41. data/vendor/controller/manifest.json +52 -0
  42. 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
- stimulus_data(value: value, min: min, max: max, indeterminate: indeterminate),
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
@@ -9,7 +9,6 @@ module StimulusPlumbers
9
9
 
10
10
  def with_indicator(icon: nil)
11
11
  set_slot(:indicator, icon, { type: icon ? :icon : :dot, icon: icon })
12
- nil
13
12
  end
14
13
  end
15
14
  end
@@ -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
- 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
@@ -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 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)
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)