stimulus_plumbers 0.4.15 → 0.4.17

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 (34) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +21 -0
  3. data/app/assets/javascripts/stimulus-plumbers/controllers.manifest.json +378 -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/combobox.md +3 -3
  9. data/docs/component/form.md +49 -14
  10. data/docs/component/indicator.md +1 -1
  11. data/docs/component/progress.md +24 -9
  12. data/docs/guide.md +63 -4
  13. data/lib/stimulus_plumbers/components/combobox/dropdown.rb +1 -1
  14. data/lib/stimulus_plumbers/components/combobox/time.rb +1 -1
  15. data/lib/stimulus_plumbers/components/combobox.rb +1 -1
  16. data/lib/stimulus_plumbers/components/progress/shared.rb +46 -3
  17. data/lib/stimulus_plumbers/components/progress_bar.rb +100 -22
  18. data/lib/stimulus_plumbers/components/progress_ring.rb +3 -2
  19. data/lib/stimulus_plumbers/form/builder.rb +20 -4
  20. data/lib/stimulus_plumbers/form/field.rb +34 -6
  21. data/lib/stimulus_plumbers/form/fields/inputs/progress.rb +30 -0
  22. data/lib/stimulus_plumbers/form/fields/inputs/range.rb +81 -0
  23. data/lib/stimulus_plumbers/form/fields/inputs/text.rb +1 -1
  24. data/lib/stimulus_plumbers/form/fields/renderer.rb +41 -0
  25. data/lib/stimulus_plumbers/helpers/progress_helper.rb +4 -2
  26. data/lib/stimulus_plumbers/themes/schema/progress/ranges.rb +13 -0
  27. data/lib/stimulus_plumbers/themes/schema.rb +17 -6
  28. data/lib/stimulus_plumbers/version.rb +1 -1
  29. data/vendor/ARIA.md +10 -0
  30. data/vendor/component/manifest.json +18 -6
  31. data/vendor/controller/docs/progress.md +69 -9
  32. data/vendor/controller/guide.md +61 -7
  33. data/vendor/controller/manifest.json +378 -1
  34. metadata +4 -1
