stimulus_plumbers 0.4.15 → 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 (30) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +8 -0
  3. data/app/assets/javascripts/stimulus-plumbers/controllers.manifest.json +11 -1
  4. data/app/assets/javascripts/stimulus-plumbers/index.es.js +196 -163
  5. data/app/assets/javascripts/stimulus-plumbers/index.es.js.map +1 -1
  6. data/app/assets/javascripts/stimulus-plumbers/index.umd.js +1 -1
  7. data/app/assets/javascripts/stimulus-plumbers/index.umd.js.map +1 -1
  8. data/docs/component/form.md +49 -14
  9. data/docs/component/indicator.md +1 -1
  10. data/docs/component/progress.md +24 -9
  11. data/docs/guide.md +63 -4
  12. data/lib/stimulus_plumbers/components/progress/shared.rb +46 -3
  13. data/lib/stimulus_plumbers/components/progress_bar.rb +100 -22
  14. data/lib/stimulus_plumbers/components/progress_ring.rb +3 -2
  15. data/lib/stimulus_plumbers/form/builder.rb +20 -4
  16. data/lib/stimulus_plumbers/form/field.rb +34 -6
  17. data/lib/stimulus_plumbers/form/fields/inputs/progress.rb +30 -0
  18. data/lib/stimulus_plumbers/form/fields/inputs/range.rb +81 -0
  19. data/lib/stimulus_plumbers/form/fields/inputs/text.rb +1 -1
  20. data/lib/stimulus_plumbers/form/fields/renderer.rb +41 -0
  21. data/lib/stimulus_plumbers/helpers/progress_helper.rb +4 -2
  22. data/lib/stimulus_plumbers/themes/schema/progress/ranges.rb +13 -0
  23. data/lib/stimulus_plumbers/themes/schema.rb +17 -6
  24. data/lib/stimulus_plumbers/version.rb +1 -1
  25. data/vendor/ARIA.md +10 -0
  26. data/vendor/component/manifest.json +7 -2
  27. data/vendor/controller/docs/progress.md +69 -9
  28. data/vendor/controller/guide.md +61 -7
  29. data/vendor/controller/manifest.json +11 -1
  30. metadata +4 -1
@@ -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
 
@@ -223,6 +227,37 @@ The validator also accepts inline options (`password_strength: { min_length: 12,
223
227
 
224
228
  Character-cell fields do not support `floating:` labels.
225
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
+
226
261
  ---
227
262
 
228
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
 
@@ -1,6 +1,6 @@
1
1
  # Progress
2
2
 
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.
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,13 +10,26 @@ Rails helpers for rendering the `progress` Stimulus controller's four 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
@@ -29,12 +42,14 @@ as a static partial fill.
29
42
  <%= sp_progress_segmented(value: 6, segments: 5, max: 10, aria: { label: "Password strength" }) %>
30
43
  ```
31
44
 
45
+ `format:` is not supported here (it raises `ArgumentError`) — there is no single track to center a readout over.
46
+
32
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.
33
48
 
34
49
  | Option | Default | Description |
35
50
  | ---------------- | ----------- | ------------------------------------------------------------------------------------------------------ |
36
51
  | `value:` | — | Required. Current value |
37
- | `segments:` | — | Required. Number of equal slots |
52
+ | `segments:` | — | Required. Positive Integer; anything else raises `ArgumentError` |
38
53
  | `min:` | `0` | Range minimum |
39
54
  | `max:` | `100` | Range maximum |
40
55
  | `mode:` | `:discrete` | `:discrete` lights a whole slot once reached; `:continuous` partially fills the boundary slot |
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
 
@@ -3,8 +3,11 @@
3
3
  module StimulusPlumbers
4
4
  module Components
5
5
  module Progress
6
- # Shared stimulus/ARIA wiring for the progress variants (bar, segmented, ring, meter).
6
+ # Shared stimulus/ARIA wiring and value formatting for the progress variants
7
+ # (bar, segmented, ring, meter) and for the range form field, which reuses the readout.
7
8
  module Shared
9
+ FORMATS = %i[percent value value_max].freeze
10
+
8
11
  private
9
12
 
10
13
  def progress_stimulus_data(value:, min:, max:, variant:, **extra)
@@ -20,11 +23,51 @@ module StimulusPlumbers
20
23
  }
