stimulus_plumbers 0.4.4 → 0.4.5

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.
@@ -0,0 +1,254 @@
1
+ # Combobox
2
+
3
+ Rails helpers that render fully-wired combobox components. Each variant shares the same wrapper structure (`input-combobox` + `input-formatter`) and differs only in its popover body.
4
+
5
+ See [docs/component/combobox.md](../../stimulus-plumbers/docs/component/combobox.md) in the JS package for the underlying controller API.
6
+
7
+ ## Helpers
8
+
9
+ ### `sp_combobox`
10
+
11
+ Single entry point. The panel type is chosen by a method call on the yielded builder
12
+ (`c.dropdown`, `c.typeahead`, `c.date`, `c.time`) — the panel owns its `aria-haspopup`,
13
+ popup id, trigger icon, and wrapper data. The `sp_combobox_*` helpers below are thin
14
+ wrappers over this.
15
+
16
+ ```erb
17
+ <%= sp_combobox(value: "us", label: "Country") do |c|
18
+ c.dropdown(options: [["United States", "us"], ["Canada", "ca"]], value: "us")
19
+ end %>
20
+
21
+ <%= sp_combobox(label: "Meeting time") do |c|
22
+ c.time(format: :h24, step: 15)
23
+ end %>
24
+ ```
25
+
26
+ | Option | Description |
27
+ | ----------------- | ------------------------------------------------ |
28
+ | `value` | Initial value (hidden input + trigger) |
29
+ | `label` | `aria-label` on the trigger input |
30
+ | `id` | Trigger id (defaults to a generated `sp_dom_id`) |
31
+ | `close_on_select` | `false` keeps the panel open after a selection |
32
+ | `**html_options` | Forwarded to the wrapper `div` |
33
+
34
+ Builder methods: `c.dropdown(options:, value:, label:)`, `c.typeahead(options:, value:, label:, url:)`,
35
+ `c.date(value:)`, `c.time(format:, step:, value:)`.
36
+
37
+ ---
38
+
39
+ ### `sp_combobox_date`
40
+
41
+ Date picker backed by a calendar grid.
42
+
43
+ ```erb
44
+ <%= sp_combobox_date %>
45
+ <%= sp_combobox_date(value: "2024-03-15", label: "Date of birth", class: "w-full") %>
46
+ ```
47
+
48
+ | Option | Description |
49
+ | ---------------- | ----------------------------------------------------------- |
50
+ | `value` | ISO 8601 date string — pre-fills and navigates the calendar |
51
+ | `label` | `aria-label` on the trigger input |
52
+ | `**html_options` | Forwarded to the wrapper `div` |
53
+
54
+ ---
55
+
56
+ ### `sp_combobox_dropdown`
57
+
58
+ Read-only combobox with a static listbox popover.
59
+
60
+ ```erb
61
+ <%= sp_combobox_dropdown(options: [["United States", "us"], ["Canada", "ca"]], value: "us") %>
62
+ ```
63
+
64
+ **Option formats:**
65
+
66
+ ```ruby
67
+ # Flat
68
+ [["Label", "value"], ...]
69
+
70
+ # Grouped
71
+ [{ label: "Americas", options: [["United States", "us"], ...] }, ...]
72
+
73
+ # With description or disabled flag
74
+ [["United States", "us", { description: "North America" }], ...]
75
+ [["Unavailable", "x", { disabled: true }], ...]
76
+ ```
77
+
78
+ | Option | Description |
79
+ | ---------------- | ------------------------------------------------------- |
80
+ | `options` | Option rows (see formats above) |
81
+ | `value` | Pre-selected value |
82
+ | `label` | `aria-label` on the trigger input and the listbox panel |
83
+ | `**html_options` | Forwarded to the wrapper `div` |
84
+
85
+ ---
86
+
87
+ ### `sp_combobox_typeahead`
88
+
89
+ Editable trigger that filters options as the user types. Supports client-side fuzzy matching and server-side fetch.
90
+
91
+ ```erb
92
+ <%# Client-side fuzzy filter %>
93
+ <%= sp_combobox_typeahead(options: [["London", "london"], ["Paris", "paris"]]) %>
94
+
95
+ <%# Server-side — receives ?q=<query>, must return <li role="option"> HTML fragments %>
96
+ <%= sp_combobox_typeahead(url: cities_path, label: "City") %>
97
+ ```
98
+
99
+ | Option | Description |
100
+ | ---------------- | ---------------------------------------------------------------- |
101
+ | `options` | Initial options rendered on page load (same formats as dropdown) |
102
+ | `value` | Pre-selected value |
103
+ | `url` | URL for server-side filtering |
104
+ | `label` | `aria-label` on the trigger input and the listbox panel |
105
+ | `**html_options` | Forwarded to the wrapper `div` |
106
+
107
+ ---
108
+
109
+ ### `sp_combobox_time`
110
+
111
+ iOS-style drum/scroll-wheel time picker.
112
+
113
+ ```erb
114
+ <%= sp_combobox_time %>
115
+ <%= sp_combobox_time(format: :h24, step: 15, value: "14:30", label: "Meeting time") %>
116
+ ```
117
+
118
+ | Option | Default | Description |
119
+ | ---------------- | ------- | --------------------------------------------- |
120
+ | `format` | `:h12` | `:h12` (1–12 + AM/PM) or `:h24` (00–23) |
121
+ | `step` | `1` | Minute increment — `15` yields 00, 15, 30, 45 |
122
+ | `value` | `nil` | Pre-selected time as `"HH:MM"` |
123
+ | `label` | `nil` | `aria-label` on the trigger input |
124
+ | `**html_options` | — | Forwarded to the wrapper `div` |
125
+
126
+ ---
127
+
128
+ ## Form Builder
129
+
130
+ `StimulusPlumbers::Form::Builder` exposes combobox-backed fields through purpose-specific methods rather than a single generic helper. See [form_builder.md](form_builder.md) for the full API.
131
+
132
+ ```erb
133
+ <%= form_with model: @user, builder: StimulusPlumbers::Form::Builder do |f| %>
134
+ <%= f.date_field :birthday %>
135
+ <%= f.time_field :meeting_time, format: :h24, step: 15 %>
136
+ <%= f.select :country, country_options %>
137
+ <%= f.search_field :city, url: cities_path %>
138
+ <% end %>
139
+ ```
140
+
141
+ All form field methods accept `label:`, `hint:`, `error:`, `required:`, and `hide_label:` in addition to their own options.
142
+
143
+ ---
144
+
145
+ ## Rendered HTML Structure
146
+
147
+ All variants share the same wrapper pattern. The `popover` controller owns panel
148
+ visibility, `aria-expanded`, outside-click dismissal, and focus; `input-combobox`
149
+ owns value/selection/filtering; `input-formatter` formats the displayed value.
150
+
151
+ ```html
152
+ <div
153
+ data-controller="popover input-combobox input-formatter"
154
+ data-action="input-combobox:changed->input-formatter#format"
155
+ data-input-combobox-value-value="[initial-value]"
156
+ >
157
+ <input
158
+ type="text"
159
+ role="combobox"
160
+ aria-haspopup="dialog|listbox"
161
+ aria-expanded="false"
162
+ aria-controls="[popup-id]"
163
+ data-popover-target="trigger"
164
+ data-input-combobox-target="trigger"
165
+ data-input-formatter-target="input"
166
+ data-action="focus->popover#open keydown.esc->popover#close"
167
+ />
168
+
169
+ <input type="hidden" name="[name]" data-input-combobox-target="input" />
170
+
171
+ <!-- variant-specific popover body (see below) -->
172
+ </div>
173
+ ```
174
+
175
+ `aria-haspopup` is `"listbox"` for dropdown/typeahead, `"dialog"` for date/time. The
176
+ `[popup-id]` is `[id]_popover` for dropdown/date/time and `[id]_popover_listbox` for typeahead.
177
+
178
+ Pass `close_on_select: false` to any `sp_combobox_*` helper to keep the panel open
179
+ after a selection (renders `data-popover-close-on-select-value="false"` on the wrapper).
180
+
181
+ ### Popover body by variant
182
+
183
+ Each variant builds its own panel root via `Popover::Builder#build_panel` (see
184
+ [popover.md](popover.md)), adding its controller and role to the panel wiring. The
185
+ trigger's `aria-controls` points at the popup — the panel for dropdown/date/time, the
186
+ nested `<ul role="listbox">` for typeahead.
187
+
188
+ **date / time** — the panel IS the `role="dialog"` element and hosts the picker controller:
189
+
190
+ ```html
191
+ <div
192
+ id="[id]_popover"
193
+ role="dialog"
194
+ aria-label="[label]"
195
+ hidden
196
+ data-popover-target="panel"
197
+ data-controller="combobox-date"
198
+ data-action="combobox-date:selected->input-combobox#onSelect combobox-date:selected->popover#closeOnSelect ..."
199
+ >
200
+ <!-- calendar grid (date) or drum columns (time) -->
201
+ </div>
202
+ ```
203
+
204
+ **dropdown** — the panel IS the `<ul role="listbox">`; options are its only children:
205
+
206
+ ```html
207
+ <ul
208
+ id="[id]_popover"
209
+ hidden
210
+ role="listbox"
211
+ aria-label="[label]"
212
+ data-popover-target="panel"
213
+ data-controller="combobox-dropdown"
214
+ data-action="click->combobox-dropdown#select keydown->combobox-dropdown#onNavigate combobox-dropdown:selected->input-combobox#onSelect combobox-dropdown:selected->popover#closeOnSelect"
215
+ data-combobox-dropdown-target="listbox"
216
+ >
217
+ <li role="option" data-value="us" aria-selected="false">United States</li>
218
+ <li role="option" data-value="ca" aria-selected="false">Canada</li>
219
+ </ul>
220
+ ```
221
+
222
+ **typeahead** — the panel is a wrapper holding the controller. The `<ul role="listbox">`
223
+ holds only options; the `loading`/`empty` status regions are siblings beside it, since
224
+ `role="listbox"` permits only `option`/`group` children. Status regions stay
225
+ non-focusable (the popover focuses the first focusable element in the panel on open).
226
+
227
+ ```html
228
+ <div
229
+ id="[id]_popover"
230
+ hidden
231
+ data-popover-target="panel"
232
+ data-controller="combobox-dropdown"
233
+ data-action="click->combobox-dropdown#select keydown->combobox-dropdown#onNavigate combobox-dropdown:selected->input-combobox#onSelect combobox-dropdown:selected->popover#closeOnSelect"
234
+ data-combobox-dropdown-url-value="[url]"
235
+ >
236
+ <ul
237
+ id="[id]_popover_listbox"
238
+ role="listbox"
239
+ aria-labelledby="[label-id]"
240
+ data-combobox-dropdown-target="listbox"
241
+ >
242
+ <li role="option" ...>Paris</li>
243
+ </ul>
244
+ <div hidden aria-live="polite" data-combobox-dropdown-target="loading">
245
+ <!-- spinner -->
246
+ </div>
247
+ <div hidden role="status" data-combobox-dropdown-target="empty">
248
+ No results
249
+ </div>
250
+ </div>
251
+ ```
252
+
253
+ The remote `url` endpoint returns options-only HTML — it replaces the listbox's
254
+ `innerHTML`, while the `loading`/`empty` siblings persist.
@@ -0,0 +1,76 @@
1
+ # Plumber::Dispatcher
2
+
3
+ Strategy factory that dispatches a callable against a target object. Used internally by `Form::Builder` to route `f.field` calls to the correct input renderer.
4
+
5
+ ## Factory
6
+
7
+ ```ruby
8
+ Dispatcher.build(callable, *args, **kwargs, &block)
9
+ ```
10
+
11
+ | `callable` type | Strategy | Behaviour |
12
+ | ------------------ | -------------- | ------------------------------------------------------------ |
13
+ | `Symbol` | `MethodCall` | Calls the named method on `target` |
14
+ | `Proc` | `InstanceExec` | Executes the proc via `instance_exec` on `target` |
15
+ | `Module` / `Class` | `KlassProxy` | Instantiates the class, calls `method_name:` on the instance |
16
+ | `String` | `KlassProxy` | Resolved via `safe_constantize`, then same as `Module` |
17
+
18
+ All dispatchers share one interface: `dispatcher.call(target)`.
19
+
20
+ ## Strategies
21
+
22
+ ### MethodCall
23
+
24
+ Calls a named method on `target`. Positional args beyond the method's arity are dropped; kwargs are forwarded only when the method declares keyword parameters. Private methods are reachable.
25
+
26
+ ```ruby
27
+ Dispatcher.build(:render_input, attribute).call(form_builder)
28
+ Dispatcher.build(:render_input, attribute, html_opts, as: :text).call(form_builder)
29
+
30
+ # block forwarded to the method
31
+ Dispatcher.build(:render_input, attribute) { |html| tag.div(html) }.call(form_builder)
32
+ ```
33
+
34
+ ### InstanceExec
35
+
36
+ Executes a `Proc` via `instance_exec` on `target`. The proc body has full access to `target`'s instance methods and instance variables.
37
+
38
+ ```ruby
39
+ renderer = proc { |attribute, opts| @template.text_field(@object_name, attribute, opts) }
40
+ Dispatcher.build(renderer, attribute, html_opts).call(form_builder)
41
+ ```
42
+
43
+ The `Proc` itself is the block — any `&block` passed to `build` is ignored. To compose a secondary callable, pass it as a positional argument:
44
+
45
+ ```ruby
46
+ wrapper = ->(html) { tag.div(html, class: "wrapper") }
47
+ renderer = proc { |attribute, opts, wrap| wrap.call(@template.text_field(@object_name, attribute, opts)) }
48
+ Dispatcher.build(renderer, attribute, html_opts, wrapper).call(form_builder)
49
+ ```
50
+
51
+ ### KlassProxy
52
+
53
+ Instantiates `callable` with `init_args:` / `init_kwargs:`, then calls `method_name:` on the instance. `target` is ignored. A `String` callable is resolved via `safe_constantize` at build time.
54
+
55
+ ```ruby
56
+ Dispatcher.build(
57
+ MyRenderer,
58
+ attribute, html_opts,
59
+ method_name: :render,
60
+ init_args: [@template]
61
+ ).call(anything)
62
+
63
+ # block forwarded to the method
64
+ Dispatcher.build(
65
+ MyRenderer,
66
+ attribute,
67
+ method_name: :render,
68
+ init_args: [@template]
69
+ ) { |html| tag.div(html) }.call(anything)
70
+ ```
71
+
72
+ ## Block forwarding
73
+
74
+ `MethodCall` and `KlassProxy` capture a block at build time and forward it to the dispatched method. `dispatcher.block` returns `nil` when no block is given.
75
+
76
+ `InstanceExec` does not participate — the `Proc` is the block. Compose further callables as positional arguments instead.
@@ -0,0 +1,47 @@
1
+ # Divider
2
+
3
+ Rails helper for rendering a horizontal rule, optionally labelled.
4
+
5
+ ## Helper
6
+
7
+ ### `sp_divider`
8
+
9
+ ```erb
10
+ <%= sp_divider %>
11
+ <%= sp_divider "or" %>
12
+ <%= sp_divider "Section title", class: "my-4" %>
13
+ ```
14
+
15
+ | Option | Default | Description |
16
+ | ---------------- | ------- | --------------------------------------------------------------------------- |
17
+ | `label` | `nil` | Positional arg. When present, the label is centred between two `<hr>` lines |
18
+ | `**html_options` | — | Forwarded to the outer `<div role="separator">` |
19
+
20
+ ---
21
+
22
+ ## Rendered HTML Structure
23
+
24
+ ### Without label
25
+
26
+ ```html
27
+ <div role="separator" class="[divider theme classes]">
28
+ <hr class="[divider_separator theme classes]" />
29
+ </div>
30
+ ```
31
+
32
+ ### With label
33
+
34
+ ```html
35
+ <div role="separator" class="[divider theme classes]">
36
+ <hr class="[divider_separator theme classes]" />
37
+ <span class="[divider_label theme classes]">or</span>
38
+ <hr class="[divider_separator theme classes]" />
39
+ </div>
40
+ ```
41
+
42
+ ---
43
+
44
+ ## ARIA
45
+
46
+ - The outer `<div>` always carries `role="separator"`.
47
+ - The label `<span>` is visible text — no additional ARIA needed.
@@ -0,0 +1,238 @@
1
+ # Form
2
+
3
+ `StimulusPlumbers::Form::Builder` extends Rails' `ActionView::Helpers::FormBuilder` with accessible, themed form fields.
4
+
5
+ ## Setup
6
+
7
+ ```ruby
8
+ # config/application.rb — use globally
9
+ config.action_view.default_form_builder = StimulusPlumbers::Form::Builder
10
+ ```
11
+
12
+ ```erb
13
+ <%# or per form %>
14
+ <%= form_with model: @user, builder: StimulusPlumbers::Form::Builder do |f| %>
15
+
16
+ <% end %>
17
+ ```
18
+
19
+ ---
20
+
21
+ ## Two-level API
22
+
23
+ | Level | What it renders | When to use |
24
+ | ------------------------------------- | --------------------------------------------------------------- | -------------------------------------------- |
25
+ | **Level 1** — native helper overrides | Input element only (theme classes, no label/hint/error wrapper) | When you control surrounding markup manually |
26
+ | **Level 2** — full-field helpers | Label + input + hint + error, fully wired for accessibility | Default choice |
27
+
28
+ ---
29
+
30
+ ## Level 1 — Native helpers
31
+
32
+ Standard Rails helpers are overridden to apply theme CSS classes. All native HTML options (`placeholder:`, `autocomplete:`, `class:`, `data:`, etc.) are forwarded.
33
+
34
+ ```erb
35
+ <%= f.text_field :name %>
36
+ <%= f.email_field :email %>
37
+ <%= f.number_field :age %>
38
+ <%= f.text_area :bio %>
39
+ <%= f.file_field :avatar %>
40
+ <%= f.date_field :birthday %>
41
+ <%= f.time_field :meeting_time %>
42
+ <%= f.select :country, country_options %>
43
+ <%= f.collection_select :country, Country.all, :code, :name %>
44
+ <%= f.time_zone_select :timezone %>
45
+ <%= f.weekday_select :weekday %>
46
+ <%= f.search_field :query %>
47
+ <%= f.check_box :agree %>
48
+ <%= f.radio_button :plan, "basic" %>
49
+ ```
50
+
51
+ Special options on native helpers:
52
+
53
+ | Helper | Option | Effect |
54
+ | ---------------- | ------------------ | ------------------------------------------------------------------ |
55
+ | `password_field` | `revealable: true` | Wraps input in an `input-formatter` reveal controller |
56
+ | `search_field` | `clearable: true` | Wraps input in an `input-clearable` controller with a clear button |
57
+
58
+ ---
59
+
60
+ ## Level 2 — Full-field helpers
61
+
62
+ Three methods render a complete, accessible field:
63
+
64
+ | Method | `as:` values |
65
+ | --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
66
+ | `f.field(attr, as:, **opts)` | `:text` `:email` `:number` `:url` `:tel` `:color` `:month` `:week` `:range` `:datetime_local` `:text_area` `:file` `:password` `:date` `:time` `:select` `:search` |
67
+ | `f.collection_field(attr, as:, collection:, value_method:, text_method:, **opts)` | `:collection_select` `:grouped_collection_select` |
68
+ | `f.choice(attr, as:, **opts)` | `:radio` `:check_box` |
69
+
70
+ ### Shared field options
71
+
72
+ | Option | Type | Default | Description |
73
+ | ------------ | ------------------------------------- | ------------------------ | -------------------------------------------------------------------------- |
74
+ | `label` | String | humanised attribute name | Override label / legend text |
75
+ | `hint` | String | `nil` | Hint text rendered below the field |
76
+ | `error` | String / Array | `nil` | Override error message(s); suppresses `model.errors[attribute]` |
77
+ | `required` | Boolean | `false` | Adds `required` + `aria-required="true"` |
78
+ | `hide_label` | Boolean | `false` | Renders label visually hidden (screen-reader accessible) |
79
+ | `layout` | `:stacked` / `:inline` | `:stacked` | Label above input vs beside it |
80
+ | `floating` | `:filled` / `:outlined` / `:standard` | `nil` | Floating-label style (text-like inputs only; `nil` = standard label above) |
81
+
82
+ ---
83
+
84
+ ## f.field
85
+
86
+ ```erb
87
+ <%= f.field :name, as: :text %>
88
+ <%= f.field :email, as: :email, label: "E-mail", required: true %>
89
+ <%= f.field :bio, as: :text_area, hint: "Tell us about yourself." %>
90
+ <%= f.field :avatar, as: :file %>
91
+ <%= f.field :password, as: :password, revealable: true %>
92
+ <%= f.field :email, as: :email, floating: :filled %>
93
+ <%= f.field :country, as: :select,
94
+ choices: [["Australia", "au"], ["Canada", "ca"], ["United States", "us"]],
95
+ include_blank: "Select a country" %>
96
+ <%= f.field :tags, as: :search,
97
+ choices: ["ruby", "rails", "hotwire"], clearable: true %>
98
+ ```
99
+
100
+ `choices:` takes the standard Rails shape — an array of `[label, value]` pairs (or a flat array of strings).
101
+
102
+ **Floating label variants** — label starts inside the input, animates above on focus/fill. Compatible with text-like inputs only.
103
+
104
+ | `floating:` value | Style |
105
+ | ----------------- | ----------------------------------------- |
106
+ | `:filled` | Filled background, bottom border only |
107
+ | `:outlined` | Full border, label clips through on float |
108
+ | `:standard` | Bottom border only, no background |
109
+
110
+ **Date** (`as: :date`) — calendar-grid picker backed by `combobox-date`. Use `f.date_field` for a plain `<input type="date">`.
111
+
112
+ **Time** (`as: :time`) — drum/scroll-wheel picker backed by `combobox-time`.
113
+
114
+ | Option | Values | Default | Description |
115
+ | -------- | --------------- | ------- | --------------- |
116
+ | `format` | `:h12` / `:h24` | `:h12` | Clock format |
117
+ | `step` | Integer | `1` | Minute interval |
118
+
119
+ Use `f.time_field` for a plain `<input type="time">`.
120
+
121
+ **Select** (`as: :select`) — read-only listbox backed by `combobox-dropdown`. Accepts `choices:`, `include_blank:`, `prompt:`, `selected:`. Use `f.select` for a native `<select>`.
122
+
123
+ **Search** (`as: :search`) — editable typeahead backed by `combobox-dropdown`. Accepts `choices:`, `url:`, `clearable:`. Use `f.search_field` for a native `<input type="search">`.
124
+
125
+ ---
126
+
127
+ ## f.collection_field
128
+
129
+ ```erb
130
+ <%= f.collection_field :country, as: :collection_select,
131
+ collection: Country.all, value_method: :code, text_method: :name %>
132
+
133
+ <%= f.collection_field :country, as: :grouped_collection_select,
134
+ collection: Continent.all,
135
+ value_method: :code,
136
+ text_method: :name,
137
+ group_method: :countries,
138
+ group_label_method: :name %>
139
+ ```
140
+
141
+ Groups render with `role="group"` and `aria-label` on each group. Use native `f.collection_select` / `f.grouped_collection_select` to skip the field wrapper.
142
+
143
+ ---
144
+
145
+ ## f.choice
146
+
147
+ Renders a `<fieldset>` / `<legend>` for accessible grouping, or a single checkbox with an explicit label.
148
+
149
+ ```erb
150
+ <%# Single checkbox %>
151
+ <%= f.choice :agree, as: :check_box, required: true, hint: "You must accept the terms." %>
152
+
153
+ <%# Radio group %>
154
+ <%= f.choice :plan, as: :radio,
155
+ collection: Plan.all, value_method: :id, text_method: :name %>
156
+
157
+ <%# Checkbox group %>
158
+ <%= f.choice :roles, as: :check_box,
159
+ collection: Role.all, value_method: :id, text_method: :name %>
160
+ ```
161
+
162
+ **Collection-only options:**
163
+
164
+ | Option | Values | Default | Description |
165
+ | --------- | ------------------------------------------------------------------- | ---------- | ------------------------------------- |
166
+ | `type` | `:default` \| `:button` \| `:card` | `:default` | Input/label presentation style |
167
+ | `variant` | `:default` \| `:success` \| `:destructive` \| `:warning` \| `:info` | `:default` | Accent color for selected state |
168
+ | `layout` | `:stacked` \| `:inline` | `:inline` | Stack cards vertically or wrap inline |
169
+
170
+ **Card / button behaviour:**
171
+
172
+ - **Checkbox card** — input visible on right; card border changes on check via `has-[:checked]:`
173
+ - **Radio card** — input hidden (`hidden peer`); entire card is the clickable area via `peer-checked:`
174
+ - **Radio button** — input hidden (`hidden peer`); inline pill style via `peer-checked:`
175
+
176
+ ---
177
+
178
+ ## Rendered HTML Structure
179
+
180
+ ### Full field (Level 2)
181
+
182
+ ```html
183
+ <div>
184
+ <label for="user_email" id="user_email_label">E-mail</label>
185
+ <input
186
+ id="user_email"
187
+ type="email"
188
+ aria-describedby="user_email_hint"
189
+ required
190
+ aria-required="true"
191
+ />
192
+ <p id="user_email_hint">We'll never share your email.</p>
193
+ <p id="user_email_error" role="alert">can't be blank</p>
194
+ </div>
195
+ ```
196
+
197
+ ### Floating label field
198
+
199
+ ```html
200
+ <div>
201
+ <div>
202
+ <input id="user_email" placeholder=" " />
203
+ <label for="user_email" id="user_email_label">Email</label>
204
+ </div>
205
+ </div>
206
+ ```
207
+
208
+ ### Choice fieldset
209
+
210
+ ```html
211
+ <fieldset aria-describedby="user_plan_hint">
212
+ <legend>Plan <span aria-hidden="true">*</span></legend>
213
+ <div>
214
+ <p id="user_plan_hint">Choose the plan that fits your needs.</p>
215
+ <label
216
+ ><input type="radio" name="user[plan]" value="1" aria-required="true" />
217
+ Basic</label
218
+ >
219
+ <label
220
+ ><input type="radio" name="user[plan]" value="2" aria-required="true" />
221
+ Pro</label
222
+ >
223
+ </div>
224
+ </fieldset>
225
+ ```
226
+
227
+ ---
228
+
229
+ ## ARIA
230
+
231
+ Every full-field helper automatically:
232
+
233
+ - Links `<label for="…">` to the input `id` (or `<fieldset>` / `<legend>` for collections)
234
+ - Adds `aria-describedby` pointing to hint and/or error elements when present
235
+ - Sets `aria-invalid="true"` when the model has errors for the attribute
236
+ - Renders errors as `<p role="alert">` for screen reader announcement
237
+ - Adds `required` + `aria-required="true"` when `required: true`; collection fields set `aria-required="true"` on each individual input (not the `<fieldset>`)
238
+ - Renders a required mark (`*`, `aria-hidden="true"`) in the `<label>` or `<legend>`
@@ -0,0 +1,44 @@
1
+ # Icon
2
+
3
+ Rails helper for rendering a named icon from the active theme's icon registry.
4
+
5
+ ## Helper
6
+
7
+ ### `sp_icon`
8
+
9
+ ```erb
10
+ <%= sp_icon name: "check" %>
11
+ <%= sp_icon name: "spinner", aria: { label: "Loading" }, role: "img" %>
12
+ <%= sp_icon name: "unknown-icon" %>
13
+ ```
14
+
15
+ | Option | Default | Description |
16
+ | ---------------- | ---------- | ------------------------------------------- |
17
+ | `name` | (required) | Icon name — looked up in the theme registry |
18
+ | `**html_options` | — | Forwarded to the root element |
19
+
20
+ ---
21
+
22
+ ## Rendered HTML Structure
23
+
24
+ ### Registered icon (SVG rendered)
25
+
26
+ ```html
27
+ <svg aria-hidden="true" class="[icon theme classes]">
28
+ <path d="..." />
29
+ </svg>
30
+ ```
31
+
32
+ ### Unregistered name (span fallback)
33
+
34
+ ```html
35
+ <span class="[icon theme classes]"></span>
36
+ ```
37
+
38
+ ---
39
+
40
+ ## ARIA
41
+
42
+ - Icons are **decorative by default** — the theme applies `aria-hidden="true"` so they are invisible to screen readers.
43
+ - For meaningful icons (standalone, no adjacent label), pass `aria: { label: "..." }` and `role: "img"` to override.
44
+ - Button and link icons are rendered internally with `aria-hidden="true"`; callers do not need to set this manually.