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
@@ -30,3 +30,15 @@ en:
30
30
  hide: "Hide password"
31
31
  search:
32
32
  clear: "Clear search"
33
+ password:
34
+ rules_heading: "It's better to have:"
35
+ rules:
36
+ length: "At least %{count} characters"
37
+ uppercase: "One uppercase letter"
38
+ lowercase: "One lowercase letter"
39
+ digit: "One number"
40
+ symbol: "One symbol"
41
+ levels:
42
+ weak: "Weak password"
43
+ fine: "Fine password"
44
+ strong: "Strong password"
@@ -8,7 +8,8 @@ See [docs/component/combobox.md](../../../stimulus-plumbers/docs/component/combo
8
8
 
9
9
  ### `sp_combobox`
10
10
 
11
- Single entry point. The panel type is chosen by a method call on the yielded builder
11
+ Single entry point. The panel type is chosen by a method call on the yielded
12
+ `Combobox::Config` ([`Plumber::Config`](plumber.md#plumberconfig))
12
13
  (`c.dropdown`, `c.typeahead`, `c.date`, `c.time`) — the panel owns its `aria-haspopup`,
13
14
  popup id, trigger icon, and wrapper data. The `sp_combobox_*` helpers below are thin
14
15
  wrappers over this.
@@ -31,7 +32,7 @@ end %>
31
32
  | `close_on_select` | `false` keeps the panel open after a selection |
32
33
  | `**html_options` | Forwarded to the wrapper `div` |
33
34
 
34
- Builder methods: `c.dropdown(options:, value:, label:)`, `c.typeahead(options:, value:, label:, url:)`,
35
+ Config methods: `c.dropdown(options:, value:, label:)`, `c.typeahead(options:, value:, label:, url:)`,
35
36
  `c.date(value:)`, `c.time(format:, step:, value:)`.
36
37
 
37
38
  ---
@@ -108,6 +108,9 @@ Three methods render a complete, accessible field:
108
108
  <%= f.field :bio, as: :text_area, hint: "Tell us about yourself." %>
109
109
  <%= f.field :avatar, as: :file %>
110
110
  <%= f.field :password, as: :password, revealable: true %>
111
+ <%= f.field :password, as: :password do |p| %>
112
+ <% p.enforce min_length: 12, max_length: 64 %>
113
+ <% end %>
111
114
  <%= f.field :verification_code, as: :code, length: 6 %>
112
115
  <%= f.field :card_number, as: :credit_card %>
113
116
  <%= f.field :email, as: :email, floating: :filled %>
@@ -118,6 +121,8 @@ Three methods render a complete, accessible field:
118
121
  choices: ["ruby", "rails", "hotwire"], clearable: true %>
119
122
  ```
120
123
 
124
+ `f.field` can take a block when its renderer declares `&block`; that declaration opts the renderer into the block DSL. For example, the password renderer yields a `Password::Requirements` that accepts `enforce(**options)` and `rule(...)` configuration — see **Password** below. Strength renders a `password-strength` wrapper containing the input, a native `<meter>`, a polite live level, a rules heading, and a rules list. The input references the rules list with `aria-describedby`.
125
+
121
126
  `choices:` takes the standard Rails shape — an array of `[label, value]` pairs (or a flat array of strings).
122
127
 
123
128
  **Floating label variants** — label starts inside the input, animates above on focus/fill. Compatible with text-like inputs only.
@@ -162,13 +167,40 @@ Use `f.search_field` for a native `<input type="search">`.
162
167
 
163
168
  **Password** (`as: :password`) — reveal-toggle wrapper backed by `input-revealable`.
164
169
 
165
- | Option | Values | Default | Description |
166
- | -------------- | ------- | -------------------- | ------------------------------------------- |
167
- | `revealable` | Boolean | `false` | Adds a show/hide toggle button on the input |
168
- | `autocomplete` | String | `"current-password"` | Native autocomplete value |
170
+ | Option | Values | Default | Description |
171
+ | -------------- | ------------------------ | -------------------- | ------------------------------------------------ |
172
+ | `revealable` | Boolean | `false` | Adds a show/hide toggle button on the input |
173
+ | `autocomplete` | String | `"current-password"` | Native autocomplete value |
174
+ | `requirements` | `Password::Requirements` | `nil` | Prebuilt rule set that drives the strength meter |
169
175
 
170
176
  Use `f.password_field` for a plain `<input type="password">` (also accepts `revealable:`).
171
177
 
178
+ **Strength rules.** Declare rules inline with a block, or pass a shared `requirements:` object. `enforce(min_length:, max_length:, uppercase:/lowercase:/digit:/symbol:)` enables built-ins — a character-class option takes `true` (≥1), an Integer (≥N), or a Range (`N..M` occurrences); the length rule requires **both** `min_length` and `max_length`. `rule(key, label, pattern:, min:, max:, negate:)` adds a custom rule (`negate: true` forbids matches).
179
+
180
+ ```erb
181
+ <%= f.field :password, as: :password do |p| %>
182
+ <% p.enforce min_length: 12, max_length: 64, digit: true %>
183
+ <% p.rule :no_spaces, "No spaces", pattern: /\s/, negate: true %>
184
+ <% end %>
185
+ ```
186
+
187
+ **Server enforcement.** Build one `Password::Requirements` and share it between the form meter and the model validator so they cannot drift:
188
+
189
+ ```ruby
190
+ PASSWORD_RULES = StimulusPlumbers::Password::Requirements.build do |r|
191
+ r.enforce(min_length: 12, max_length: 64, digit: true)
192
+ end
193
+
194
+ # model — PasswordStrengthValidator; valid? iff every enabled rule passes
195
+ validates :password, password_strength: { with: PASSWORD_RULES }
196
+ ```
197
+
198
+ ```erb
199
+ <%= f.field :password, as: :password, requirements: PASSWORD_RULES %>
200
+ ```
201
+
202
+ The validator also accepts inline options (`password_strength: { min_length: 12, max_length: 64, digit: true }`) and a custom `message:`. For the rule-descriptor wire contract and meter behaviour, see the [JS controller doc](../../../stimulus-plumbers/docs/component/password-strength.md).
203
+
172
204
  **Code** (`as: :code`) — character-cell entry backed by `input-formatter` and the `character-cells` plumber. The native input remains the accessible control; cells are decorative.
173
205
 
174
206
  | Option | Values | Default | Description |
@@ -105,7 +105,7 @@ end
105
105
 
106
106
  ## Plumber::Slots
107
107
 
108
- Base class for a component's slot DSL (e.g. `renderer.with_title(value)`, `renderer.with_content { ... }`). Used by `Card`, `Button`, `Link`, `List::Item`, `Timeline::Event`, and `Combobox::Builder`.
108
+ Base class for a component's slot DSL (e.g. `renderer.with_title(value)`, `renderer.with_content { ... }`). Used by `Card`, `Button`, `Link`, `List::Item`, and `Timeline::Event`.
109
109
 
110
110
  ```ruby
111
111
  class MySlots < StimulusPlumbers::Plumber::Slots
@@ -123,6 +123,45 @@ yield slots if block_given?
123
123
 
124
124
  Slots needing custom validation (e.g. a required keyword) define `with_*` manually instead of using the `slot` DSL — see `Card::Slots#with_action`.
125
125
 
126
+ `set_slot` is private and returns `nil`, so a `with_*` method reads as a command — hand-written setters need no trailing `nil`. Reads go through the public `resolve` / `options_for`.
127
+
128
+ ---
129
+
130
+ ## Plumber::Config
131
+
132
+ Sibling of `Slots` for block DSLs whose payload is configuration rather than content. Used by `Combobox::Config`. (Password rules are **not** a `Plumber::Config` — `Password::Requirements` is a standalone model-layer object; see the password field in [form.md](form.md).)
133
+
134
+ **Which one to subclass — does the block body produce markup?** Yes → `Slots`. No → `Config`.
135
+
136
+ | | `Plumber::Slots` | `Plumber::Config` |
137
+ | ----------------- | ------------------------------------------------ | ------------------------------------------------ |
138
+ | Holds | content — blocks rendered through the view | configuration — values, hashes, class references |
139
+ | Template used for | `capture` (essential) | passing through to renderers only |
140
+ | Declares | `slot :a, :b` macro, generating `with_*` setters | plain named methods |
141
+ | Public read API | `resolve` / `options_for` | none — subclasses expose named readers |
142
+
143
+ `Config` never captures, so it inherits no `slot` macro. Its whole store is **private**: `Slots` publishes `resolve` because its payload is uniform content a renderer reads generically, whereas a `Config`'s settings are typed, so each subclass names its own readers.
144
+
145
+ ```ruby
146
+ class MyConfig < StimulusPlumbers::Plumber::Config
147
+ def panel(**options)
148
+ configure(:panel, options) # stores, returns nil
149
+ end
150
+
151
+ def panel_options
152
+ config(:panel) # reads, nil when unset
153
+ end
154
+
155
+ def panel?
156
+ configured?(:panel) # distinguishes unset from set-to-nil
157
+ end
158
+ end
159
+ ```
160
+
161
+ Setters return `nil` for the same reason `set_slot` does — the DSL method reads as a command.
162
+
163
+ Construct with the view `template`; `Config` exposes it via `attr_reader` so subclasses can hand it to renderers (see `Combobox::Config#render_panel`).
164
+
126
165
  ---
127
166
 
128
167
  ## Plumber::Options::Html
@@ -1,6 +1,6 @@
1
1
  # Progress
2
2
 
3
- Rails helpers for rendering the `progress` Stimulus controller's three variants. See [stimulus-plumbers's docs/component/progress.md](../../../stimulus-plumbers/docs/component/progress.md) for the controller's Values/Targets/Methods/Dispatches.
3
+ Rails helpers for rendering the `progress` Stimulus controller's four variants. See [stimulus-plumbers's docs/component/progress.md](../../../stimulus-plumbers/docs/component/progress.md) for the controller's Values/Targets/Methods/Dispatches.
4
4
 
5
5
  ## Helpers
6
6
 
@@ -23,21 +23,41 @@ width directly — this is the same with or without a theme. A theme may
23
23
  layer motion on top (Tailwind slides it); without one, the bar renders
24
24
  as a static partial fill.
25
25
 
26
+ ### `sp_progress_segmented`
27
+
28
+ ```erb
29
+ <%= sp_progress_segmented(value: 6, segments: 5, max: 10, aria: { label: "Password strength" }) %>
30
+ ```
31
+
32
+ Splits the track into `segments:` equal slots and distributes the value across them (max 10 with `segments: 5` → each slot spans 2 units). Renders one `fill` target per slot; the JS controller fills them.
33
+
34
+ | Option | Default | Description |
35
+ | ---------------- | ----------- | ------------------------------------------------------------------------------------------------------ |
36
+ | `value:` | — | Required. Current value |
37
+ | `segments:` | — | Required. Number of equal slots |
38
+ | `min:` | `0` | Range minimum |
39
+ | `max:` | `100` | Range maximum |
40
+ | `mode:` | `:discrete` | `:discrete` lights a whole slot once reached; `:continuous` partially fills the boundary slot |
41
+ | `ramp:` | `nil` | `:strength` colors slots danger → warning → success by position (strength meter); `nil` = single color |
42
+ | `indeterminate:` | `false` | Omits `aria-valuenow`; a single chunk relays across the slots, one at a time |
43
+ | `**html_options` | — | Forwarded to the outer `<div role="progressbar">` |
44
+
26
45
  ### `sp_progress_ring`
27
46
 
28
47
  ```erb
29
48
  <%= sp_progress_ring(value: 60, max: 100, aria: { label: "Storage used" }) %>
30
49
  ```
31
50
 
32
- | Option | Default | Description |
33
- | ---------------- | ------- | ---------------------------------------------------------- |
34
- | `value:` | — | Required. Current value |
35
- | `min:` | `0` | Range minimum |
36
- | `max:` | `100` | Range maximum |
37
- | `indeterminate:` | `false` | Omits `aria-valuenow`; adds the indeterminate hook class |
38
- | `**html_options` | | Forwarded to the rendered icon (role, aria, classes, etc.) |
51
+ | Option | Default | Description |
52
+ | ---------------- | ------- | --------------------------------------------------------------------------------------------- |
53
+ | `value:` | — | Required. Current value |
54
+ | `min:` | `0` | Range minimum |
55
+ | `max:` | `100` | Range maximum |
56
+ | `indeterminate:` | `false` | Omits `aria-valuenow`; adds the indeterminate hook class |
57
+ | `size:` | `nil` | `:sm` \| `:md` \| `:lg` size token; `nil` uses the icon's own size (override with `classes:`) |
58
+ | `**html_options` | — | Forwarded to the rendered icon (role, aria, classes, etc.) |
39
59
 
40
- Renders via the theme's icon registry (icon name `"progress-ring"`), the same mechanism as `sp_icon`. Track/fill color and ring size are fixed by the icon's own SVG — resize with a `classes:`/`class:` override (e.g. `classes: "size-16"`) rather than a radius option. **Themes must register a `"progress-ring"` icon to render the ring's visual structure** — `stimulus-plumbers-tailwind` ships one; a theme without it (including the unstyled `Themes::Base`) falls back to an empty `<span role="progressbar">` with no visible ring, same as `sp_icon` for an unknown icon name.
60
+ Renders via the theme's icon registry (icon name `"progress-ring"`), the same mechanism as `sp_icon`. Resize with the `size:` token (`:sm`/`:md`/`:lg`) or, for an exact size, a `classes:`/`class:` override (e.g. `classes: "size-16"`) there is no radius option. **Themes must register a `"progress-ring"` icon to render the ring's visual structure** — `stimulus-plumbers-tailwind` ships one; a theme without it (including the unstyled `Themes::Base`) falls back to an empty `<span role="progressbar">` with no visible ring, same as `sp_icon` for an unknown icon name.
41
61
 
42
62
  When `indeterminate:` is true (and a theme provides the ring icon), the JS
43
63
  controller sets the fill circle's `stroke-dasharray` to a fixed 25% arc
@@ -10,7 +10,6 @@ module StimulusPlumbers
10
10
  raise ArgumentError, "card.body requires a block" unless block
11
11
 
12
12
  set_slot(:body, block)
13
- nil
14
13
  end
15
14
 
16
15
  # Defined manually (not via `slot` DSL) because it requires a named `url:` keyword with validation.
@@ -18,7 +17,6 @@ module StimulusPlumbers
18
17
  raise ArgumentError, "card.action requires content (string or block) when url: is given" if url && value.nil? && !block
19
18
 
20
19
  set_slot(:action, block || value, url ? { url: url } : {})
21
- nil
22
20
  end
23
21
  end
24
22
  end
@@ -5,7 +5,7 @@ module StimulusPlumbers
5
5
  class Combobox
6
6
  # Yielded to `Combobox#render`: selects a variant renderer, then exposes its
7
7
  # `metadata` (trigger/wrapper wiring) and renders its panel body.
8
- class Builder < Plumber::Slots
8
+ class Config < Plumber::Config
9
9
  def dropdown(**options)
10
10
  select(Dropdown, options)
11
11
  end
@@ -23,22 +23,22 @@ module StimulusPlumbers
23
23
  end
24
24
 
25
25
  def selected?
26
- @slots.key?(:variant)
26
+ configured?(:renderer)
27
27
  end
28
28
 
29
29
  def renderer
30
- selection&.fetch(:renderer)
30
+ config(:renderer)
31
31
  end
32
32
 
33
33
  def options
34
- selection ? selection[:options] : {}
34
+ config(:options) || {}
35
35
  end
36
36
 
37
37
  def metadata
38
38
  renderer ? renderer::Metadata : DefaultMetadata
39
39
  end
40
40
 
41
- def render_panel(template, panel_attrs:)
41
+ def render_panel(panel_attrs:)
42
42
  renderer&.new(template)&.render(panel_attrs: panel_attrs, **options)
43
43
  end
44
44
 
@@ -70,12 +70,8 @@ module StimulusPlumbers
70
70
  private
71
71
 
72
72
  def select(renderer, options)
73
- set_slot(:variant, { renderer: renderer, options: options })
74
- nil
75
- end
76
-
77
- def selection
78
- resolve(:variant)
73
+ configure(:renderer, renderer)
74
+ configure(:options, options)
79
75
  end
80
76
  end
81
77
  end
@@ -9,42 +9,42 @@ module StimulusPlumbers
9
9
 
10
10
  def render(trigger: {}, input: {}, id: nil, label: nil, close_on_select: nil, **kwargs, &block)
11
11
  trigger_opts = trigger.dup
12
- builder = resolve_builder(&block)
12
+ config = resolve_config(&block)
13
13
  trigger_id = id || trigger_opts.delete(:id) || template.sp_dom_id
14
14
  panel_id = Popover.panel_id_for(trigger_id)
15
15
 
16
- template.content_tag(:div, **combobox_attrs(input, close_on_select, builder, panel_id, kwargs)) do
17
- build_popover(trigger_opts, input, builder, trigger_id, panel_id, label)
16
+ template.content_tag(:div, **combobox_attrs(input, close_on_select, config, panel_id, kwargs)) do
17
+ build_popover(trigger_opts, input, config, trigger_id, panel_id, label)
18
18
  end
19
19
  end
20
20
 
21
21
  private
22
22
 
23
- def resolve_builder
24
- builder = Combobox::Builder.new(template)
25
- yield builder if block_given?
26
- builder
23
+ def resolve_config
24
+ config = Combobox::Config.new(template)
25
+ yield config if block_given?
26
+ config
27
27
  end
28
28
 
29
- def build_popover(trigger, input, builder, trigger_id, panel_id, label)
30
- metadata = builder.metadata
29
+ def build_popover(trigger, input, config, trigger_id, panel_id, label)
30
+ metadata = config.metadata
31
31
 
32
32
  Components::Popover.new(template).build(panel_id: panel_id) do |p|
33
33
  p.trigger(haspopup: metadata.haspopup, controls: metadata.popup_id_for(panel_id)) do |attrs|
34
34
  build_combobox_trigger(attrs, trigger, input, metadata, trigger_id, label)
35
35
  end
36
36
  p.build_panel(classes: theme.resolve(:combobox_popover).fetch(:classes, "")) do |panel_attrs|
37
- builder.render_panel(template, panel_attrs: panel_attrs)
37
+ config.render_panel(panel_attrs: panel_attrs)
38
38
  end
39
39
  end
40
40
  end
41
41
 
42
- def combobox_attrs(input, close_on_select, builder, panel_id, kwargs)
42
+ def combobox_attrs(input, close_on_select, config, panel_id, kwargs)
43
43
  merge_html_options(
44
44
  theme.resolve(:combobox),
45
45
  kwargs,
46
46
  { data: stimulus_data(input[:value], close_on_select) },
47
- { data: builder.metadata.stimulus_data(panel_id, builder.options) }
47
+ { data: config.metadata.stimulus_data(panel_id, config.options) }
48
48
  )
49
49
  end
50
50
 
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StimulusPlumbers
4
+ module Components
5
+ class PasswordStrength < Plumber::Base
6
+ LEVEL_KEYS = %i[weak fine strong].freeze
7
+
8
+ class << self
9
+ def meter_id_for(input_id)
10
+ "#{input_id}_meter"
11
+ end
12
+
13
+ def rules_id_for(input_id)
14
+ "#{input_id}_rules"
15
+ end
16
+ end
17
+
18
+ def render(input:, input_id:, config:)
19
+ @config = config
20
+ template.content_tag(:div, **wrapper_options(config, input_id)) do
21
+ template.safe_join([input, render_meter(input_id), render_level, render_rules_heading, render_rules(input_id)])
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ def wrapper_options(config, input_id)
28
+ stimulus = config.to_stimulus
29
+ merge_html_options(
30
+ theme.resolve(:password_strength_wrapper),
31
+ data: {
32
+ controller: "password-strength",
33
+ password_strength_rules_value: stimulus[:rules].to_json,
34
+ password_strength_options_value: stimulus[:options].to_json,
35
+ password_strength_labels_value: stimulus[:labels].to_json,
36
+ password_strength_progress_outlet: "##{self.class.meter_id_for(input_id)}"
37
+ }
38
+ )
39
+ end
40
+
41
+ def render_meter(input_id)
42
+ ProgressMeter.new(template).render(value: 0, id: self.class.meter_id_for(input_id), **@config.thresholds)
43
+ end
44
+
45
+ def render_level
46
+ template.content_tag(
47
+ :p,
48
+ level_labels[:weak],
49
+ **merge_html_options(
50
+ theme.resolve(:password_strength_level),
51
+ data: { password_strength_target: "level" },
52
+ aria: { live: "polite" }
53
+ )
54
+ )
55
+ end
56
+
57
+ def level_labels
58
+ LEVEL_KEYS.index_with { |key| I18n.t("stimulus_plumbers.password.levels.#{key}") }
59
+ end
60
+
61
+ def render_rules_heading
62
+ template.content_tag(
63
+ :p,
64
+ I18n.t("stimulus_plumbers.password.rules_heading"),
65
+ **theme.resolve(:password_strength_rules_heading)
66
+ )
67
+ end
68
+
69
+ def render_rules(input_id)
70
+ html_options = merge_html_options(
71
+ theme.resolve(:password_strength_rules),
72
+ id: self.class.rules_id_for(input_id)
73
+ )
74
+ template.content_tag(:ul, **html_options) do
75
+ template.safe_join(@config.rules.map { |key, label| render_rule(key, label) })
76
+ end
77
+ end
78
+
79
+ def render_rule(key, label)
80
+ template.content_tag(
81
+ :li,
82
+ **merge_html_options(
83
+ theme.resolve(:password_strength_rule),
84
+ data: {
85
+ password_strength_target: "rule", rule: key, satisfied: "false"
86
+ }
87
+ )
88
+ ) { template.safe_join([rule_icon("check", "checkIcon", hidden: true), rule_icon("close", "closeIcon"), label]) }
89
+ end
90
+
91
+ def rule_icon(name, target, hidden: false)
92
+ Icon.new(template).render(
93
+ name,
94
+ size: :sm,
95
+ aria: { hidden: "true" },
96
+ data: { password_strength_target: target },
97
+ hidden: hidden,
98
+ **theme.resolve(:password_strength_rule_icon)
99
+ )
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StimulusPlumbers
4
+ module Components
5
+ module Progress
6
+ # Shared stimulus/ARIA wiring for the progress variants (bar, segmented, ring, meter).
7
+ module Shared
8
+ private
9
+
10
+ def progress_stimulus_data(value:, min:, max:, variant:, **extra)
11
+ {
12
+ data: {
13
+ controller: "progress",
14
+ "progress-variant-value": variant,
15
+ "progress-current-value": value,
16
+ "progress-min-value": min,
17
+ "progress-max-value": max,
18
+ **extra
19
+ }
20
+ }
21
+ end
22
+
23
+ def progress_aria(value:, min:, max:, indeterminate: false)
24
+ aria = { valuemin: min, valuemax: max }
25
+ aria[:valuenow] = value unless indeterminate
26
+ aria
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -3,47 +3,71 @@
3
3
  module StimulusPlumbers
4
4
  module Components
5
5
  class ProgressBar < Plumber::Base
6
+ include Progress::Shared
7
+
6
8
  def render(...)
7
9
  render_bar(...)
8
10
  end
9
11
 
12
+ def render_segmented(value:, segments:, min: 0, max: 100, mode: :discrete, indeterminate: false, ramp: nil, **kwargs)
13
+ html_options = merge_html_options(
14
+ theme.resolve(:progress_segmented),
15
+ kwargs,
16
+ progress_stimulus_data(
17
+ value: value,
18
+ min: min,
19
+ max: max,
20
+ variant: "segmented",
21
+ "progress-segment-mode-value": mode,
22
+ "progress-indeterminate-value": indeterminate
23
+ ),
24
+ { role: "progressbar", aria: progress_aria(value: value, min: min, max: max, indeterminate: indeterminate) }
25
+ )
26
+ slots = ramp_intents(ramp, segments).map { |intent| render_segment(intent: intent) }
27
+ template.content_tag(:div, template.safe_join(slots), **html_options)
28
+ end
29
+
10
30
  private
11
31
 
12
32
  def render_bar(value:, min: 0, max: 100, indeterminate: false, **kwargs)
13
33
  html_options = merge_html_options(
14
34
  theme.resolve(:progress_bar),
15
35
  kwargs,
16
- stimulus_data(value: value, min: min, max: max, indeterminate: indeterminate),
36
+ progress_stimulus_data(
37
+ value: value, min: min, max: max, variant: "bar", "progress-indeterminate-value": indeterminate
38
+ ),
17
39
  { role: "progressbar", aria: progress_aria(value: value, min: min, max: max, indeterminate: indeterminate) }
18
40
  )
19
41
  template.content_tag(:div, render_fill, **html_options)
20
42
  end
21
43
 
22
- def render_fill
44
+ # `data-intent` is a theme-independent styling hook the theme colors via attribute variants.
45
+ def render_fill(intent: nil)
46
+ data = { "progress-target": "fill" }
47
+ data[:intent] = intent if intent
48
+ template.content_tag(:div, nil, **merge_html_options(theme.resolve(:progress_bar_fill), { data: data }))
49
+ end
50
+
51
+ # Slots are aria-hidden — the container owns the progressbar ARIA.
52
+ def render_segment(intent: nil)
23
53
  template.content_tag(
24
54
  :div,
25
- nil,
26
- **merge_html_options(theme.resolve(:progress_bar_fill), { data: { "progress-target": "fill" } })
55
+ render_fill(intent: intent),
56
+ **merge_html_options(theme.resolve(:progress_segment), { aria: { hidden: true } })
27
57
  )
28
58
  end
29
59
 
30
- def progress_aria(value:, min:, max:, indeterminate:)
31
- aria = { valuemin: min, valuemax: max }
32
- aria[:valuenow] = value unless indeterminate
33
- aria
34
- end
60
+ # `:strength` buckets each slot into danger/warning/success thirds by position; nil = uncolored.
61
+ def ramp_intents(ramp, count)
62
+ return Array.new(count) unless ramp == :strength
35
63
 
36
- def stimulus_data(value:, min:, max:, indeterminate:)
37
- {
38
- data: {
39
- controller: "progress",
40
- "progress-variant-value": "bar",
41
- "progress-current-value": value,
42
- "progress-min-value": min,
43
- "progress-max-value": max,
44
- "progress-indeterminate-value": indeterminate
45
- }
46
- }
64
+ Array.new(count) do |i|
65
+ case (i + 1).fdiv(count)
66
+ when ..(1.0 / 3) then :danger
67
+ when ..(2.0 / 3) then :warning
68
+ else :success
69
+ end
70
+ end
47
71
  end
48
72
  end
49
73
  end
@@ -3,6 +3,8 @@
3
3
  module StimulusPlumbers
4
4
  module Components
5
5
  class ProgressMeter < Plumber::Base
6
+ include Progress::Shared
7
+
6
8
  def render(...)
7
9
  render_meter(...)
8
10
  end
@@ -14,25 +16,25 @@ module StimulusPlumbers
14
16
  html_options = merge_html_options(
15
17
  theme.resolve(:progress_meter),
16
18
  kwargs,
17
- stimulus_data(value: value, min: min, max: max, low: low, high: high, optimum: optimum),
19
+ progress_stimulus_data(
20
+ value: value,
21
+ min: min,
22
+ max: max,
23
+ variant: "meter",
24
+ "progress-target": "meter",
25
+ **threshold_data(low: low, high: high, optimum: optimum)
26
+ ),
18
27
  attrs
19
28
  )
20
29
  template.content_tag(:meter, nil, **html_options)
21
30
  end
22
31
 
23
- def stimulus_data(value:, min:, max:, low:, high:, optimum:)
24
- data = {
25
- controller: "progress",
26
- "progress-target": "meter",
27
- "progress-variant-value": "meter",
28
- "progress-current-value": value,
29
- "progress-min-value": min,
30
- "progress-max-value": max
31
- }
32
- data["progress-low-value"] = low if low
33
- data["progress-high-value"] = high if high
34
- data["progress-optimum-value"] = optimum if optimum
35
- { data: data }
32
+ def threshold_data(low:, high:, optimum:)
33
+ data = {}
34
+ data[:"progress-low-value"] = low if low
35
+ data[:"progress-high-value"] = high if high
36
+ data[:"progress-optimum-value"] = optimum if optimum
37
+ data
36
38
  end
37
39
  end
38
40
  end