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
@@ -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
  ---
@@ -44,6 +44,7 @@ Standard Rails helpers are overridden to apply theme CSS classes. All native HTM
44
44
  <%= f.time_zone_select :timezone %>
45
45
  <%= f.weekday_select :weekday %>
46
46
  <%= f.search_field :query %>
47
+ <%= f.range_field :volume %>
47
48
  <%= f.check_box :agree %>
48
49
  <%= f.radio_button :plan, "basic" %>
49
50
  ```
@@ -54,6 +55,7 @@ Special options on native helpers:
54
55
  | ---------------- | ------------------ | ------------------------------------------------------------------ |
55
56
  | `password_field` | `revealable: true` | Wraps input in an `input-revealable` controller |
56
57
  | `search_field` | `clearable: true` | Wraps input in an `input-clearable` controller with a clear button |
58
+ | `range_field` | — | Themed track and thumb; no controller wiring, readout, or fill |
57
59
 
58
60
  ### f.submit
59
61
 
@@ -80,23 +82,25 @@ Submit buttons need an accessible name: pass non-blank text, or an `aria` label
80
82
 
81
83
  Three methods render a complete, accessible field:
82
84
 
83
- | Method | `as:` values |
84
- | --------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
85
- | `f.field(attr, as:, **opts)` | `:text` `:email` `:number` `:url` `:tel` `:color` `:month` `:week` `:range` `:datetime_local` `:text_area` `:file` `:password` `:date` `:time` `:select` `:search` `:code` `:credit_card` |
86
- | `f.collection_field(attr, as:, collection:, value_method:, text_method:, **opts)` | `:collection_select` `:grouped_collection_select` |
87
- | `f.choice(attr, as:, **opts)` | `:radio` `:check_box` |
85
+ | Method | `as:` values |
86
+ | --------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
87
+ | `f.field(attr, as:, **opts)` | `:text` `:email` `:number` `:url` `:tel` `:color` `:month` `:week` `:range` `:datetime_local` `:text_area` `:file` `:password` `:date` `:time` `:select` `:search` `:code` `:credit_card` `:progress` |
88
+ | `f.collection_field(attr, as:, collection:, value_method:, text_method:, **opts)` | `:collection_select` `:grouped_collection_select` |
89
+ | `f.choice(attr, as:, **opts)` | `:radio` `:check_box` |
88
90
 
89
91
  ### Shared field options
90
92
 
91
- | Option | Type | Default | Description |
92
- | ------------ | ------------------------------------- | ------------------------ | -------------------------------------------------------------------------- |
93
- | `label` | String | humanised attribute name | Override label / legend text |
94
- | `hint` | String | `nil` | Hint text rendered below the field |
95
- | `error` | String / Array | `nil` | Override error message(s); suppresses `model.errors[attribute]` |
96
- | `required` | Boolean | `false` | Adds `required` + `aria-required="true"` |
97
- | `hide_label` | Boolean | `false` | Renders label visually hidden (screen-reader accessible) |
98
- | `layout` | `:stacked` / `:inline` | `:stacked` | Label above input vs beside it |
99
- | `floating` | `:filled` / `:outlined` / `:standard` | `nil` | Floating-label style (text-like inputs only; `nil` = standard label above) |
93
+ | Option | Type | Default | Description |
94
+ | ------------ | ------------------------------------- | ------------------------- | -------------------------------------------------------------------------- |
95
+ | `label` | String | translated attribute name | Override label / legend text |
96
+ | `hint` | String | `nil` | Hint text rendered below the field |
97
+ | `error` | String / Array | `nil` | Override error message(s); suppresses `model.errors[attribute]` |
98
+ | `required` | Boolean | `false` | Adds `required` + `aria-required="true"` |
99
+ | `hide_label` | Boolean | `false` | Renders label visually hidden (screen-reader accessible) |
100
+ | `layout` | `:stacked` / `:inline` | `:stacked` | Label above input vs beside it |
101
+ | `floating` | `:filled` / `:outlined` / `:standard` | `nil` | Floating-label style (text-like inputs only; `nil` = standard label above) |
102
+
103
+ Labels and legends default to the text `f.label` renders: `helpers.label.<object_name>.<attribute>`, then the model's `human_attribute_name` (`activerecord.attributes.*` / `activemodel.attributes.*`), then the humanized attribute name when the form has no model. In `fields_for`, the nested object supplies the name.
100
104
 
101
105
  ---
102
106
 
@@ -108,6 +112,9 @@ Three methods render a complete, accessible field:
108
112
  <%= f.field :bio, as: :text_area, hint: "Tell us about yourself." %>
109
113
  <%= f.field :avatar, as: :file %>
110
114
  <%= f.field :password, as: :password, revealable: true %>
115
+ <%= f.field :password, as: :password do |p| %>
116
+ <% p.enforce min_length: 12, max_length: 64 %>
117
+ <% end %>
111
118
  <%= f.field :verification_code, as: :code, length: 6 %>
112
119
  <%= f.field :card_number, as: :credit_card %>
113
120
  <%= f.field :email, as: :email, floating: :filled %>
@@ -118,6 +125,8 @@ Three methods render a complete, accessible field:
118
125
  choices: ["ruby", "rails", "hotwire"], clearable: true %>
119
126
  ```
