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,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).