@@ -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
 
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StimulusPlumbers
4
+ module Form
5
+ module Fields
6
+ module Inputs
7
+ module Progress
8
+ private
9
+
10
+ # A progressbar submits nothing and is never invalid — it reads its value from the model
11
+ # attribute, and `floating:` is dropped so it can't leak onto the element as an attribute.
12
+ def render_progress(attribute, html_opts, opts, _error, segments: nil, format: nil, readout: :inside, **kwargs)
13
+ html_options = merge_html_options(
14
+ theme.resolve(:form_field_input_progress), opts, html_opts, kwargs.except(:floating)
15
+ )
16
+ value = object.public_send(attribute)
17
+ component = Components::ProgressBar.new(@template)
18
+ if segments
19
+ raise ArgumentError, "format: is not supported with segments:" unless format.nil?
20
+
21
+ component.render_segmented(value: value, segments: segments, **html_options)
22
+ else
23
+ component.render(value: value, format: format, readout: readout, **html_options)
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StimulusPlumbers
4
+ module Form
5
+ module Fields
6
+ module Inputs
7
+ # A range is a track and a thumb, not a text box, so it takes none of the text input's
8
+ # chrome. With `format:` it grows a readout driven by the progress controller.
9
+ module Range
10
+ include Components::Progress::Shared
11
+
12
+ def range_field(attribute, **options)
13
+ super(attribute, merge_html_options(theme.resolve(:form_field_input_range), options.except(:floating)))
14
+ end
15
+
16
+ private
17
+
18
+ # `floating:` is dropped — a label can't sit inside a slider.
19
+ def render_range_input(attribute, html_opts, opts, _error, format: nil, min: 0, max: 100, **kwargs)
20
+ validate_format!(format)
21
+ current = clamp(numeric(object.public_send(attribute)), min, max)
22
+ input = range_input(attribute, current, min, max, format, html_opts, opts, kwargs.except(:floating))
23
+ return input if format.nil?
24
+
25
+ range_group(input, current, min, max, format)
26
+ end
27
+
28
+ # No readout to contain, so the input hosts the controller itself.
29
+ def range_input(attribute, current, min, max, format, html_opts, opts, kwargs)
30
+ wired = format.nil? ? range_stimulus_data(current, min, max, format) : { data: { "progress-target": "input" } }
31
+ html_options = merge_html_options(
32
+ theme.resolve(:form_field_input_range),
33
+ opts,
34
+ html_opts,
35
+ kwargs,
36
+ wired,
37
+ # The input carries the track gradient, so the fill percentage lands here, not on
38
+ # the wrapper. Server-rendered so the fill is right before the controller connects.
39
+ { min: min, max: max, style: "--sp-progress-percent: #{integral(percent(current, min, max).round(2))}" }
40
+ )
41
+ @template.range_field(@object_name, attribute, objectify_options(html_options))
42
+ end
43
+
44
+ # A Stimulus target must be a descendant of its controller element, so a readout
45
+ # forces a wrapper local to the input row.
46
+ def range_group(input, current, min, max, format)
47
+ html_options = merge_html_options(
48
+ theme.resolve(:form_field_input_range_group),
49
+ range_stimulus_data(current, min, max, format)
50
+ )
51
+ body = @template.safe_join([input, range_value(format, current, min, max)])
52
+ @template.content_tag(:div, body, **html_options)
53
+ end
54
+
55
+ # aria-hidden: the native input already announces its own value.
56
+ def range_value(format, current, min, max)
57
+ @template.content_tag(
58
+ :span,
59
+ value_text(format, current, min, max),
60
+ **merge_html_options(
61
+ theme.resolve(:form_field_input_range_value),
62
+ { data: { "progress-target": "value" }, aria: { hidden: true } }
63
+ )
64
+ )
65
+ end
66
+
67
+ def range_stimulus_data(current, min, max, format)
68
+ progress_stimulus_data(
69
+ value: current,
70
+ min: min,
71
+ max: max,
72
+ variant: "range",
73
+ action: "input->progress#refresh",
74
+ **(format.nil? ? {} : { "progress-format-value": format })
75
+ )
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -5,6 +5,7 @@ module StimulusPlumbers
5
5
  module Fields
6
6
  module Inputs
7
7
  module Text
8
+ # `range` is not here — see inputs/range.rb; it shares none of the text input chrome.
8
9
  TEXT_FIELD_METHODS = {
9
10
  text: :text_field,
10
11
  email: :email_field,
@@ -14,7 +15,6 @@ module StimulusPlumbers
14
15
  color: :color_field,
15
16
  month: :month_field,
16
17
  week: :week_field,
17
- range: :range_field,
18
18
  datetime_local: :datetime_local_field
19
19
  }.freeze
20
20
 
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../field"
4
+
3
5
  module StimulusPlumbers
4
6
  module Form
5
7
  module Fields
@@ -18,6 +20,7 @@ module StimulusPlumbers
18
20
  text_area: :render_text_area_input,
19
21
  file: :render_file_input,
20
22
  password: :render_password_input,
23
+ progress: :render_progress,
21
24
  code: :render_code_input,
22
25
  credit_card: :render_credit_card_input,
23
26
  date: :render_combobox_date,
@@ -26,6 +29,44 @@ module StimulusPlumbers
26
29
  search: :render_combobox_typeahead
27
30
  }.freeze
28
31
 