120
127
 
128
+ `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`.
129
+
121
130
  `choices:` takes the standard Rails shape — an array of `[label, value]` pairs (or a flat array of strings).
122
131
 
123
132
  **Floating label variants** — label starts inside the input, animates above on focus/fill. Compatible with text-like inputs only.
@@ -162,13 +171,40 @@ Use `f.search_field` for a native `<input type="search">`.
162
171
 
163
172
  **Password** (`as: :password`) — reveal-toggle wrapper backed by `input-revealable`.
164
173
 
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 |
174
+ | Option | Values | Default | Description |
175
+ | -------------- | ------------------------ | -------------------- | ------------------------------------------------ |
176
+ | `revealable` | Boolean | `false` | Adds a show/hide toggle button on the input |
177
+ | `autocomplete` | String | `"current-password"` | Native autocomplete value |
178
+ | `requirements` | `Password::Requirements` | `nil` | Prebuilt rule set that drives the strength meter |
169
179
 
170
180
  Use `f.password_field` for a plain `<input type="password">` (also accepts `revealable:`).
171
181
 
182
+ **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).
183
+
184
+ ```erb
185
+ <%= f.field :password, as: :password do |p| %>
186
+ <% p.enforce min_length: 12, max_length: 64, digit: true %>
187
+ <% p.rule :no_spaces, "No spaces", pattern: /\s/, negate: true %>
188
+ <% end %>
189
+ ```
190
+
191
+ **Server enforcement.** Build one `Password::Requirements` and share it between the form meter and the model validator so they cannot drift:
192
+
193
+ ```ruby
194
+ PASSWORD_RULES = StimulusPlumbers::Password::Requirements.build do |r|
195
+ r.enforce(min_length: 12, max_length: 64, digit: true)
196
+ end
197
+
198
+ # model — PasswordStrengthValidator; valid? iff every enabled rule passes
199
+ validates :password, password_strength: { with: PASSWORD_RULES }
200
+ ```
201
+
202
+ ```erb
203
+ <%= f.field :password, as: :password, requirements: PASSWORD_RULES %>
204
+ ```
205
+
206
+ 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).
207
+
172
208
  **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
209
 
174
210
  | Option | Values | Default | Description |
