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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +8 -0
- data/app/assets/javascripts/stimulus-plumbers/controllers.manifest.json +11 -1
- data/app/assets/javascripts/stimulus-plumbers/index.es.js +196 -163
- 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/docs/component/form.md +49 -14
- data/docs/component/indicator.md +1 -1
- data/docs/component/progress.md +24 -9
- data/docs/guide.md +63 -4
- data/lib/stimulus_plumbers/components/progress/shared.rb +46 -3
- data/lib/stimulus_plumbers/components/progress_bar.rb +100 -22
- data/lib/stimulus_plumbers/components/progress_ring.rb +3 -2
- data/lib/stimulus_plumbers/form/builder.rb +20 -4
- data/lib/stimulus_plumbers/form/field.rb +34 -6
- data/lib/stimulus_plumbers/form/fields/inputs/progress.rb +30 -0
- data/lib/stimulus_plumbers/form/fields/inputs/range.rb +81 -0
- data/lib/stimulus_plumbers/form/fields/inputs/text.rb +1 -1
- data/lib/stimulus_plumbers/form/fields/renderer.rb +41 -0
- data/lib/stimulus_plumbers/helpers/progress_helper.rb +4 -2
- data/lib/stimulus_plumbers/themes/schema/progress/ranges.rb +13 -0
- data/lib/stimulus_plumbers/themes/schema.rb +17 -6
- data/lib/stimulus_plumbers/version.rb +1 -1
- data/vendor/ARIA.md +10 -0
- data/vendor/component/manifest.json +7 -2
- data/vendor/controller/docs/progress.md +69 -9
- data/vendor/controller/guide.md +61 -7
- data/vendor/controller/manifest.json +11 -1
- metadata +4 -1
data/docs/component/form.md
CHANGED
|
@@ -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
|
|
92
|
-
| ------------ | ------------------------------------- |
|
|
93
|
-
| `label` | String |
|
|
94
|
-
| `hint` | String | `nil`
|
|
95
|
-
| `error` | String / Array | `nil`
|
|
96
|
-
| `required` | Boolean | `false`
|
|
97
|
-
| `hide_label` | Boolean | `false`
|
|
98
|
-
| `layout` | `:stacked` / `:inline` | `:stacked`
|
|
99
|
-
| `floating` | `:filled` / `:outlined` / `:standard` | `nil`
|
|
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
|
data/docs/component/indicator.md
CHANGED
|
@@ -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
|
|
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
|
|
data/docs/component/progress.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Progress
|
|
2
2
|
|
|
3
|
-
Rails helpers for rendering the `progress`
|
|
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
|
|
14
|
-
| ---------------- |
|
|
15
|
-
| `value:` | —
|
|
16
|
-
| `min:` | `0`
|
|
17
|
-
| `max:` | `100`
|
|
18
|
-
| `indeterminate:` | `false`
|
|
19
|
-
|
|
|
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.
|
|
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
|
|
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
|
|
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](
|
|
23
|
-
`
|
|
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
|
|
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]
|
|
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(
|
|
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(:
|
|
26
|
+
theme.resolve(:progress_segment_group),
|
|
15
27
|
kwargs,
|
|
16
|
-
|
|
17
|
-
|
|
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
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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(
|
|
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:
|
|
20
|
+
value: current, min: min, max: max, variant: "ring", "progress-indeterminate-value": indeterminate
|
|
20
21
|
),
|
|
21
|
-
{ role: "progressbar", aria: progress_aria(value:
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|