21
24
  end
22
25
 
23
- def progress_aria(value:, min:, max:, indeterminate: false)
26
+ def progress_aria(value:, min:, max:, indeterminate: false, valuetext: nil)
24
27
  aria = { valuemin: min, valuemax: max }
25
- aria[:valuenow] = value unless indeterminate
28
+ aria[:valuenow] = value unless indeterminate
29
+ aria[:valuetext] = valuetext if valuetext
26
30
  aria
27
31
  end
32
+
33
+ def validate_format!(format)
34
+ return if format.nil? || (format.respond_to?(:to_sym) && FORMATS.include?(format.to_sym))
35
+
36
+ raise ArgumentError, "unknown format: #{format.inspect} (expected one of #{FORMATS.join(", ")})"
37
+ end
38
+
39
+ # Keep in sync with the progress controller's formattedValue().
40
+ def value_text(format, current, min, max)
41
+ case format&.to_sym
42
+ when :percent then "#{percent(current, min, max).round}%"
43
+ when :value then integral(current).to_s
44
+ when :value_max then "#{integral(current)} / #{integral(max)}"
45
+ end
46
+ end
47
+
48
+ # Comparable#clamp raises when min > max; the JS returns max there, and percent() then yields 0%.
49
+ def clamp(value, min, max)
50
+ return max if max < min
51
+
52
+ value.clamp(min, max)
53
+ end
54
+
55
+ def percent(current, min, max)
56
+ range = max - min
57
+ range <= 0 ? 0 : (current - min).fdiv(range) * 100
58
+ end
59
+
60
+ # A `step: 0.1` range holds 45.5 — to_i would paint a 45 fill under a 45.5 thumb.
61
+ def numeric(value)
62
+ return value if value.is_a?(Numeric)
63
+
64
+ Float(value, exception: false) || 0
65
+ end
66
+
67
+ # 45.0 renders as "45" — JS has no Float/Integer distinction to mirror.
68
+ def integral(number)
69
+ (number % 1).zero? ? number.to_i : number
70
+ end
28
71
  end
29
72
  end
30
73
  end
@@ -9,19 +9,24 @@ module StimulusPlumbers
9
9
  render_bar(...)
10
10
  end
11
11
 
12
- def render_segmented(value:, segments:, min: 0, max: 100, mode: :discrete, indeterminate: false, ramp: nil, **kwargs)
12
+ def render_segmented(
13
+ value:,
14
+ segments:,
15
+ min: 0,
16
+ max: 100,
17
+ mode: :discrete,
18
+ indeterminate: false,
19
+ ramp: nil,
20
+ format: nil,
21
+ **kwargs
22
+ )
23
+ validate_segments!(segments, format)
24
+ current = clamp(value, min, max)
13
25
  html_options = merge_html_options(
14
- theme.resolve(:progress_segmented),
26
+ theme.resolve(:progress_segment_group),
15
27
  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) }
28
+ segmented_stimulus_data(current, min, max, mode, indeterminate),
29
+ { role: "progressbar", aria: progress_aria(value: current, min: min, max: max, indeterminate: indeterminate) }
25
30
  )
26
31
  slots = ramp_intents(ramp, segments).map { |intent| render_segment(intent: intent) }
27
32
  template.content_tag(:div, template.safe_join(slots), **html_options)
@@ -29,30 +34,103 @@ module StimulusPlumbers
29
34
 
30
35
  private
31
36
 
