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
data/config/locales/en.yml
CHANGED
|
@@ -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"
|
data/docs/component/combobox.md
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
---
|
data/docs/component/form.md
CHANGED
|
@@ -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
|
|
166
|
-
| -------------- |
|
|
167
|
-
| `revealable` | Boolean
|
|
168
|
-
| `autocomplete` | String
|
|
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 |
|
data/docs/component/plumber.md
CHANGED
|
@@ -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
|
|
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
|
data/docs/component/progress.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Progress
|
|
2
2
|
|
|
3
|
-
Rails helpers for rendering the `progress` Stimulus controller's
|
|
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
|
-
|
|
|
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`.
|
|
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
|
|
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
|
-
|
|
26
|
+
configured?(:renderer)
|
|
27
27
|
end
|
|
28
28
|
|
|
29
29
|
def renderer
|
|
30
|
-
|
|
30
|
+
config(:renderer)
|
|
31
31
|
end
|
|
32
32
|
|
|
33
33
|
def options
|
|
34
|
-
|
|
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(
|
|
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
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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,
|
|
17
|
-
build_popover(trigger_opts, input,
|
|
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
|
|
24
|
-
|
|
25
|
-
yield
|
|
26
|
-
|
|
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,
|
|
30
|
-
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
|
-
|
|
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,
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
26
|
-
**merge_html_options(theme.resolve(:
|
|
55
|
+
render_fill(intent: intent),
|
|
56
|
+
**merge_html_options(theme.resolve(:progress_segment), { aria: { hidden: true } })
|
|
27
57
|
)
|
|
28
58
|
end
|
|
29
59
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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
|
|
24
|
-
data = {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|