32
+ # Require every field type to declare its caption association.
33
+ LABEL_MODE = {
34
+ text: :native,
35
+ email: :native,
36
+ number: :native,
37
+ url: :native,
38
+ tel: :native,
39
+ color: :native,
40
+ month: :native,
41
+ week: :native,
42
+ range: :native,
43
+ datetime_local: :native,
44
+ text_area: :native,
45
+ file: :native,
46
+ password: :native,
47
+ progress: :aria,
48
+ code: :native,
49
+ credit_card: :native,
50
+ date: :native,
51
+ time: :native,
52
+ select: :native,
53
+ search: :native
54
+ }.freeze
55
+
56
+ missing = FIELD.keys - LABEL_MODE.keys
57
+ raise ArgumentError, "field types missing a label_mode: #{missing.join(", ")}" if missing.any?
58
+
59
+ extra = LABEL_MODE.keys - FIELD.keys
60
+ raise ArgumentError, "label_mode declared for unknown field types: #{extra.join(", ")}" if extra.any?
61
+
62
+ LABEL_MODE.each_value { |mode| Field.validate_label_mode!(mode) }
63
+
64
+ class << self
65
+ def label_mode(as)
66
+ LABEL_MODE.fetch(as)
67
+ end
68
+ end
69
+
29
70
  COLLECTION = {
30
71
  collection_select: :render_collection_combobox_dropdown,
31
72
  grouped_collection_select: :render_grouped_collection_combobox_dropdown
@@ -3,8 +3,10 @@
3
3
  module StimulusPlumbers
4
4
  module Helpers
5
5
  module ProgressHelper
6
- def sp_progress_bar(value:, min: 0, max: 100, indeterminate: false, **kwargs)
7
- Components::ProgressBar.new(self).render(value: value, min: min, max: max, indeterminate: indeterminate, **kwargs)
6
+ def sp_progress_bar(value:, min: 0, max: 100, indeterminate: false, format: nil, readout: :inside, **kwargs)
7
+ Components::ProgressBar.new(self).render(
8
+ value: value, min: min, max: max, indeterminate: indeterminate, format: format, readout: readout, **kwargs
9
+ )
8
10
  end
9
11
 
10
12
  def sp_progress_segmented(value:, segments:, min: 0, max: 100, mode: :discrete, indeterminate: false, ramp: nil, **kwargs)
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StimulusPlumbers
4
+ module Themes
5
+ module Schema
6
+ module Progress
7
+ module Ranges
8
+ PLACEMENT = %i[inside outside].freeze
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
@@ -11,6 +11,7 @@ require_relative "schema/form/ranges"
11
11
  require_relative "schema/form/checkbox/ranges"
12
12
  require_relative "schema/form/floating/ranges"
13
13
  require_relative "schema/form/radio/ranges"
14
+ require_relative "schema/progress/ranges"
14
15
  require_relative "schema/icon"
15
16
 
16
17
  module StimulusPlumbers
@@ -179,6 +180,10 @@ module StimulusPlumbers
179
180
  type: { default: :default, validate: Form::Radio::Ranges::TYPE },
180
181
  variant: { default: :tertiary, validate: Form::Radio::Ranges::VARIANT }
181
182
  }.freeze,
183
+ form_field_input_progress: {}.freeze,
184
+ form_field_input_range: {}.freeze,
185
+ form_field_input_range_group: {}.freeze,
186
+ form_field_input_range_value: {}.freeze,
182
187
  form_field_input_combobox: {
183
188
  error: { default: false, validate: Ranges::BOOL },
184
189
  floating: { default: nil, validate: [nil, *Form::Floating::Ranges::TYPE] }
@@ -255,13 +260,19 @@ module StimulusPlumbers
255
260
  popover: {}.freeze
256
261
  }.freeze
257
262
 
263
+ # The outside readout gets its own keys, not parameters on existing ones — a theme method
264
+ # written before them has no keyword to accept.
258
265
  PROGRESS = {
259
- progress_bar: {}.freeze,
260
- progress_bar_fill: {}.freeze,
261
- progress_segmented: {}.freeze,
262
- progress_segment: {}.freeze,
263
- progress_ring: { size: { default: nil, validate: %i[sm md lg] } }.freeze,
264
- progress_meter: {}.freeze
266
+ progress_bar: { labelled: { default: false, validate: Ranges::BOOL } }.freeze,
267
+ progress_bar_group: {}.freeze,
268
+ progress_bar_fill: {}.freeze,
269
+ progress_bar_value: {}.freeze,
270
+ progress_bar_value_outside: {}.freeze,
271
+ progress_segment_group: {}.freeze,
272
+ progress_segment: {}.freeze,
273
+ progress_segment_fill: {}.freeze,
274
+ progress_ring: { size: { default: nil, validate: %i[sm md lg] } }.freeze,
275
+ progress_meter: {}.freeze
265
276
  }.freeze
266
277
 
