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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +7 -0
- data/docs/architecture.md +33 -0
- data/docs/component/avatar.md +84 -0
- data/docs/component/button.md +135 -0
- data/docs/component/calendar.md +223 -0
- data/docs/component/card.md +92 -0
- data/docs/component/combobox.md +254 -0
- data/docs/component/dispatcher.md +76 -0
- data/docs/component/divider.md +47 -0
- data/docs/component/form.md +238 -0
- data/docs/component/icon.md +44 -0
- data/docs/component/link.md +90 -0
- data/docs/component/list.md +161 -0
- data/docs/component/modal.md +19 -0
- data/docs/component/plumber.md +183 -0
- data/docs/component/popover.md +138 -0
- data/docs/component/theme.md +188 -0
- data/docs/component/timeline.md +138 -0
- data/lib/stimulus_plumbers/version.rb +1 -1
- data/vendor/controllers.manifest.json +386 -0
- metadata +19 -1
|
@@ -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.
|