@@ -191,6 +227,37 @@ Use `f.password_field` for a plain `<input type="password">` (also accepts `reve
191
227
 
192
228
  Character-cell fields do not support `floating:` labels.
193
229
 
230
+ **Range** (`as: :range`) — native `<input type="range">` styled as a progress track with a thumb. Unlike the progress field it is a real control, so it keeps an ordinary `<label for>`.
231
+
232
+ | Option | Values | Default | Description |
233
+ | --------------- | ------------------------------------ | ----------- | ------------------------------------ |
234
+ | `format:` | `:percent` / `:value` / `:value_max` | `nil` | Adds a live readout beside the track |
235
+ | `min:` / `max:` | Number | `0` / `100` | Range bounds |
236
+
237
+ ```erb
238
+ <%= f.field :volume, as: :range, format: :percent %>
239
+ ```
240
+
241
+ The controller writes no `aria-*` — a native range already exposes its own slider semantics, and the readout is `aria-hidden` so the value isn't announced twice. The fill percentage is server-rendered onto the input, so it is correct before the controller connects.
242
+
243
+ Cross-browser caveat: WebKit has no filled-track pseudo-element, so the fill is a gradient driven by `--sp-progress-percent`; Firefox uses its native `::-moz-range-progress`. Use `f.range_field` for the same themed slider without the controller wiring, readout, or server-rendered fill.
244
+
245
+ **Progress** (`as: :progress`) — read-only indicator for a model attribute, rendered by [`sp_progress_bar`](progress.md#sp_progress_bar). It submits nothing and is never invalid.
246
+
247
+ | Option | Values | Default | Description |
248
+ | --------------- | ------------------------------------ | ----------- | --------------------------------------------------------------------------------------------------- |
249
+ | `format:` | `:percent` / `:value` / `:value_max` | `nil` | On-screen readout of the value |
250
+ | `readout:` | `:inside` / `:outside` | `:inside` | Readout placement — see [`sp_progress_bar`](progress.md#sp_progress_bar) |
251
+ | `segments:` | positive Integer | `nil` | Renders segmented instead of a single bar; excludes `format:`. Anything else raises `ArgumentError` |
252
+ | `min:` / `max:` | Number | `0` / `100` | Range bounds |
253
+
254
+ ```erb
255
+ <%= f.field :completion, as: :progress, format: :percent %>
256
+ <%= f.field :profile_strength, as: :progress, segments: 5, max: 5 %>
257
+ ```
258
+
259
+ Its label renders as a `<span>`, not a `<label>`, and names the bar via `aria-labelledby`. `<label for>` is only valid against a labelable element (`button`, `input`, `meter`, `output`, `progress`, `select`, `textarea`), and a `div[role="progressbar"]` is none of those — a `for=` pointing at it would silently leave the bar unnamed. For the same reason it takes no `required` or `aria-invalid`; `hint:` and `error:` still describe it via `aria-describedby`.
260
+
194
261
  ---
195
262
 
196
263
  ## f.collection_field
@@ -35,7 +35,7 @@ Presentational status marker — a colored dot, an animated "pulse" ring, or a n
35
35
 
36
36
  When `pulse: true`, the dot is wrapped in an extra `<span>` (`indicator_wrapper` theme key) that positions the ring behind the dot — no margin offsets involved.
37
37
 
38
- **Every indicator must be paired with an accessible name** — a visible label or `aria-label`/adjacent `sr-only` text. The component itself renders no text and cannot know the right label; this is enforced by an accessibility test (see `test/accessibility/components/indicator_accessibility_test.rb`), not by the component.
38
+ **Every indicator must be paired with an accessible name** — a visible label or `aria-label`/adjacent `sr-only` text. The component renders no text and cannot infer the right label, so the caller must supply one.
39
39
 
40
40
  ## Legend pattern
41
41
 
@@ -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 four standalone `progress` variants. The controller also has a `range` variant, which has no `sp_*` helper — it is a form control, reached through [`f.field(as: :range)`](form.md). 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
 
@@ -10,34 +10,69 @@ Rails helpers for rendering the `progress` Stimulus controller's three variants.
10
10
  <%= sp_progress_bar(value: 65, max: 100, aria: { label: "Upload progress" }) %>
11
11
  ```
12
12
 
13
- | Option | Default | Description |
14
- | ---------------- | ------- | -------------------------------------------------------- |
15
- | `value:` | — | Required. Current value |
16
- | `min:` | `0` | Range minimum |
17
- | `max:` | `100` | Range maximum |
18
- | `indeterminate:` | `false` | Omits `aria-valuenow`; adds the indeterminate hook class |
19
- | `**html_options` | | Forwarded to the outer `<div role="progressbar">` |
13
+ | Option | Default | Description |
14
+ | ---------------- | --------- | ---------------------------------------------------------------------------------------------------------------------------- |
15
+ | `value:` | — | Required. Current value |
16
+ | `min:` | `0` | Range minimum |
17
+ | `max:` | `100` | Range maximum |
18
+ | `indeterminate:` | `false` | Omits `aria-valuenow`; adds the indeterminate hook class |
19
+ | `format:` | `nil` | Renders an on-screen readout: `:percent` \| `:value` \| `:value_max`. Any other value raises `ArgumentError` |
20
+ | `readout:` | `:inside` | Where that readout sits: `:inside` (over the track) \| `:outside` (beside it). Always validated; no effect without `format:` |
21
+ | `**html_options` | — | Forwarded to the outer `<div role="progressbar">` |
22
+
23
+ `format:` renders the readout server-side, so it is correct before the controller connects. The text and its `aria-valuetext` behaviour are the same as the controller's — see [Readout formats](../../../stimulus-plumbers/docs/component/progress.md#readout-formats). The theme is told whether a readout is present, so it can give the track room for the text.
24
+
25
+ ```erb
26
+ <%= sp_progress_bar(value: 45, format: :percent, aria: { label: "Upload progress" }) %>
27
+ <%= sp_progress_bar(value: 45, format: :percent, readout: :outside, aria: { label: "Upload progress" }) %>
28
+ ```
29
+
30
+ `:outside` wraps the root around a track element plus the readout, since the track clips its overflow. Role, ARIA, and `data-controller` stay on the root in both.
31
+
32
+ Theme keys: `progress_bar_value` (inside), `progress_bar_value_outside` + `progress_bar_group` (outside). A theme without the latter two renders `:outside` unstyled, not broken.
20
33
 
21
34
  When `indeterminate:` is true, the JS controller sets a fixed 25% fill
22
35
  width directly — this is the same with or without a theme. A theme may
23
36
  layer motion on top (Tailwind slides it); without one, the bar renders
24
37
  as a static partial fill.
25
38
 
39
+ ### `sp_progress_segmented`
40
+
41
+ ```erb
42
+ <%= sp_progress_segmented(value: 6, segments: 5, max: 10, aria: { label: "Password strength" }) %>
43
+ ```
44
+
45
+ `format:` is not supported here (it raises `ArgumentError`) — there is no single track to center a readout over.
46
+
47
+ 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.
48
+
49
+ | Option | Default | Description |
50
+ | ---------------- | ----------- | ------------------------------------------------------------------------------------------------------ |
51
+ | `value:` | — | Required. Current value |
52
+ | `segments:` | — | Required. Positive Integer; anything else raises `ArgumentError` |
53
+ | `min:` | `0` | Range minimum |
54
+ | `max:` | `100` | Range maximum |
55
+ | `mode:` | `:discrete` | `:discrete` lights a whole slot once reached; `:continuous` partially fills the boundary slot |
56
+ | `ramp:` | `nil` | `:strength` colors slots danger → warning → success by position (strength meter); `nil` = single color |
57
+ | `indeterminate:` | `false` | Omits `aria-valuenow`; a single chunk relays across the slots, one at a time |
58
+ | `**html_options` | — | Forwarded to the outer `<div role="progressbar">` |
59
+
26
60
  ### `sp_progress_ring`
27
61
 
28
62
  ```erb
29
63
  <%= sp_progress_ring(value: 60, max: 100, aria: { label: "Storage used" }) %>
30
64
  ```
31
65
 
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.) |
66
+ | Option | Default | Description |
67
+ | ---------------- | ------- | --------------------------------------------------------------------------------------------- |
68
+ | `value:` | — | Required. Current value |
69
+ | `min:` | `0` | Range minimum |
70
+ | `max:` | `100` | Range maximum |
71
+ | `indeterminate:` | `false` | Omits `aria-valuenow`; adds the indeterminate hook class |
72
+ | `size:` | `nil` | `:sm` \| `:md` \| `:lg` size token; `nil` uses the icon's own size (override with `classes:`) |
73
+ | `**html_options` | — | Forwarded to the rendered icon (role, aria, classes, etc.) |
39
74
 
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.
75
+ 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
76
 