32
- def render_bar(value:, min: 0, max: 100, indeterminate: false, **kwargs)
33
- html_options = merge_html_options(
34
- theme.resolve(:progress_bar),
37
+ def validate_segments!(segments, format)
38
+ raise ArgumentError, "format: is not supported with segments:" unless format.nil?
39
+ raise ArgumentError, "segments must be a positive integer" unless segments.is_a?(Integer) && segments.positive?
40
+ end
41
+
42
+ def segmented_stimulus_data(current, min, max, mode, indeterminate)
43
+ progress_stimulus_data(
44
+ value: current,
45
+ min: min,
46
+ max: max,
47
+ variant: "segmented",
48
+ "progress-segment-mode-value": mode,
49
+ "progress-indeterminate-value": indeterminate
50
+ )
51
+ end
52
+
53
+ def render_bar(value:, min: 0, max: 100, indeterminate: false, format: nil, readout: :inside, **kwargs)
54
+ validate_format!(format)
55
+ validate_readout!(readout)
56
+ current = clamp(value, min, max)
57
+ text = value_text(format, current, min, max) unless indeterminate
58
+ html_options = bar_html_options(current, min, max, indeterminate, format, readout, text, kwargs)
59
+ template.content_tag(:div, bar_body(format, readout, text), **html_options)
60
+ end
61
+
62
+ def validate_readout!(readout)
63
+ return if Themes::Schema::Progress::Ranges::PLACEMENT.include?(readout&.to_sym)
64
+
65
+ raise ArgumentError, "readout must be one of #{Themes::Schema::Progress::Ranges::PLACEMENT.join(", ")}"
66
+ end
67
+
68
+ # The track clips its overflow, so an outside readout needs the root to be a wrapper.
69
+ def bar_body(format, readout, text)
70
+ return render_fill if format.nil?
71
+ return template.safe_join([render_fill, render_value(text, :inside)]) if readout.to_sym == :inside
72
+
73
+ template.safe_join([render_track, render_value(text, :outside)])
74
+ end
75
+
76
+ def render_track
77
+ template.content_tag(:div, render_fill, **merge_html_options(theme.resolve(:progress_bar, labelled: false)))
78
+ end
79
+
80
+ def bar_html_options(current, min, max, indeterminate, format, readout, text, kwargs)
81
+ # `percent` omits aria-valuetext — AT derives the percentage from aria-valuenow itself.
82
+ valuetext = text unless format&.to_sym == :percent
83
+ aria = progress_aria(value: current, min: min, max: max, indeterminate: indeterminate, valuetext: valuetext)
84
+ merge_html_options(
85
+ bar_root_theme(format, readout),
35
86
  kwargs,
36
- progress_stimulus_data(
37
- value: value, min: min, max: max, variant: "bar", "progress-indeterminate-value": indeterminate
38
- ),
39
- { role: "progressbar", aria: progress_aria(value: value, min: min, max: max, indeterminate: indeterminate) }
87
+ bar_stimulus_data(current, min, max, indeterminate, format),
88
+ { role: "progressbar", aria: aria }
89
+ )
90
+ end
91
+
92
+ def bar_root_theme(format, readout)
93
+ return theme.resolve(:progress_bar_group) if format && readout.to_sym == :outside
94
+
95
+ theme.resolve(:progress_bar, labelled: !format.nil?)
96
+ end
97
+
98
+ def bar_stimulus_data(current, min, max, indeterminate, format)
99
+ progress_stimulus_data(
100
+ value: current,
101
+ min: min,
102
+ max: max,
103
+ variant: "bar",
104
+ "progress-indeterminate-value": indeterminate,
105
+ **(format.nil? ? {} : { "progress-format-value": format })
106
+ )
107
+ end
108
+
109
+ # aria-hidden: the value reaches AT via aria-valuenow/aria-valuetext, not this span.
110
+ def render_value(text, placement)
111
+ key = placement == :outside ? :progress_bar_value_outside : :progress_bar_value
112
+ template.content_tag(
113
+ :span,
114
+ text,
115
+ **merge_html_options(
116
+ theme.resolve(key),
117
+ { data: { "progress-target": "value" }, aria: { hidden: true } }
118
+ )
40
119
  )
41
- template.content_tag(:div, render_fill, **html_options)
42
120
  end
43
121
 
44
122
  # `data-intent` is a theme-independent styling hook the theme colors via attribute variants.
45
- def render_fill(intent: nil)
123
+ def render_fill(intent: nil, key: :progress_bar_fill)
46
124
  data = { "progress-target": "fill" }
47
125
  data[:intent] = intent if intent
48
- template.content_tag(:div, nil, **merge_html_options(theme.resolve(:progress_bar_fill), { data: data }))
126
+ template.content_tag(:div, nil, **merge_html_options(theme.resolve(key), { data: data }))
49
127
  end
50
128
 
51
129
  # Slots are aria-hidden — the container owns the progressbar ARIA.
52
130
  def render_segment(intent: nil)
53
131
  template.content_tag(
54
132
  :div,
55
- render_fill(intent: intent),
133
+ render_fill(intent: intent, key: :progress_segment_fill),
56
134
  **merge_html_options(theme.resolve(:progress_segment), { aria: { hidden: true } })
57
135
  )
58
136
  end
@@ -12,13 +12,14 @@ module StimulusPlumbers
12
12
  private
13
13
 
14
14
  def render_ring(value:, max: 100, min: 0, indeterminate: false, size: nil, **kwargs)
15
+ current = clamp(value, min, max)
15
16
  icon_options = merge_html_options(
16
17
  theme.resolve(:progress_ring, size: size),
17
18
  kwargs,
18
19
  progress_stimulus_data(
19
- value: value, min: min, max: max, variant: "ring", "progress-indeterminate-value": indeterminate
20
+ value: current, min: min, max: max, variant: "ring", "progress-indeterminate-value": indeterminate
20
21
  ),
21
- { role: "progressbar", aria: progress_aria(value: value, min: min, max: max, indeterminate: indeterminate) }
22
+ { role: "progressbar", aria: progress_aria(value: current, min: min, max: max, indeterminate: indeterminate) }
22
23
  )
23
24
  Components::Icon.new(template).render("progress-ring", **icon_options)
24
25
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "action_view/version"
4
+ require "action_view/helpers/tags/translator"
4
5
 
5
6
  require_relative "../plumber/options/aria"
6
7
  require_relative "../plumber/options/html"
@@ -17,6 +18,8 @@ require_relative "fields/inputs/datetime"
17
18
  require_relative "fields/inputs/radio"
18
19
  require_relative "fields/inputs/file"
19
20
  require_relative "fields/inputs/password"
21
+ require_relative "fields/inputs/progress"
22
+ require_relative "fields/inputs/range"
20
23
  require_relative "fields/inputs/search"
21
24
  require_relative "fields/inputs/select"
22
25
  require_relative "fields/inputs/select/grouped"
@@ -39,6 +42,8 @@ module StimulusPlumbers
39
42
  include Fields::Inputs::Radio
40
43
  include Fields::Inputs::File
41
44
  include Fields::Inputs::Password
45
+ include Fields::Inputs::Progress
46
+ include Fields::Inputs::Range
42
47
  include Fields::Inputs::Search
43
48
  include Fields::Inputs::Select
44
49
  include Fields::Inputs::Select::Grouped
@@ -49,25 +54,36 @@ module StimulusPlumbers
49
54
  include Fields::Inputs::TextArea
50
55
 
51
56
  def field(attribute, as:, **options, &block)
52
- field_opts = options.slice(*Field::OPTIONS)
57
+ field_opts = field_options(attribute, options)
53
58
  input_opts = options.except(*Field::OPTIONS)
54
59
  render_field(as, attribute, field_opts, input_opts, &block)
55
60
  end
56
61
 
57
62
  def collection_field(attribute, as:, collection:, value_method:, text_method:, **options)
58
- field_opts = options.slice(*Field::OPTIONS)
63
+ field_opts = field_options(attribute, options)
59
64
  input_opts = options.except(*Field::OPTIONS)
60
65
  render_collection_field(as, attribute, field_opts, collection, value_method, text_method, input_opts)
61
66
  end
62
67
 
63
68
  def choice(attribute, as:, collection: nil, value_method: nil, text_method: nil, **options)
64
- field_opts = options.slice(*Field::OPTIONS)
69
+ field_opts = field_options(attribute, options)
65
70
  input_opts = options.except(*Field::OPTIONS)
66
71
  render_choice_field(as, attribute, field_opts, collection, value_method, text_method, input_opts)
67
72
  end
68
73
 
69
74
  private
70
75
 
76
+ def field_options(attribute, options)
77
+ options.slice(*Field::OPTIONS).tap { |opts| opts[:label] ||= default_label(attribute) }
78
+ end
79
+
80
+ # Mirrors f.label: helpers.label.* → human_attribute_name → humanize.
81
+ def default_label(attribute)
82
+ ActionView::Helpers::Tags::Translator.new(
83
+ object, @object_name.to_s, attribute.to_s, scope: "helpers.label"
84
+ ).translate.presence || attribute.to_s.humanize
85
+ end
86
+
71
87
  def theme
72
88
  StimulusPlumbers.config.theme.current
73
89
  end
@@ -77,7 +93,7 @@ module StimulusPlumbers
77
93
 
78
94
  validate_field_block!(as, block)
79
95
 
80
- field = Field.new(@template, **field_opts)
96
+ field = Field.new(@template, label_mode: Fields::Renderer.label_mode(as), **field_opts)
81
97
  field.render(object, attribute, input_id: field_id(attribute)) do |html_opts, opts, error|
82
98
  Plumber::Dispatcher.build(
83
99
  Fields::Renderer::FIELD.fetch(as),
@@ -13,26 +13,40 @@ module StimulusPlumbers
13
13
  COLLECTION_TYPES = %i[radio check_box collection_select grouped_collection_select].freeze
14
14
  OPTIONS = (Base::OPTIONS + %i[hide_label]).freeze
15
15
 
16
- attr_reader :hide_label
16
+ # :aria uses aria-labelledby because non-labelable components cannot use `<label for>`.
17
+ LABEL_MODES = %i[native aria].freeze
18
+
19
+ attr_reader :hide_label, :label_mode
17
20
 
18
21
  class << self
19
22
  def label_id(input_id)
20
23
  [input_id, "label"].compact.join("_")
21
24
  end
25
+
26
+ def validate_label_mode!(mode)
27
+ return mode if LABEL_MODES.include?(mode)
28
+
29
+ raise ArgumentError, "unknown label_mode: #{mode.inspect} (expected one of #{LABEL_MODES.join(", ")})"
30
+ end
22
31
  end
23
32
 
24
- def initialize(template, hide_label: false, **kwargs)
33
+ def initialize(template, hide_label: false, label_mode: :native, **kwargs)
25
34
  super(template, **kwargs)
26
35
  @hide_label = hide_label
36
+ @label_mode = self.class.validate_label_mode!(label_mode)
27
37
  end
28
38
 
29
39
  def label_hidden?
30
40
  @hide_label
31
41
  end
32
42
 
43
+ def native_label?
44
+ @label_mode == :native
45
+ end
46
+
33
47
  def render(object, attribute, input_id:, &block)
34
48
  @label ||= attribute.to_s.humanize
35
- case @floating
49
+ case native_label? ? @floating : nil
36
50
  when *StimulusPlumbers::Themes::Schema::Form::Floating::Ranges::TYPE
37
51
  render_floating_field(object, attribute, input_id, &block)
38
52
  else
@@ -42,6 +56,18 @@ module StimulusPlumbers
42
56
 
43
57
  private
44
58
 
59
+ def build_aria(object, attribute, input_id)
60
+ return super if native_label?
61
+
62
+ { describedby: described_by(object, attribute, input_id), labelledby: self.class.label_id(input_id) }.compact
63
+ end
64
+
65
+ def build_html_options(input_id, aria)
66
+ return super if native_label?
67
+
68
+ { id: input_id, aria: aria }
69
+ end
70
+
45
71
  def render_default_field(object, attribute, input_id, &block)
46
72
  error_override = error?(object, attribute)
47
73
  aria = build_aria(object, attribute, input_id)
@@ -59,13 +85,15 @@ module StimulusPlumbers
59
85
  end
60
86
  end
61
87
 
88
+ # hide_label is visual only; :aria fields cannot be required.
62
89
  def field_label(input_id)
63
90
  Fields::Label.new(@template).render(
64
91
  text: @label,
65
- for_id: input_id,
92
+ for_id: (input_id if native_label?),
66
93
  id: self.class.label_id(input_id),
67
- required: @required,
68
- hidden: @hide_label
94
+ required: native_label? && @required,
95
+ hidden: @hide_label,
96
+ tag: native_label? ? :label : :span
69
97
  )
70
98
  end
71
99