267
278
  TIMELINE = {
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module StimulusPlumbers
4
- VERSION = "0.4.15"
4
+ VERSION = "0.4.17"
5
5
  end
data/vendor/ARIA.md CHANGED
@@ -97,6 +97,16 @@ Two helper classes handle keyboard navigation in controllers — see [`stimulus-
97
97
  - Accepted tradeoff: because `indeterminate` has no HTML attribute, the server can only render the master's initial `checked` state for the all-true case; every other case (including mixed) renders unchecked and is corrected to `indeterminate` once the `checklist` controller connects — a brief, accepted flash for the mixed case only.
98
98
  - Disabled (readonly) items are excluded from the master's aggregate and from bulk toggling — the `checklist` controller filters them out via their own `.disabled` property, mirroring their exclusion from tab order and AT interaction.
99
99
 
100
+ #### Progress (`progress_controller`, `sp_progress_*`)
101
+ - `role="progressbar"` is read-only and never focusable — it reports a value, it does not accept one. An interactive equivalent is a native `<input type="range">` (or `role="slider"`), not a progressbar with `tabindex`
102
+ - Value: `aria-valuemin`/`aria-valuemax` always; `aria-valuenow` omitted while indeterminate (an omitted `valuenow` is what signals "unknown progress" to AT)
103
+ - `aria-valuetext` only when the readout text is not derivable from `aria-valuenow` — set for the `value`/`value_max` formats, deliberately **not** for `percent`, where AT already computes the percentage and a duplicate would be announced twice
104
+ - On-screen readout is `aria-hidden="true"` — the value reaches AT through `aria-valuenow`/`aria-valuetext`, so exposing the span too would double-announce it
105
+ - Name: `aria-label` standalone, or `aria-labelledby` → a visible caption. `<label for>` cannot name a progressbar — `for=` is only valid against a labelable element (`button`, `input`, `meter`, `output`, `progress`, `select`, `textarea`), so a `<div role="progressbar">` targeted by one is silently left unnamed (WCAG 4.1.2). Form fields rendering a progressbar therefore emit a `<span>` caption, not a `<label>`
106
+ - No `aria-invalid`/`aria-required` on a progressbar — neither is supported on the role, and it submits nothing that could be invalid. Errors still attach via `aria-describedby`
107
+ - Segment slots are decorative (`aria-hidden="true"`) — the value is announced once, by the container
108
+ - The `range` variant drives a native `<input type="range">` and writes **no** ARIA: the native control already exposes slider role, value, and keyboard operation, and duplicating them announces worse than leaving them alone. It keeps an ordinary `<label for>` because an `input` is labelable
109
+
100
110
  #### Avatar / Card / Icon
101
111
  - Decorative images/icons: `aria-hidden="true"` or `alt=""`
102
112
  - Meaningful images: descriptive `alt` text
@@ -66,7 +66,11 @@
66
66
  "values": []
67
67
  },
68
68
  "combobox-date": {
69
- "actions": [],
69
+ "actions": [
70
+ "onDaySelect",
71
+ "onMonthSelect",
72
+ "onYearSelect"
73
+ ],
70
74
  "listens": [
71
75
  "selected"
72
76
  ],
@@ -76,7 +80,7 @@
76
80
  "combobox-dropdown": {
77
81
  "actions": [
78
82
  "onNavigate",
79
- "select"
83
+ "onSelect"
80
84
  ],
81
85
  "listens": [
82
86
  "selected"
@@ -87,7 +91,7 @@
87
91
  "combobox-time": {
88
92
  "actions": [
89
93
  "onNavigate",
90
- "select"
94
+ "onSelect"
91
95
  ],
92
96
  "listens": [
93
97
  "selected"
@@ -117,7 +121,8 @@
117
121
  },
118
122
  "input-combobox": {
119
123
  "actions": [
120
- "onInput"
124
+ "onInput",
125
+ "onSelect"
121
126
  ],
122
127
  "listens": [
123
128
  "changed"
@@ -128,6 +133,7 @@
128
133
  "input-formatter": {
129
134
  "actions": [
130
135
  "onBlur",
136
+ "onChange",
131
137
  "onFocus",
132
138
  "onInput"
133
139
  ],
@@ -166,6 +172,7 @@
166
172
  "popover": {
167
173
  "actions": [
168
174
  "close",
175
+ "closeOnSelect",
169
176
  "open",
170
177
  "toggle"
171
178
  ],
@@ -174,14 +181,19 @@
174
181
  "values": []
175
182
  },
176
183
  "progress": {
177
- "actions": [],
184
+ "actions": [
185
+ "refresh"
186
+ ],
178
187
  "listens": [],
179
188
  "targets": [
180
189
  "fill",
181
- "meter"
190
+ "input",
191
+ "meter",
192
+ "value"
182
193
  ],
183
194
  "values": [
184
195
  "current",
196
+ "format",
185
197
  "high",
186
198
  "indeterminate",
187
199
  "low",
@@ -1,6 +1,6 @@
1
1
  # Progress
2
2
 
3
- Value-driven progress indicator supporting four render variants: a linear bar, a segmented bar, an SVG ring, and a native `<meter>`.
3
+ Value-driven progress indicator supporting five render variants: a linear bar, a segmented bar, an SVG ring, a native `<meter>`, and a native `<input type="range">`.
4
4
 
5
5
  ## Stimulus Identifier
6
6
 
@@ -12,12 +12,14 @@ Value-driven progress indicator supporting four render variants: a linear bar, a
12
12
  | ------- | --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
13
13
  | `fill` | `<div>` (bar) / `<circle>` (ring) | Element whose `width` (bar) or `stroke-dasharray`/`stroke-dashoffset` (ring) is set. Segmented renders **one `fill` per segment**; the controller distributes the value across them |
14
14
  | `meter` | `<meter>` | Present only for `variant: "meter"` — native element, attributes synced directly |
15
+ | `value` | `<span>` | Optional on-screen readout; its `textContent` is set from `format`. Bar and range variants |
16
+ | `input` | `<input type="range">` | Range-only, and only when a readout is present — the controller then sits on a wrapper containing both |
15
17
 
16
18
  ## Values
17
19
 
18
20
  | Name | Type | Default | Purpose |
19
21
  | ----------------------- | ------- | ------------ | -------------------------------------------------------------------------------------------------------------------------------------- |
20
- | `variant` | String | `"bar"` | `"bar"` \| `"segmented"` \| `"ring"` \| `"meter"` |
22
+ | `variant` | String | `"bar"` | `"bar"` \| `"segmented"` \| `"ring"` \| `"meter"` \| `"range"` |
21
23
  | `current` | Number | `0` | Current value |
22
24
  | `min` | Number | `0` | Range minimum |
23
25
  | `max` | Number | `100` | Range maximum |
@@ -27,19 +29,37 @@ Value-driven progress indicator supporting four render variants: a linear bar, a
27
29
  | `indeterminate` | Boolean | `false` | Suppresses `aria-valuenow`; toggles the `sp-progress-indeterminate` class |
28
30
  | `indeterminateFraction` | Number | `0.25` | Bar width / ring arc / segment chunk-width fraction rendered while indeterminate |
29
31
  | `segmentMode` | String | `"discrete"` | Segmented-only. `"discrete"` lights a whole segment once progress reaches into it; `"continuous"` partially fills the boundary segment |
32
+ | `format` | String | `""` | Readout text for the `value` target: `"percent"` \| `"value"` \| `"value_max"`. Empty or unrecognized renders nothing |
33
+
34
+ ### Readout formats
35
+
36
+ | `format` | Renders | `aria-valuetext` |
37
+ | ------------- | --------- | ---------------- |
38
+ | `"percent"` | `45%` | not set |
39
+ | `"value"` | `45` | `45` |
40
+ | `"value_max"` | `45 / 60` | `45 / 60` |
41
+
42
+ `percent` omits `aria-valuetext` — assistive technology derives the percentage from `aria-valuenow` itself, so setting it would only duplicate what AT already announces. The readout element is decorative (`aria-hidden`); the value reaches AT through `aria-valuenow`/`aria-valuetext`.
43
+
44
+ The `range` variant writes no `aria-value*` at all — a native `<input type="range">` already exposes its own slider semantics — and sets `--sp-progress-percent` on the input so a theme can paint the filled portion of the track.
45
+
46
+ The `bar` variant sets `--sp-progress-percent` too, on the controller element, alongside the fill target's width. A theme can use it to split the readout's color at the fill edge. It is unset until the controller connects, so read it as `var(--sp-progress-percent, 0)` to match the server-rendered (empty) fill.
47
+
48
+ The rendered number is the value clamped to `[min, max]`, so an out-of-range `current` reads as the nearest bound rather than an impossible percentage. An empty or inverted range (`max <= min`) renders `0%`. While `indeterminate`, the readout is blank and `aria-valuetext` is removed.
30
49
 
31
50
  ## Methods
32
51
 
33
- | Method | Wired via | Purpose |
34
- | ---------------------------- | ----------------------- | --------------------------------------------------------------------------------------------------------- |
35
- | `setValue(value)` | — | Programmatic API — clamps to `[min, max]`, updates `currentValue`, dispatches `progress:changed` |
36
- | `currentValueChanged(value)` | Stimulus value callback | Recalculates fill/meter attrs whenever `current` changes (covers `setValue()` and direct attribute edits) |
52
+ | Method | Wired via | Purpose |
53
+ | ---------------------------- | ------------------------- | --------------------------------------------------------------------------------------------------------- |
54
+ | `setValue(value)` | — | Programmatic API — clamps to `[min, max]`, updates `currentValue`, dispatches `progress:changed` |
55
+ | `currentValueChanged(value)` | Stimulus value callback | Recalculates fill/meter attrs whenever `current` changes (covers `setValue()` and direct attribute edits) |
56
+ | `refresh()` | `input->progress#refresh` | Range-only. Reads the native input's value and passes it to `setValue()` |
37
57
 
38
58
  ## Dispatches
39
59
 
40
- | Event | Detail | When |
41
- | ------------------ | --------------------- | ------------------------------------ |
42
- | `progress:changed` | `{ value, min, max }` | After `setValue()` updates the value |
60
+ | Event | Detail | When |
61
+ | ------------------ | --------------------- | -------------------------------------------------------------------------------------- |
62
+ | `progress:changed` | `{ value, min, max }` | After `setValue()` updates the value — including a range drag, which routes through it |
43
63
 
44
64
  ## Example HTML
45
65
 
@@ -55,6 +75,18 @@ Value-driven progress indicator supporting four render variants: a linear bar, a
55
75
  <div data-progress-target="fill"></div>
56
76
  </div>
57
77
 
78
+ <!-- Bar with an on-screen readout -->
79
+ <div
80
+ role="progressbar"
81
+ data-controller="progress"
82
+ data-progress-current-value="45"
83
+ data-progress-max-value="100"
84
+ data-progress-format-value="percent"
85
+ >
86
+ <div data-progress-target="fill"></div>
87
+ <span data-progress-target="value" aria-hidden="true">45%</span>
88
+ </div>
89
+
58
90
  <!-- Segmented — one fill per segment; number of segments = number of fill targets -->
59
91
  <div
60
92
  role="progressbar"
@@ -92,6 +124,34 @@ Value-driven progress indicator supporting four render variants: a linear bar, a
92
124
  data-progress-max-value="100"
93
125
  ></meter>
94
126
 
127
+ <!-- Range — no readout, so the controller sits on the input itself -->
128
+ <input
129
+ type="range"
130
+ min="0"
131
+ max="100"
132
+ value="45"
133
+ style="--sp-progress-percent: 45"
134
+ data-controller="progress"
135
+ data-progress-variant-value="range"
136
+ data-progress-current-value="45"
137
+ data-progress-min-value="0"
138
+ data-progress-max-value="100"
139
+ data-action="input->progress#refresh"
140
+ />
141
+
142
+ <!-- Range with a readout — a target must be a descendant, so a wrapper hosts the controller -->
143
+ <div
144
+ data-controller="progress"
145
+ data-progress-variant-value="range"
146
+ data-progress-current-value="45"
147
+ data-progress-max-value="100"
148
+ data-progress-format-value="percent"
149
+ data-action="input->progress#refresh"
150
+ >
151
+ <input type="range" min="0" max="100" value="45" style="--sp-progress-percent: 45" data-progress-target="input" />
152
+ <span data-progress-target="value" aria-hidden="true">45%</span>
153
+ </div>
154
+
95
155
  <!-- Indeterminate -->
96
156
  <div role="progressbar" data-controller="progress" data-progress-indeterminate-value="true">
97
157
  <div data-progress-target="fill"></div>
@@ -3,14 +3,68 @@
3
3
  For a non-Rails / plain JS consumer of `@stimulus-plumbers/controllers`. Rails apps get this wired
4
4
  automatically via the `stimulus_plumbers` gem's `sp_*` helpers — skip this guide for Rails.
5
5
 
6
+ ## Install
7
+
6
8
  ```bash
7
- npm install @stimulus-plumbers/controllers
9
+ npm install @hotwired/stimulus @stimulus-plumbers/controllers
10
+ ```
11
+
12
+ ## Register
13
+
14
+ Every controller is a named export. Import the ones you use and register each under its identifier:
15
+
16
+ ```javascript
17
+ import { Application } from '@hotwired/stimulus';
18
+ import { PopoverController, ProgressController, ComboboxDateController } from '@stimulus-plumbers/controllers';
19
+
20
+ const application = Application.start();
21
+
22
+ application.register('popover', PopoverController);
23
+ application.register('progress', ProgressController);
24
+ application.register('combobox-date', ComboboxDateController);
25
+ ```
26
+
27
+ The export name is the identifier in PascalCase plus `Controller` — `combobox-date` →
28
+ `ComboboxDateController`. For the full identifier list ask `list_controllers` / read
29
+ `controller://index`; each one's targets, values, classes, outlets, and events are in
30
+ `get_controller_schema(name: identifier)` — `name:` is the identifier (`combobox-date`), not the
31
+ export name. Narrative docs come from `get_controller_docs(name: family)`, which takes the family
32
+ (`combobox`) rather than the identifier; `list_controller_docs` lists the families. Outside MCP, the
33
+ [Controllers table](https://github.com/ryancyq/stimulus-plumbers/blob/main/stimulus-plumbers/README.md#controllers)
34
+ lists the same identifiers.
35
+
36
+ Registering a controller the page never uses is harmless — Stimulus only instantiates on a matching
37
+ `data-controller`.
38
+
39
+ ## Wire the markup
40
+
41
+ Interactive components (combobox, popover, calendar) expect their `data-controller`, target, and
42
+ value attributes to already be present in the rendered HTML. Rails apps get these from `sp_*`
43
+ helpers; a plain-JS setup writes them by hand, following the HTML structure in each controller's
44
+ doc:
45
+
46
+ ```html
47
+ <div
48
+ role="progressbar"
49
+ data-controller="progress"
50
+ data-progress-current-value="30"
51
+ data-progress-min-value="0"
52
+ data-progress-max-value="100"
53
+ >
54
+ <div data-progress-target="fill"></div>
55
+ </div>
8
56
  ```
9
57
 
10
- Import and register each controller you use with your Stimulus application — see
11
- [README.md](../README.md#setup) for the full import + `application.register(...)` list and the
12
- [Controllers table](../README.md#controllers) for identifiers and their docs.
58
+ Markup shape is per-controller take the authoritative structure from `get_controller_docs(name:)`
59
+ rather than adapting this example.
60
+
61
+ ## Styling
62
+
63
+ Controllers ship no CSS; they toggle classes and attributes only. Bring your own styles, or use the
64
+ `stimulus_plumbers_tailwind` gem's token set (`guide://tailwind`).
65
+
66
+ ## Accessibility
13
67
 
14
- Interactive components (combobox, popover, calendar) expect their `data-controller` attributes to
15
- already be present in the rendered HTML Rails apps get these from `sp_*` helpers; a plain-JS setup
16
- must add them manually per each controller's doc.
68
+ Keyboard, focus, and ARIA behaviour per component is in `aria://reference` read it before
69
+ hand-writing markup, since the controllers assume the documented roles and relationships are
70
+ present.