42
77
  When `indeterminate:` is true (and a theme provides the ring icon), the JS
43
78
  controller sets the fill circle's `stroke-dasharray` to a fixed 25% arc
data/docs/guide.md CHANGED
@@ -1,5 +1,62 @@
1
1
  # Guide
2
2
 
3
+ ## Quickstart
4
+
5
+ Requires Ruby >= 3.0 and Rails >= 6.1.
6
+
7
+ 1. Add the gem and install:
8
+
9
+ ```ruby
10
+ # Gemfile
11
+ gem "stimulus_plumbers"
12
+ ```
13
+
14
+ ```bash
15
+ bundle install
16
+ ```
17
+
18
+ 2. Run the install generator — it copies `tokens.css` into your app and injects the import into your
19
+ CSS entry file (see [CSS entry file detection](#css-entry-file-detection)):
20
+
21
+ ```bash
22
+ bin/rails generate stimulus_plumbers:install
23
+ ```
24
+
25
+ 3. Register the Stimulus controllers that back the interactive components. The gem renders the
26
+ `data-controller` attributes; the JS package supplies the controllers themselves:
27
+
28
+ ```bash
29
+ npm install @stimulus-plumbers/controllers
30
+ ```
31
+
32
+ Register them as `guide://controller` describes — identifiers must match what the helpers emit.
33
+
34
+ 4. Make the form builder the default (or pass `builder:` per `form_with`):
35
+
36
+ ```ruby
37
+ # config/application.rb
38
+ config.action_view.default_form_builder = StimulusPlumbers::Form::Builder
39
+ ```
40
+
41
+ 5. Render a first form:
42
+
43
+ ```erb
44
+ <%= form_with model: @user do |f| %>
45
+ <%= f.field :email, as: :email, hint: "We never share it" %>
46
+ <%= f.field :password, as: :password, revealable: true %>
47
+ <%= f.field :country, as: :select, choices: ["Australia", "Canada"] %>
48
+ <%= f.submit "Create account" %>
49
+ <% end %>
50
+ ```
51
+
52
+ Each `f.field` renders the label, input, hint, and error with their ARIA wiring already
53
+ associated — no `label`/`aria-describedby` by hand.
54
+
55
+ For view components outside a form, include the helper modules you use in `ApplicationHelper` (see
56
+ the gem's
57
+ [README](https://github.com/ryancyq/stimulus-plumbers/blob/main/stimulus-plumbers-rails/README.md#installation))
58
+ and call the `sp_*` helpers described under [Building views](#building-views).
59
+
3
60
  ## Building forms
4
61
 
5
62
  Use `StimulusPlumbers::Form::Builder` (set `config.action_view.default_form_builder`, or pass
@@ -7,20 +64,22 @@ Use `StimulusPlumbers::Form::Builder` (set `config.action_view.default_form_buil
7
64
 
8
65
  - **Level 2 — recommended.** Full accessible field (label + input + hint + error):
9
66
  `f.field(attr, as:)`, `f.collection_field(attr, as:, collection:, ...)`, `f.choice(attr, as:)`.
10
- See [docs/component/form.md](component/form.md) for valid `as:` values per builder method and
67
+ See `component://form/docs` ([form.md](https://github.com/ryancyq/stimulus-plumbers/blob/main/stimulus-plumbers-rails/docs/component/form.md))
68
+ for valid `as:` values per builder method and
11
69
  which ones are backed by a Stimulus controller (date/time/select/search pickers).
12
70
  - **Level 1.** Native helper overrides (`f.text_field`, `f.select`, `f.check_box`, ...) render
13
71
  only the themed input element — use when you control the surrounding markup.
14
72
 
15
73
  Submit with `f.submit` (themed button; supports `icon_leading:`/`icon_trailing:` and
16
- `hide_label:` for an icon-only button — see [docs/component/form.md](component/form.md)).
74
+ `hide_label:` for an icon-only button — see `component://form/docs`).
17
75
 
18
76
  ## Building views
19
77
 
20
78
  Render components with `sp_*` helpers (`sp_button`, `sp_button_group`, `sp_card`, `sp_list`,
21
79
  `sp_link`, `sp_avatar`, `sp_divider`, `sp_icon`, `sp_popover`, ...) — see the
22
- [Components table](../README.md#components) for the full helper list, and each component's
23
- `docs/component/<name>.md` for its keyword options, slots, and themed params.
80
+ [Components table](https://github.com/ryancyq/stimulus-plumbers/blob/main/stimulus-plumbers-rails/README.md#components)
81
+ for the full helper list (`list_components` / `list_component_docs` over MCP), and each component's
82
+ `component://{name}/docs` for its keyword options, slots, and themed params.
24
83
 
25
84
  ## Stimulus integration
26
85
 
@@ -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