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,90 @@
|
|
|
1
|
+
# Link
|
|
2
|
+
|
|
3
|
+
Rails helper for rendering themed, accessible links and link-styled buttons.
|
|
4
|
+
|
|
5
|
+
## Helper
|
|
6
|
+
|
|
7
|
+
### `sp_link`
|
|
8
|
+
|
|
9
|
+
```erb
|
|
10
|
+
<%= sp_link "Home", url: root_path %>
|
|
11
|
+
<%= sp_link "Docs", url: docs_path, type: :button %>
|
|
12
|
+
<%= sp_link "Delete", url: item_path(@item), type: :card, variant: :destructive %>
|
|
13
|
+
<%= sp_link(icon_leading: "arrow-left", url: back_path) { "Back" } %>
|
|
14
|
+
<%= sp_link "GitHub", url: "https://github.com", target: "_blank" %>
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
| Option | Default | Description |
|
|
18
|
+
| ---------------- | ---------- | ---------------------------------------------------------------------------------- |
|
|
19
|
+
| `content` | `nil` | Link text — positional arg or block |
|
|
20
|
+
| `url` | (required) | `href` value |
|
|
21
|
+
| `type` | `:default` | Visual style — `:default` \| `:button` \| `:card` |
|
|
22
|
+
| `variant` | `:default` | Color source — `:default` \| `:success` \| `:destructive` \| `:warning` \| `:info` |
|
|
23
|
+
| `icon_leading` | `nil` | Icon rendered **before** the label — icon name (string or symbol) or callable |
|
|
24
|
+
| `icon_trailing` | `nil` | Icon rendered **after** the label — icon name (string or symbol) or callable |
|
|
25
|
+
| `target` | `nil` | Forwarded to `<a target>`. `"_blank"` auto-sets `icon_trailing: "external-link"` |
|
|
26
|
+
| `**html_options` | — | Forwarded to the `<a>` element |
|
|
27
|
+
|
|
28
|
+
**`type:` — visual style**
|
|
29
|
+
|
|
30
|
+
| Value | Appearance |
|
|
31
|
+
| ---------- | -------------------------------------------------------------------------- |
|
|
32
|
+
| `:default` | Inline text link — colored, underlines on hover |
|
|
33
|
+
| `:button` | Outlined button shape — border + surface background, no fill |
|
|
34
|
+
| `:card` | Full-padding card — `flex-1`, `justify-start`; border + surface background |
|
|
35
|
+
|
|
36
|
+
**`variant:` — color source**
|
|
37
|
+
|
|
38
|
+
Unlike `sp_button`, link variants use `--link-color` / `--link-ring` / `--link-bg` tokens. `:default` maps to `--sp-color-primary`.
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## Rendered HTML Structure
|
|
43
|
+
|
|
44
|
+
Text content is always wrapped in a `<span>`:
|
|
45
|
+
|
|
46
|
+
```html
|
|
47
|
+
<a href="/path" class="[theme classes]"><span>Home</span></a>
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### With icons
|
|
51
|
+
|
|
52
|
+
```html
|
|
53
|
+
<a href="/path" class="[theme classes]">
|
|
54
|
+
<svg aria-hidden="true" class="[link_icon theme classes]">
|
|
55
|
+
<!-- icon svg -->
|
|
56
|
+
</svg>
|
|
57
|
+
<span>Back</span>
|
|
58
|
+
</a>
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### External link (auto trailing icon)
|
|
62
|
+
|
|
63
|
+
```html
|
|
64
|
+
<a href="https://github.com" target="_blank" class="[theme classes]">
|
|
65
|
+
<span>GitHub</span>
|
|
66
|
+
<svg aria-hidden="true" class="[link_icon theme classes]">
|
|
67
|
+
<!-- external-link icon -->
|
|
68
|
+
</svg>
|
|
69
|
+
</a>
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Icon only (`type: :button`)
|
|
73
|
+
|
|
74
|
+
When no text is provided, `type: :button` links become square (same mechanism as `sp_button`):
|
|
75
|
+
|
|
76
|
+
```html
|
|
77
|
+
<a href="/path" aria-label="Add" class="[theme classes]">
|
|
78
|
+
<svg aria-hidden="true" class="[link_icon theme classes]">
|
|
79
|
+
<!-- icon svg -->
|
|
80
|
+
</svg>
|
|
81
|
+
</a>
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
---
|
|
85
|
+
|
|
86
|
+
## ARIA
|
|
87
|
+
|
|
88
|
+
- Renders an `<a>` element — use `sp_button` for actions without a URL destination.
|
|
89
|
+
- `target="_blank"` automatically appends an external-link icon (`aria-hidden="true"`); add a visually-hidden description if the context requires it.
|
|
90
|
+
- Icon-only links must supply an accessible label via `aria: { label: "..." }` in `html_options`.
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# List
|
|
2
|
+
|
|
3
|
+
Rails helper for rendering an accessible list of links, buttons, or grouped sections.
|
|
4
|
+
|
|
5
|
+
## Helper
|
|
6
|
+
|
|
7
|
+
### `sp_list`
|
|
8
|
+
|
|
9
|
+
```erb
|
|
10
|
+
<%# Flat list %>
|
|
11
|
+
<%= sp_list do |list| %>
|
|
12
|
+
<%= list.item("Dashboard", url: root_path) %>
|
|
13
|
+
<%= list.item("Settings", url: settings_path) %>
|
|
14
|
+
<%= list.item("Sign out") %>
|
|
15
|
+
<% end %>
|
|
16
|
+
|
|
17
|
+
<%# With icons %>
|
|
18
|
+
<%= sp_list do |list| %>
|
|
19
|
+
<%= list.item("Dashboard", url: root_path) do |item| %>
|
|
20
|
+
<% item.with_icon_leading("home") %>
|
|
21
|
+
<% end %>
|
|
22
|
+
<% end %>
|
|
23
|
+
|
|
24
|
+
<%# Grouped sections %>
|
|
25
|
+
<%= sp_list(heading_level: 2) do |list| %>
|
|
26
|
+
<%= list.section(title: "Account") do |section| %>
|
|
27
|
+
<%= section.item("Profile", url: profile_path) %>
|
|
28
|
+
<%= section.item("Billing", url: billing_path) %>
|
|
29
|
+
<% end %>
|
|
30
|
+
<%= list.section(title: "Danger zone") do |section| %>
|
|
31
|
+
<%= section.item("Delete account") %>
|
|
32
|
+
<% end %>
|
|
33
|
+
<% end %>
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
| Option | Default | Description |
|
|
37
|
+
| ---------------- | -------- | ---------------------------------------------------------------------------------------- |
|
|
38
|
+
| `heading_level:` | `nil` | When set, section titles render as `<h{n}>`; nested sections increment and clamp at `h6` |
|
|
39
|
+
| `role:` | `"list"` | ARIA role on the `<ul>` — use `"menu"` for interactive menus |
|
|
40
|
+
| `**html_options` | — | Forwarded to the `<ul>` |
|
|
41
|
+
|
|
42
|
+
### `list.section(title:, description:, **html_options)`
|
|
43
|
+
|
|
44
|
+
Renders a `<li>` with an optional heading/description and a nested `<ul>`.
|
|
45
|
+
|
|
46
|
+
| Option | Default | Description |
|
|
47
|
+
| -------------- | ------- | ---------------------------------------------------------------------------------------------------- |
|
|
48
|
+
| `title:` | `nil` | Without `heading_level`: renders `<span aria-hidden="true">`; with `heading_level`: renders `<h{n}>` |
|
|
49
|
+
| `description:` | `nil` | Rendered as a `<span>` beside the title |
|
|
50
|
+
|
|
51
|
+
### `list.item(content, url:, active:, target:, **html_options, &block)`
|
|
52
|
+
|
|
53
|
+
Renders a `<li>` containing an `<a>` (when `url:` present) or `<button>`.
|
|
54
|
+
|
|
55
|
+
| Option | Default | Description |
|
|
56
|
+
| ---------------- | ------- | ------------------------------------------------------------------- |
|
|
57
|
+
| `content` | `nil` | Item label — positional arg or via `item.with_title` |
|
|
58
|
+
| `url:` | `nil` | Renders `<a href>` inside the `<li>` |
|
|
59
|
+
| `active:` | `false` | Adds `aria-current="page"` (link) or `aria-current="true"` (button) |
|
|
60
|
+
| `target:` | `nil` | Forwarded to the `<a>` (e.g. `"_blank"`) |
|
|
61
|
+
| `**html_options` | — | Forwarded to the inner `<a>` or `<button>` |
|
|
62
|
+
|
|
63
|
+
When `target: "_blank"` is set, `icon_trailing: "external-link"` is added automatically.
|
|
64
|
+
|
|
65
|
+
### Item slot methods (yielded as `item`)
|
|
66
|
+
|
|
67
|
+
| Slot method | Description |
|
|
68
|
+
| ------------------------------- | ------------------------------------------------------------- |
|
|
69
|
+
| `item.with_icon_leading(name)` | Icon before the content — string/symbol resolves via `Icon` |
|
|
70
|
+
| `item.with_title(text)` | Title text (pre-populated when positional `content` is given) |
|
|
71
|
+
| `item.with_description(text)` | Secondary text below the title |
|
|
72
|
+
| `item.with_icon_trailing(name)` | Icon after the content — string/symbol resolves via `Icon` |
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
## Rendered HTML Structure
|
|
77
|
+
|
|
78
|
+
### Flat list
|
|
79
|
+
|
|
80
|
+
```html
|
|
81
|
+
<ul role="list" class="[list theme classes]">
|
|
82
|
+
<li class="[list_item theme classes]">
|
|
83
|
+
<a href="/dashboard" class="...">
|
|
84
|
+
<span class="[list_item_content theme classes]">
|
|
85
|
+
<span class="[list_item_title theme classes]">Dashboard</span>
|
|
86
|
+
</span>
|
|
87
|
+
</a>
|
|
88
|
+
</li>
|
|
89
|
+
</ul>
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### Item with icons
|
|
93
|
+
|
|
94
|
+
```html
|
|
95
|
+
<li class="[list_item theme classes]">
|
|
96
|
+
<a href="/profile" class="...">
|
|
97
|
+
<svg aria-hidden="true" class="[list_item_icon theme classes]">...</svg>
|
|
98
|
+
<span class="[list_item_content theme classes]">
|
|
99
|
+
<span class="[list_item_title theme classes]">Profile</span>
|
|
100
|
+
<span class="[list_item_description theme classes]"
|
|
101
|
+
>Manage your account</span
|
|
102
|
+
>
|
|
103
|
+
</span>
|
|
104
|
+
<svg aria-hidden="true" class="[list_item_icon theme classes]">...</svg>
|
|
105
|
+
</a>
|
|
106
|
+
</li>
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Sectioned list (no heading_level)
|
|
110
|
+
|
|
111
|
+
```html
|
|
112
|
+
<ul role="list" class="[list theme classes]">
|
|
113
|
+
<li class="[list_section theme classes]">
|
|
114
|
+
<span aria-hidden="true" class="[list_section_title theme classes]"
|
|
115
|
+
>Account</span
|
|
116
|
+
>
|
|
117
|
+
<ul aria-label="Account">
|
|
118
|
+
<li class="[list_item theme classes]">...</li>
|
|
119
|
+
</ul>
|
|
120
|
+
</li>
|
|
121
|
+
</ul>
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
### Sectioned list (heading_level: 2)
|
|
125
|
+
|
|
126
|
+
```html
|
|
127
|
+
<ul role="list" class="[list theme classes]">
|
|
128
|
+
<li class="[list_section theme classes]">
|
|
129
|
+
<h2 class="[list_section_title theme classes]">Account</h2>
|
|
130
|
+
<ul aria-label="Account">
|
|
131
|
+
<li class="[list_item theme classes]">...</li>
|
|
132
|
+
</ul>
|
|
133
|
+
</li>
|
|
134
|
+
</ul>
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
---
|
|
138
|
+
|
|
139
|
+
## Theme keys
|
|
140
|
+
|
|
141
|
+
| Key | Element | Variants |
|
|
142
|
+
| -------------------------- | ----------------------------------------------------- | -------- |
|
|
143
|
+
| `list` | Outer `<ul>` | — |
|
|
144
|
+
| `list_section` | Section wrapper `<li>` | — |
|
|
145
|
+
| `list_section_title` | Section title `<span>` or `<h{n}>` | — |
|
|
146
|
+
| `list_section_description` | Section description `<span>` | — |
|
|
147
|
+
| `list_item` | Item `<li>` (theme applied to inner `<a>`/`<button>`) | — |
|
|
148
|
+
| `list_item_icon` | Leading and trailing icon elements | — |
|
|
149
|
+
| `list_item_content` | Content wrapper `<span>` (title + description) | — |
|
|
150
|
+
| `list_item_title` | Title `<span>` | — |
|
|
151
|
+
| `list_item_description` | Description `<span>` | — |
|
|
152
|
+
|
|
153
|
+
---
|
|
154
|
+
|
|
155
|
+
## ARIA
|
|
156
|
+
|
|
157
|
+
- Default `role="list"` is appropriate for navigation or command lists.
|
|
158
|
+
- Use `role="menu"` + `role="menuitem"` (via `html_options`) only when items form a widget menu — arrow key navigation must then be wired via a Stimulus controller.
|
|
159
|
+
- `active: true` adds `aria-current="page"` on links and `aria-current="true"` on buttons; pair this with a visual style change so the state is not communicated by color alone.
|
|
160
|
+
- Section titles without `heading_level` use `aria-hidden="true"` on the `<span>` and set `aria-label` on the nested `<ul>` to avoid double-announcing.
|
|
161
|
+
- Nested sections increment the heading level automatically (clamped at `h6`).
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Modal
|
|
2
|
+
|
|
3
|
+
The `modal` controller is used directly in HTML — there is no Rails helper. See the [JS controller docs](../../../stimulus-plumbers/docs/component/modal.md) for targets, methods, and usage examples.
|
|
4
|
+
|
|
5
|
+
## Quick example (ERB)
|
|
6
|
+
|
|
7
|
+
```erb
|
|
8
|
+
<div data-controller="modal">
|
|
9
|
+
<%= tag.button "Open", data: { action: "modal#open" } %>
|
|
10
|
+
|
|
11
|
+
<%= tag.dialog data: { modal_target: "modal" },
|
|
12
|
+
aria: { labelledby: "modal-title", modal: true } do %>
|
|
13
|
+
<h2 id="modal-title">Confirm action</h2>
|
|
14
|
+
<p>Are you sure?</p>
|
|
15
|
+
<%= tag.button "Cancel", data: { action: "modal#close" } %>
|
|
16
|
+
<%= tag.button "Confirm" %>
|
|
17
|
+
<% end %>
|
|
18
|
+
</div>
|
|
19
|
+
```
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# Plumber Internals
|
|
2
|
+
|
|
3
|
+
The `Plumber` namespace provides the shared infrastructure that all renderers and the form builder are built on: a base class, a renderer declaration macro, and an HTML option merge utility.
|
|
4
|
+
|
|
5
|
+
See [dispatcher.md](dispatcher.md) for the `Plumber::Dispatcher` strategy factory.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Plumber::Base
|
|
10
|
+
|
|
11
|
+
The foundation class for all component renderers. Inherit from it to get `template`, `theme`, `merge_html_options`, and the `renders` macro in one shot.
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
class MyRenderer < StimulusPlumbers::Plumber::Base
|
|
15
|
+
renders :my_component, with: MyComponent
|
|
16
|
+
|
|
17
|
+
def render(...)
|
|
18
|
+
my_component(...)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
### Included modules
|
|
24
|
+
|
|
25
|
+
| Module | What it adds |
|
|
26
|
+
| ------------------------ | --------------------------------------------------------------- |
|
|
27
|
+
| `Plumber::Options::Html` | `merge_html_options`, `merge_stimulus_data`, `merge_token_list` |
|
|
28
|
+
| `Plumber::Renderer` | `renders` class macro; `renderers` class attribute |
|
|
29
|
+
|
|
30
|
+
### Instance interface
|
|
31
|
+
|
|
32
|
+
```ruby
|
|
33
|
+
renderer = MyRenderer.new(view_context)
|
|
34
|
+
renderer.template # => the ActionView template / view context
|
|
35
|
+
renderer.theme # => StimulusPlumbers.config.theme.current
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
`theme` delegates to the global configuration so renderers always read the active theme at call time — no need to pass it in.
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## Plumber::Renderer
|
|
43
|
+
|
|
44
|
+
`ActiveSupport::Concern` that adds the `renders` class macro. Included by `Plumber::Base`; can also be included standalone.
|
|
45
|
+
|
|
46
|
+
### `renders`
|
|
47
|
+
|
|
48
|
+
Declares a public method on the class and wires it to a callable via `Plumber::Dispatcher`.
|
|
49
|
+
|
|
50
|
+
```ruby
|
|
51
|
+
renders :method_name, with: callable
|
|
52
|
+
renders :method_name { |*args| ... } # block form — Proc/InstanceExec strategy
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`callable` follows the same type rules as `Dispatcher.build` (Symbol, Proc, Class/Module, String). A block is sugar for `with: proc { ... }`.
|
|
56
|
+
|
|
57
|
+
When the generated method is called, it:
|
|
58
|
+
|
|
59
|
+
1. Looks up `renderers.fetch(:method_name)` for the registered callable.
|
|
60
|
+
2. Builds a dispatcher via `Dispatcher.build(callable, *args, method_name: :method_name, init_args: [template], **kwargs)`.
|
|
61
|
+
3. Calls `dispatcher.call(self)`.
|
|
62
|
+
|
|
63
|
+
The `method_name:` and `init_args: [template]` are injected automatically — you never pass them when calling the generated method.
|
|
64
|
+
|
|
65
|
+
### Strategy mapping
|
|
66
|
+
|
|
67
|
+
| `with:` type | Dispatcher strategy | Effective call |
|
|
68
|
+
| ------------------ | ------------------- | --------------------------------------------------- |
|
|
69
|
+
| `Symbol` | `MethodCall` | `self.send(symbol, *args, **kwargs)` |
|
|
70
|
+
| `Proc` / block | `InstanceExec` | `self.instance_exec(*args, **kwargs, &proc)` |
|
|
71
|
+
| `Class` / `Module` | `KlassProxy` | `Class.new(template).method_name(*args, **kwargs)` |
|
|
72
|
+
| `String` | `KlassProxy` | Resolved via `safe_constantize`, then same as Class |
|
|
73
|
+
|
|
74
|
+
### Example
|
|
75
|
+
|
|
76
|
+
```ruby
|
|
77
|
+
class ComboboxHelper
|
|
78
|
+
include StimulusPlumbers::Plumber::Renderer
|
|
79
|
+
include StimulusPlumbers::Plumber::Options::Html
|
|
80
|
+
|
|
81
|
+
attr_reader :template
|
|
82
|
+
|
|
83
|
+
def initialize(template)
|
|
84
|
+
@template = template
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
renders :sp_combobox_dropdown, with: Components::Combobox::Dropdown
|
|
88
|
+
renders :sp_combobox_date, with: Components::Combobox::Date
|
|
89
|
+
end
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Calling `helper.sp_combobox_dropdown(*args, **opts)` instantiates `Components::Combobox::Dropdown.new(template)` and calls `.sp_combobox_dropdown(*args, **opts)` on it.
|
|
93
|
+
|
|
94
|
+
### `renderers` class attribute
|
|
95
|
+
|
|
96
|
+
`renderers` is an inheritable `class_attribute` (hash). Subclasses inherit the parent's renderers and can extend or override them without affecting the parent.
|
|
97
|
+
|
|
98
|
+
```ruby
|
|
99
|
+
class MyHelper < ComboboxHelper
|
|
100
|
+
renders :sp_combobox_dropdown, with: MyCustomDropdown # overrides parent's entry
|
|
101
|
+
end
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## Plumber::Options::Html
|
|
107
|
+
|
|
108
|
+
Mixin for safely deep-merging HTML attribute hashes. Handles three concerns that a plain `Hash#merge` gets wrong:
|
|
109
|
+
|
|
110
|
+
1. **`class:` accumulation** — theme classes and caller-supplied classes must both appear, not overwrite each other.
|
|
111
|
+
2. **Stimulus space-joining** — `data-controller` and `data-action` are additive; multiple controllers/actions must be space-joined, not replaced.
|
|
112
|
+
3. **`classes:` key** — theme resolution returns `{ classes: "..." }` rather than `{ class: "..." }` to avoid conflicts; `merge_html_options` unifies both keys.
|
|
113
|
+
|
|
114
|
+
### `merge_html_options(*hashes)`
|
|
115
|
+
|
|
116
|
+
Merges any number of HTML option hashes into one. Order matters — later hashes win on plain key conflicts, but `class`/`classes`/`data-controller`/`data-action` are always accumulated.
|
|
117
|
+
|
|
118
|
+
```ruby
|
|
119
|
+
merge_html_options(
|
|
120
|
+
{ class: "text-sm", data: { controller: "toggle" } },
|
|
121
|
+
{ classes: "border", data: { controller: "flip", action: "click->flip#run" } },
|
|
122
|
+
{ class: "font-bold", data: { action: "keydown->flip#key" } }
|
|
123
|
+
)
|
|
124
|
+
# => {
|
|
125
|
+
# class: "text-sm border font-bold",
|
|
126
|
+
# data: {
|
|
127
|
+
# controller: "toggle flip",
|
|
128
|
+
# action: "click->flip#run keydown->flip#key"
|
|
129
|
+
# }
|
|
130
|
+
# }
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Typical call site in an input renderer:
|
|
134
|
+
|
|
135
|
+
```ruby
|
|
136
|
+
html_options = merge_html_options(caller_opts, field_theme(:form_input, error: error))
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
`field_theme` returns `{ class: theme_classes }` (via `{ classes: ... }` internally); `merge_html_options` folds both into a single `class:` value.
|
|
140
|
+
|
|
141
|
+
### `merge_token_list(*parts, delimiter: " ")`
|
|
142
|
+
|
|
143
|
+
Joins string-like values into a single space-separated string, deduplicating tokens. Accepts heterogeneous inputs:
|
|
144
|
+
|
|
145
|
+
| Input type | Behaviour |
|
|
146
|
+
| ------------- | -------------------------------------------------------------------- |
|
|
147
|
+
| `String` | Split by delimiter, each token kept |
|
|
148
|
+
| `Hash` | Keys whose value is truthy become tokens (conditional class pattern) |
|
|
149
|
+
| `Array` | Recursively merged |
|
|
150
|
+
| anything else | Ignored |
|
|
151
|
+
|
|
152
|
+
```ruby
|
|
153
|
+
merge_token_list("btn", "btn-sm", "btn") # => "btn btn-sm"
|
|
154
|
+
merge_token_list({ "hidden" => false, "active" => true }) # => "active"
|
|
155
|
+
merge_token_list(["flex", "items-center"], "gap-2") # => "flex items-center gap-2"
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
### `merge_stimulus_data(*hashes, spacejoin:)`
|
|
159
|
+
|
|
160
|
+
Deep-merges `data:` sub-hashes. Keys listed in `spacejoin:` (default: `[:controller, :action]`) are space-joined when they conflict; all other keys use last-wins semantics.
|
|
161
|
+
|
|
162
|
+
```ruby
|
|
163
|
+
merge_stimulus_data(
|
|
164
|
+
{ controller: "toggle", value: "1" },
|
|
165
|
+
{ controller: "flip", value: "2" }
|
|
166
|
+
)
|
|
167
|
+
# => { controller: "toggle flip", value: "2" }
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Pass `spacejoin: []` to disable space-joining entirely (plain deep merge).
|
|
171
|
+
|
|
172
|
+
### Extending the space-join set
|
|
173
|
+
|
|
174
|
+
If a Stimulus controller uses a custom multi-valued data key (e.g. `data-targets`), override `merge_stimulus_data` with an extended `spacejoin:` list:
|
|
175
|
+
|
|
176
|
+
```ruby
|
|
177
|
+
def merge_html_options(*hashes)
|
|
178
|
+
super.tap do |result|
|
|
179
|
+
result[:data] = merge_stimulus_data(*hashes.map { |h| h[:data] || {} },
|
|
180
|
+
spacejoin: STIMULUS_SPACEJOIN_KEYS + %i[targets])
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
```
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# Popover
|
|
2
|
+
|
|
3
|
+
Rails helper for rendering an accessible popover with a trigger and panel slot.
|
|
4
|
+
|
|
5
|
+
## Helper
|
|
6
|
+
|
|
7
|
+
```erb
|
|
8
|
+
<%# Default button trigger %>
|
|
9
|
+
<%= sp_popover do |p| %>
|
|
10
|
+
<% p.trigger { "Open" } %>
|
|
11
|
+
<% p.panel(role: "dialog", aria: { label: "Options" }) do %>
|
|
12
|
+
<p>Popover content</p>
|
|
13
|
+
<% end %>
|
|
14
|
+
<% end %>
|
|
15
|
+
|
|
16
|
+
<%# Custom trigger element — one-arity block receives wiring attrs %>
|
|
17
|
+
<%= sp_popover do |p| %>
|
|
18
|
+
<% p.trigger do |attrs| %>
|
|
19
|
+
<button type="button"
|
|
20
|
+
aria-haspopup="<%= attrs[:aria][:haspopup] %>"
|
|
21
|
+
aria-expanded="<%= attrs[:aria][:expanded] %>"
|
|
22
|
+
aria-controls="<%= attrs[:panel_id] %>"
|
|
23
|
+
data-popover-target="trigger"
|
|
24
|
+
data-action="<%= attrs[:data][:action] %>">
|
|
25
|
+
<%= image_tag "avatar.png", alt: "My account" %>
|
|
26
|
+
</button>
|
|
27
|
+
<% end %>
|
|
28
|
+
<% p.panel(role: "menu", aria: { label: "Account" }) do %>
|
|
29
|
+
...
|
|
30
|
+
<% end %>
|
|
31
|
+
<% end %>
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
| Option | Default | Description |
|
|
35
|
+
| ------------------ | ------- | ----------------------------------------------------------------------------------------------- |
|
|
36
|
+
| `panel_id:` | auto | Override the generated panel `id` |
|
|
37
|
+
| `close_on_select:` | — | When `false`, sets `data-popover-close-on-select-value="false"` (panel stays open on selection) |
|
|
38
|
+
| `**html_options` | — | Forwarded to the outer wrapper `div` |
|
|
39
|
+
|
|
40
|
+
### `p.trigger`
|
|
41
|
+
|
|
42
|
+
Zero-arity block — renders a wired `<button>`; block content becomes the button label:
|
|
43
|
+
|
|
44
|
+
```erb
|
|
45
|
+
<% p.trigger { "Open" } %>
|
|
46
|
+
<% p.trigger(haspopup: "listbox") { "Choose" } %>
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
One-arity block — yields `{ panel_id:, aria:, data: }` for caller-defined elements:
|
|
50
|
+
|
|
51
|
+
```erb
|
|
52
|
+
<% p.trigger do |attrs| %>
|
|
53
|
+
<input type="text" role="combobox"
|
|
54
|
+
aria-controls="<%= attrs[:panel_id] %>"
|
|
55
|
+
data-action="<%= attrs[:data][:action] %>">
|
|
56
|
+
<% end %>
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### `p.panel` / `p.build_panel`
|
|
60
|
+
|
|
61
|
+
Two panel slots mirror the `render` / `build` split (and the 0-/1-arity `trigger`):
|
|
62
|
+
|
|
63
|
+
`p.panel` — the builder renders the wired panel element; all kwargs are forwarded as
|
|
64
|
+
HTML attributes. The panel is hidden by default and revealed by the Stimulus controller.
|
|
65
|
+
|
|
66
|
+
```erb
|
|
67
|
+
<% p.panel(tag: :ul, role: "listbox", aria: { labelledby: "my-label" }) do %>
|
|
68
|
+
...
|
|
69
|
+
<% end %>
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
`p.build_panel` — the **caller** renders the element; the block receives `panel_attrs`
|
|
73
|
+
(including the panel `id`) to spread onto its own root. Use it when the panel needs a
|
|
74
|
+
structure of its own (e.g. combobox typeahead: a wrapper around a listbox plus sibling
|
|
75
|
+
status regions).
|
|
76
|
+
|
|
77
|
+
```ruby
|
|
78
|
+
p.build_panel(classes: "...") do |panel_attrs|
|
|
79
|
+
content_tag(:div, **panel_attrs) { safe_join([listbox_html, status_html]) }
|
|
80
|
+
end
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
---
|
|
84
|
+
|
|
85
|
+
## `build` — for component authors
|
|
86
|
+
|
|
87
|
+
When a component owns its outer wrapper, call `build` instead of `render` to get trigger + panel without the extra div or `data-controller="popover"`:
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
Components::Popover.new(template).build(panel_id: "my-panel") do |p|
|
|
91
|
+
p.trigger(haspopup: "listbox") { |attrs| render_my_trigger(attrs) }
|
|
92
|
+
p.panel(tag: :ul, role: "listbox") { options_html }
|
|
93
|
+
end
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`Popover::Panel` exposes the same pair: `#render` (builds the wired element) and `#build`
|
|
97
|
+
(yields `panel_attrs` for the caller to wire).
|
|
98
|
+
|
|
99
|
+
---
|
|
100
|
+
|
|
101
|
+
## Rendered HTML Structure
|
|
102
|
+
|
|
103
|
+
```html
|
|
104
|
+
<div data-controller="popover" class="[popover_wrapper]">
|
|
105
|
+
<button
|
|
106
|
+
type="button"
|
|
107
|
+
aria-haspopup="dialog"
|
|
108
|
+
aria-expanded="false"
|
|
109
|
+
aria-controls="[panel_id]"
|
|
110
|
+
data-popover-target="trigger"
|
|
111
|
+
data-action="click->popover#toggle keydown.esc->popover#close"
|
|
112
|
+
class="[popover_trigger]"
|
|
113
|
+
>
|
|
114
|
+
Open
|
|
115
|
+
</button>
|
|
116
|
+
<div
|
|
117
|
+
id="[panel_id]"
|
|
118
|
+
hidden
|
|
119
|
+
class="[popover]"
|
|
120
|
+
role="dialog"
|
|
121
|
+
aria-label="Options"
|
|
122
|
+
data-popover-target="panel"
|
|
123
|
+
>
|
|
124
|
+
Popover content
|
|
125
|
+
</div>
|
|
126
|
+
</div>
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
---
|
|
130
|
+
|
|
131
|
+
## ARIA
|
|
132
|
+
|
|
133
|
+
- Default trigger button has `aria-haspopup="dialog"`, `aria-expanded="false"`, and `aria-controls` pointing to the panel.
|
|
134
|
+
- `aria-expanded` is toggled to `"true"` / `"false"` by the Stimulus controller.
|
|
135
|
+
- Pass `role: "dialog"` or `role: "tooltip"` on `p.panel` based on whether the content is interactive.
|
|
136
|
+
- `aria: { label: }` or `aria: { labelledby: }` on `p.panel` provides the accessible name.
|
|
137
|
+
|
|
138
|
+
For the JS controller API (targets, values, keyboard behaviour), see the [JS package docs](../../../stimulus-plumbers/docs/component/popover.md).
|