baldur 0.2.5 → 0.3.0
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/README.md +10 -0
- data/TODO.md +27 -7
- data/app/assets/javascripts/baldur/controllers/segmented_tabs_controller.js +53 -2
- data/app/assets/javascripts/baldur/controllers/theme_controller.js +4 -3
- data/app/assets/stylesheets/baldur/application/components/auth-page.css +5 -6
- data/app/assets/stylesheets/baldur/application/components/table.css +17 -7
- data/app/helpers/baldur/ui_helper.rb +11 -2
- data/app/helpers/baldur/ui_helper_feedback.rb +19 -2
- data/app/views/baldur/components/_button.html.erb +4 -0
- data/app/views/baldur/components/_segmented_buttons.html.erb +14 -7
- data/app/views/baldur/components/_snackbar_stack.html.erb +10 -6
- data/app/views/baldur/components/_table.html.erb +6 -4
- data/app/views/baldur/optional/_auth_page.html.erb +2 -2
- data/baldur.gemspec +4 -1
- data/context7.json +17 -0
- data/docs/alerts-and-snackbars.md +72 -0
- data/docs/auth.md +66 -0
- data/docs/forms.md +267 -0
- data/docs/installation.md +63 -0
- data/docs/marketing.md +77 -0
- data/docs/modals-and-panels.md +55 -0
- data/docs/security.md +11 -0
- data/docs/sidebar.md +105 -0
- data/docs/styling.md +34 -0
- data/docs/tables.md +173 -0
- data/docs/tabs-and-segmented-controls.md +509 -0
- data/docs/theme.md +118 -0
- data/lib/baldur/version.rb +1 -1
- data/llms-full.txt +179 -0
- data/llms.txt +35 -0
- data/test/hidden_field_helper_test.rb +23 -0
- data/test/segmented_buttons_helper_test.rb +85 -0
- data/test/snackbar_stack_helper_test.rb +121 -0
- data/test/table_helper_test.rb +118 -0
- data/test/text_field_helper_test.rb +40 -0
- data/test/theme_toggle_helper_test.rb +2 -0
- metadata +22 -2
data/docs/tables.md
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# Tables
|
|
2
|
+
|
|
3
|
+
## Composition
|
|
4
|
+
|
|
5
|
+
Use the table helpers as a small composition system:
|
|
6
|
+
|
|
7
|
+
- `ui_table` is the table atom.
|
|
8
|
+
- `ui_table_card` is the card shell for title, controls, table body, and footer.
|
|
9
|
+
- `ui_table_footer` owns the `Show [x] items per page` control and the `Showing x-y of z` status line.
|
|
10
|
+
- `ui_pagination` is the page-navigation atom and is usually composed through `ui_table_footer`.
|
|
11
|
+
|
|
12
|
+
If a table has title, controls, rows, and pagination, render them inside the same `ui_table_card`.
|
|
13
|
+
|
|
14
|
+
## Simple Table
|
|
15
|
+
|
|
16
|
+
Use `ui_table` directly for embedded or simple tables:
|
|
17
|
+
|
|
18
|
+
```erb
|
|
19
|
+
<%= ui_table(
|
|
20
|
+
columns: [
|
|
21
|
+
{ label: "SKU", key: :sku },
|
|
22
|
+
{ label: "Status", key: :status },
|
|
23
|
+
{ label: "Revenue", key: :revenue, numeric: true }
|
|
24
|
+
],
|
|
25
|
+
rows: [
|
|
26
|
+
{ sku: "SKU-001", status: "Active", revenue: number_to_currency(12_500) },
|
|
27
|
+
{ sku: "SKU-002", status: "Draft", revenue: number_to_currency(3_800) }
|
|
28
|
+
],
|
|
29
|
+
empty_state: "No SKUs found"
|
|
30
|
+
) %>
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Standalone Table with Sort and Pagination
|
|
34
|
+
|
|
35
|
+
Use `ui_table_card` when the table is a standalone surface:
|
|
36
|
+
|
|
37
|
+
```erb
|
|
38
|
+
<% table_controls = capture do %>
|
|
39
|
+
<div class="flex items-end gap-3">
|
|
40
|
+
<%= ui_menu_select_tag :status,
|
|
41
|
+
options: [
|
|
42
|
+
{ label: "All", value: "all" },
|
|
43
|
+
{ label: "Active", value: "active" },
|
|
44
|
+
{ label: "Draft", value: "draft" }
|
|
45
|
+
],
|
|
46
|
+
selected: params[:status].presence || "all",
|
|
47
|
+
label: "Status" %>
|
|
48
|
+
</div>
|
|
49
|
+
<% end %>
|
|
50
|
+
|
|
51
|
+
<%= ui_table_card(
|
|
52
|
+
title: "Products",
|
|
53
|
+
description: "Track inventory and performance in one place.",
|
|
54
|
+
controls: table_controls,
|
|
55
|
+
controls_position: :header,
|
|
56
|
+
footer: ui_table_footer(
|
|
57
|
+
current_page: @pagination[:current_page],
|
|
58
|
+
total_pages: @pagination[:total_pages],
|
|
59
|
+
total_count: @pagination[:total_count],
|
|
60
|
+
per_page: @pagination[:per_page],
|
|
61
|
+
path_builder: ->(page) { products_path(request.query_parameters.merge(page: page, per_page: @pagination[:per_page])) },
|
|
62
|
+
rows_per_page_param: "per_page",
|
|
63
|
+
rows_per_page_options: [10, 20, 50],
|
|
64
|
+
rows_per_page_selected: @pagination[:per_page]
|
|
65
|
+
)
|
|
66
|
+
) do %>
|
|
67
|
+
<%= ui_table(
|
|
68
|
+
sort: { key: params[:sort], direction: params[:direction] },
|
|
69
|
+
sort_path_builder: ->(sort_key, direction) { products_path(request.query_parameters.merge(sort: sort_key, direction: direction, page: 1)) },
|
|
70
|
+
columns: [
|
|
71
|
+
{ label: "SKU", key: :sku, sortable: true, sort_key: "sku" },
|
|
72
|
+
{ label: "Status", key: :status },
|
|
73
|
+
{
|
|
74
|
+
label: "Revenue",
|
|
75
|
+
key: :revenue,
|
|
76
|
+
numeric: true,
|
|
77
|
+
sortable: true,
|
|
78
|
+
sort_key: "revenue",
|
|
79
|
+
header_tooltip: "Total revenue attributed to the current filter window."
|
|
80
|
+
}
|
|
81
|
+
],
|
|
82
|
+
rows: @rows,
|
|
83
|
+
empty_state: "No products found"
|
|
84
|
+
) %>
|
|
85
|
+
<% end %>
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Options
|
|
89
|
+
|
|
90
|
+
- `controls_position: :header` for compact data-view controls in the top-right header zone. Keep `:row` for wider filter bars.
|
|
91
|
+
- `title_meta:` renders subdued inline metadata beside the title, for example `title_meta: "24 rows"`.
|
|
92
|
+
- Sorting is opt-in: header sort controls render only when a column is marked `sortable: true` and the table receives `sort:` plus `sort_path_builder:`.
|
|
93
|
+
- `numeric: true` on a column right-aligns both header and cell. Use it for currency, counts, percentages, and other numeric data. Columns without this flag stay left-aligned regardless of position.
|
|
94
|
+
- `emphasize_last_column: true` makes the last body cell semibold. The default is `false` so numeric final columns are not unexpectedly bold.
|
|
95
|
+
- Use `ui_pagination` directly only when you need bare page navigation without the table-footer composition.
|
|
96
|
+
|
|
97
|
+
## Pagy Integration
|
|
98
|
+
|
|
99
|
+
Baldur does not bundle `pagy` as a runtime dependency, but the table helpers are designed to work with any pagination library. Pagy is the recommended choice for host apps.
|
|
100
|
+
|
|
101
|
+
### Setup
|
|
102
|
+
|
|
103
|
+
Add `pagy` to the host app's Gemfile:
|
|
104
|
+
|
|
105
|
+
```ruby
|
|
106
|
+
gem 'pagy', '~> 43.5'
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Include Pagy in your controller:
|
|
110
|
+
|
|
111
|
+
```ruby
|
|
112
|
+
# app/controllers/application_controller.rb
|
|
113
|
+
class ApplicationController < ActionController::Base
|
|
114
|
+
include Pagy::Method
|
|
115
|
+
end
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### Controller
|
|
119
|
+
|
|
120
|
+
Paginate the collection with Pagy, then pass the result into Baldur's table helpers:
|
|
121
|
+
|
|
122
|
+
```ruby
|
|
123
|
+
# app/controllers/products_controller.rb
|
|
124
|
+
def index
|
|
125
|
+
scope = Product.order(created_at: :desc)
|
|
126
|
+
@pagy, @rows = pagy(:offset, scope, items: params[:per_page].presence&.to_i || 20)
|
|
127
|
+
end
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### View
|
|
131
|
+
|
|
132
|
+
Map Pagy's metadata into `ui_table_footer`:
|
|
133
|
+
|
|
134
|
+
```erb
|
|
135
|
+
<%= ui_table_card(
|
|
136
|
+
title: "Products",
|
|
137
|
+
description: "Track inventory and performance in one place.",
|
|
138
|
+
footer: ui_table_footer(
|
|
139
|
+
current_page: @pagy.page,
|
|
140
|
+
total_pages: @pagy.pages,
|
|
141
|
+
total_count: @pagy.count,
|
|
142
|
+
per_page: @pagy.limit,
|
|
143
|
+
path_builder: ->(page) { products_path(request.query_parameters.merge(page: page, per_page: @pagy.limit)) },
|
|
144
|
+
rows_per_page_param: "per_page",
|
|
145
|
+
rows_per_page_options: [10, 20, 50],
|
|
146
|
+
rows_per_page_selected: @pagy.limit
|
|
147
|
+
)
|
|
148
|
+
) do %>
|
|
149
|
+
<%= ui_table(
|
|
150
|
+
sort: { key: params[:sort], direction: params[:direction] },
|
|
151
|
+
sort_path_builder: ->(sort_key, direction) { products_path(request.query_parameters.merge(sort: sort_key, direction: direction, page: 1)) },
|
|
152
|
+
columns: [
|
|
153
|
+
{ label: "SKU", key: :sku, sortable: true, sort_key: "sku" },
|
|
154
|
+
{ label: "Status", key: :status },
|
|
155
|
+
{ label: "Revenue", key: :revenue, numeric: true, sortable: true, sort_key: "revenue" }
|
|
156
|
+
],
|
|
157
|
+
rows: @rows,
|
|
158
|
+
empty_state: "No products found"
|
|
159
|
+
) %>
|
|
160
|
+
<% end %>
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### Mapping Reference
|
|
164
|
+
|
|
165
|
+
| Pagy attribute | Baldur parameter |
|
|
166
|
+
|---|---|
|
|
167
|
+
| `@pagy.page` | `current_page:` |
|
|
168
|
+
| `@pagy.pages` | `total_pages:` |
|
|
169
|
+
| `@pagy.count` | `total_count:` |
|
|
170
|
+
| `@pagy.limit` | `per_page:` |
|
|
171
|
+
| `@pagy.limit` | `rows_per_page_selected:` |
|
|
172
|
+
|
|
173
|
+
Baldur owns the UI (footer layout, per-page selector, pagination nav). Pagy owns the data-side logic (offset/keyset/countless strategies, count queries). The two stay cleanly separated through the mapping above.
|
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
# Tabs and Segmented Controls
|
|
2
|
+
|
|
3
|
+
## What It Is
|
|
4
|
+
|
|
5
|
+
Use `ui_segmented_buttons` as Baldur's tabs trigger primitive, not only as a visual button group.
|
|
6
|
+
|
|
7
|
+
Today it already renders tab-oriented trigger semantics:
|
|
8
|
+
|
|
9
|
+
- wrapper `role="tablist"`
|
|
10
|
+
- each trigger `role="tab"`
|
|
11
|
+
- `aria-selected`
|
|
12
|
+
- roving `tabindex`
|
|
13
|
+
|
|
14
|
+
Combined with the shipped `segmented_tabs_controller.js`, this is now the documented Baldur tabs pattern for:
|
|
15
|
+
|
|
16
|
+
- selected tab trigger
|
|
17
|
+
- tab panels
|
|
18
|
+
- ARIA wiring
|
|
19
|
+
- keyboard behavior
|
|
20
|
+
- hidden / inactive panel handling
|
|
21
|
+
- hidden input syncing for form-backed workflows
|
|
22
|
+
|
|
23
|
+
## Helper API
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
ui_segmented_buttons(items:, aria_label: "Tabs", classes: nil, id: nil, data: nil)
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
For hidden tab state inside forms, Baldur also ships `ui_hidden_field_tag(name, value = nil, options = {})`. It is a thin wrapper around Rails `hidden_field_tag`, so either helper is acceptable. Prefer `ui_hidden_field_tag` when you want app code to stay on Baldur helper naming consistently.
|
|
30
|
+
|
|
31
|
+
Each item can include:
|
|
32
|
+
|
|
33
|
+
- `id`
|
|
34
|
+
- `label`
|
|
35
|
+
- `value`
|
|
36
|
+
- `selected`
|
|
37
|
+
- `disabled`
|
|
38
|
+
- `icon`
|
|
39
|
+
- `badge_label`
|
|
40
|
+
- `sr_label`
|
|
41
|
+
- `aria`
|
|
42
|
+
- `data`
|
|
43
|
+
- `class`
|
|
44
|
+
|
|
45
|
+
Recommended tab item keys when using segmented buttons as tabs:
|
|
46
|
+
|
|
47
|
+
- `id` for the tab trigger element
|
|
48
|
+
- `aria: { controls: ... }` for panel association
|
|
49
|
+
- `data: { segmented_tabs_target: "tab", tab_value: ... }` for controller wiring
|
|
50
|
+
|
|
51
|
+
## Quick Example
|
|
52
|
+
|
|
53
|
+
Server-render the selected tab from params or controller state and wire panels with `aria-controls` / `aria-labelledby`:
|
|
54
|
+
|
|
55
|
+
```erb
|
|
56
|
+
<% current_tab = params[:tab].presence || "overview" %>
|
|
57
|
+
|
|
58
|
+
<%= ui_segmented_buttons(
|
|
59
|
+
id: "catalog-tabs",
|
|
60
|
+
aria_label: "Catalog tabs",
|
|
61
|
+
items: [
|
|
62
|
+
{
|
|
63
|
+
id: "catalog-tab-overview",
|
|
64
|
+
label: "Overview",
|
|
65
|
+
value: "overview",
|
|
66
|
+
selected: current_tab == "overview",
|
|
67
|
+
aria: { controls: "catalog-panel-overview" }
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
id: "catalog-tab-products",
|
|
71
|
+
label: "Products",
|
|
72
|
+
value: "products",
|
|
73
|
+
selected: current_tab == "products",
|
|
74
|
+
aria: { controls: "catalog-panel-products" }
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
id: "catalog-tab-settings",
|
|
78
|
+
label: "Settings",
|
|
79
|
+
value: "settings",
|
|
80
|
+
selected: current_tab == "settings",
|
|
81
|
+
aria: { controls: "catalog-panel-settings" }
|
|
82
|
+
}
|
|
83
|
+
]
|
|
84
|
+
) %>
|
|
85
|
+
|
|
86
|
+
<section id="catalog-panel-overview"
|
|
87
|
+
role="tabpanel"
|
|
88
|
+
aria-labelledby="catalog-tab-overview"
|
|
89
|
+
class="<%= current_tab == "overview" ? nil : "hidden" %>"
|
|
90
|
+
<%= current_tab == "overview" ? nil : 'hidden' %>
|
|
91
|
+
aria-hidden="<%= (current_tab != "overview").to_s %>">
|
|
92
|
+
...overview content...
|
|
93
|
+
</section>
|
|
94
|
+
|
|
95
|
+
<section id="catalog-panel-products"
|
|
96
|
+
role="tabpanel"
|
|
97
|
+
aria-labelledby="catalog-tab-products"
|
|
98
|
+
class="<%= current_tab == "products" ? nil : "hidden" %>"
|
|
99
|
+
<%= current_tab == "products" ? nil : 'hidden' %>
|
|
100
|
+
aria-hidden="<%= (current_tab != "products").to_s %>">
|
|
101
|
+
...products content...
|
|
102
|
+
</section>
|
|
103
|
+
|
|
104
|
+
<section id="catalog-panel-settings"
|
|
105
|
+
role="tabpanel"
|
|
106
|
+
aria-labelledby="catalog-tab-settings"
|
|
107
|
+
class="<%= current_tab == "settings" ? nil : "hidden" %>"
|
|
108
|
+
<%= current_tab == "settings" ? nil : 'hidden' %>
|
|
109
|
+
aria-hidden="<%= (current_tab != "settings").to_s %>">
|
|
110
|
+
...settings content...
|
|
111
|
+
</section>
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Cookbook
|
|
115
|
+
|
|
116
|
+
### Local Instant Switching
|
|
117
|
+
|
|
118
|
+
`baldur:install` already generates a `segmented_tabs_controller.js` shim. Use it for instant client-side tab switching without a round trip.
|
|
119
|
+
|
|
120
|
+
Controller behavior now includes:
|
|
121
|
+
|
|
122
|
+
- click selection
|
|
123
|
+
- `ArrowLeft` / `ArrowRight` navigation
|
|
124
|
+
- `Home` / `End` navigation
|
|
125
|
+
- roving `tabindex`
|
|
126
|
+
- `.hidden` class toggling
|
|
127
|
+
- `hidden` attribute toggling
|
|
128
|
+
- `aria-hidden` syncing
|
|
129
|
+
- optional hidden input syncing when `hiddenInput` target exists
|
|
130
|
+
|
|
131
|
+
```erb
|
|
132
|
+
<div data-controller="segmented-tabs"
|
|
133
|
+
data-segmented-tabs-active-value="overview">
|
|
134
|
+
<%= ui_segmented_buttons(
|
|
135
|
+
id: "report-tabs",
|
|
136
|
+
aria_label: "Report tabs",
|
|
137
|
+
items: [
|
|
138
|
+
{
|
|
139
|
+
id: "report-tab-overview",
|
|
140
|
+
label: "Overview",
|
|
141
|
+
value: "overview",
|
|
142
|
+
selected: true,
|
|
143
|
+
aria: { controls: "report-panel-overview" },
|
|
144
|
+
data: {
|
|
145
|
+
action: "click->segmented-tabs#select keydown->segmented-tabs#handleKeydown",
|
|
146
|
+
segmented_tabs_target: "tab",
|
|
147
|
+
tab_value: "overview"
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
id: "report-tab-targets",
|
|
152
|
+
label: "Targets",
|
|
153
|
+
value: "targets",
|
|
154
|
+
aria: { controls: "report-panel-targets" },
|
|
155
|
+
data: {
|
|
156
|
+
action: "click->segmented-tabs#select keydown->segmented-tabs#handleKeydown",
|
|
157
|
+
segmented_tabs_target: "tab",
|
|
158
|
+
tab_value: "targets"
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
id: "report-tab-history",
|
|
163
|
+
label: "History",
|
|
164
|
+
value: "history",
|
|
165
|
+
aria: { controls: "report-panel-history" },
|
|
166
|
+
data: {
|
|
167
|
+
action: "click->segmented-tabs#select keydown->segmented-tabs#handleKeydown",
|
|
168
|
+
segmented_tabs_target: "tab",
|
|
169
|
+
tab_value: "history"
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
]
|
|
173
|
+
) %>
|
|
174
|
+
|
|
175
|
+
<section id="report-panel-overview"
|
|
176
|
+
role="tabpanel"
|
|
177
|
+
aria-labelledby="report-tab-overview"
|
|
178
|
+
data-segmented-tabs-target="panel"
|
|
179
|
+
data-tab-value="overview">
|
|
180
|
+
<p>Overview content</p>
|
|
181
|
+
</section>
|
|
182
|
+
|
|
183
|
+
<section id="report-panel-targets"
|
|
184
|
+
role="tabpanel"
|
|
185
|
+
aria-labelledby="report-tab-targets"
|
|
186
|
+
class="hidden"
|
|
187
|
+
hidden
|
|
188
|
+
aria-hidden="true"
|
|
189
|
+
data-segmented-tabs-target="panel"
|
|
190
|
+
data-tab-value="targets">
|
|
191
|
+
<p>Targets content</p>
|
|
192
|
+
</section>
|
|
193
|
+
|
|
194
|
+
<section id="report-panel-history"
|
|
195
|
+
role="tabpanel"
|
|
196
|
+
aria-labelledby="report-tab-history"
|
|
197
|
+
class="hidden"
|
|
198
|
+
hidden
|
|
199
|
+
aria-hidden="true"
|
|
200
|
+
data-segmented-tabs-target="panel"
|
|
201
|
+
data-tab-value="history">
|
|
202
|
+
<p>History content</p>
|
|
203
|
+
</section>
|
|
204
|
+
</div>
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
Use this mode when all tab content is already on the page and switching should feel instant.
|
|
208
|
+
|
|
209
|
+
### Server-Driven / Turbo-Backed Tab Selection
|
|
210
|
+
|
|
211
|
+
For tabs that drive filtering, expensive queries, or frame-local rendering, let server own selected state and re-render the tabs with Turbo. A good pattern is to keep selected tab in params or a hidden field and let Turbo re-render the frame with that state.
|
|
212
|
+
|
|
213
|
+
```erb
|
|
214
|
+
<% current_tab = params[:tab].presence_in(%w[summary imports errors]) || "summary" %>
|
|
215
|
+
|
|
216
|
+
<%= turbo_frame_tag "catalog-tabs" do %>
|
|
217
|
+
<%= form_with url: reports_path,
|
|
218
|
+
method: :get,
|
|
219
|
+
data: {
|
|
220
|
+
controller: "report-tabs",
|
|
221
|
+
turbo_frame: "catalog-tabs",
|
|
222
|
+
report_tabs_current_tab_value: current_tab
|
|
223
|
+
} do %>
|
|
224
|
+
<%= ui_hidden_field_tag :tab, current_tab, data: { report_tabs_target: "hiddenInput" } %>
|
|
225
|
+
|
|
226
|
+
<%= ui_segmented_buttons(
|
|
227
|
+
aria_label: "Catalog tabs",
|
|
228
|
+
items: [
|
|
229
|
+
{
|
|
230
|
+
id: 'catalog-tab-summary',
|
|
231
|
+
label: "Summary",
|
|
232
|
+
value: "summary",
|
|
233
|
+
selected: current_tab == "summary",
|
|
234
|
+
aria: { controls: 'catalog-panel-summary' },
|
|
235
|
+
data: {
|
|
236
|
+
action: "click->report-tabs#submitTab",
|
|
237
|
+
report_tabs_target: "tab",
|
|
238
|
+
report_tab_value: "summary"
|
|
239
|
+
}
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
id: 'catalog-tab-imports',
|
|
243
|
+
label: "Imports",
|
|
244
|
+
value: "imports",
|
|
245
|
+
selected: current_tab == "imports",
|
|
246
|
+
aria: { controls: 'catalog-panel-imports' },
|
|
247
|
+
data: {
|
|
248
|
+
action: "click->report-tabs#submitTab",
|
|
249
|
+
report_tabs_target: "tab",
|
|
250
|
+
report_tab_value: "imports"
|
|
251
|
+
}
|
|
252
|
+
},
|
|
253
|
+
{
|
|
254
|
+
id: 'catalog-tab-errors',
|
|
255
|
+
label: "Errors",
|
|
256
|
+
value: "errors",
|
|
257
|
+
selected: current_tab == "errors",
|
|
258
|
+
aria: { controls: 'catalog-panel-errors' },
|
|
259
|
+
data: {
|
|
260
|
+
action: "click->report-tabs#submitTab",
|
|
261
|
+
report_tabs_target: "tab",
|
|
262
|
+
report_tab_value: "errors"
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
]
|
|
266
|
+
) %>
|
|
267
|
+
<% end %>
|
|
268
|
+
|
|
269
|
+
<div class="mt-4">
|
|
270
|
+
<% case current_tab %>
|
|
271
|
+
<% when "summary" %>
|
|
272
|
+
<section id="catalog-panel-summary" role="tabpanel" aria-labelledby="catalog-tab-summary">
|
|
273
|
+
<%= render "summary" %>
|
|
274
|
+
</section>
|
|
275
|
+
<% when "imports" %>
|
|
276
|
+
<section id="catalog-panel-imports" role="tabpanel" aria-labelledby="catalog-tab-imports">
|
|
277
|
+
<%= render "imports" %>
|
|
278
|
+
</section>
|
|
279
|
+
<% when "errors" %>
|
|
280
|
+
<section id="catalog-panel-errors" role="tabpanel" aria-labelledby="catalog-tab-errors">
|
|
281
|
+
<%= render "errors" %>
|
|
282
|
+
</section>
|
|
283
|
+
<% end %>
|
|
284
|
+
</div>
|
|
285
|
+
<% end %>
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
Example host controller:
|
|
289
|
+
|
|
290
|
+
```js
|
|
291
|
+
import { Controller } from "@hotwired/stimulus"
|
|
292
|
+
|
|
293
|
+
export default class extends Controller {
|
|
294
|
+
static targets = ["hiddenInput"]
|
|
295
|
+
static values = { currentTab: String }
|
|
296
|
+
|
|
297
|
+
submitTab(event) {
|
|
298
|
+
const tab = event.currentTarget.dataset.reportTabValue
|
|
299
|
+
if (!tab) return
|
|
300
|
+
|
|
301
|
+
this.hiddenInputTarget.value = tab
|
|
302
|
+
this.currentTabValue = tab
|
|
303
|
+
this.element.requestSubmit()
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
This pattern keeps selected tab in the URL or request params, makes Turbo frame refreshes deterministic, and avoids hidden local-only state.
|
|
309
|
+
|
|
310
|
+
Use this mode when tab selection changes what the server must load or when the URL should stay shareable.
|
|
311
|
+
|
|
312
|
+
### Preserving Selected Tab Across Form Submits
|
|
313
|
+
|
|
314
|
+
When tabs live inside a form, store selected tab in a hidden field so validation rerenders and submit cycles return user to same panel.
|
|
315
|
+
|
|
316
|
+
```erb
|
|
317
|
+
<% selected_tab = params[:report_tab].presence || "details" %>
|
|
318
|
+
|
|
319
|
+
<%= form_with url: reports_path,
|
|
320
|
+
method: :post,
|
|
321
|
+
data: {
|
|
322
|
+
controller: "report-form-tabs",
|
|
323
|
+
report_form_tabs_current_tab_value: selected_tab
|
|
324
|
+
} do %>
|
|
325
|
+
<%= ui_hidden_field_tag :report_tab,
|
|
326
|
+
selected_tab,
|
|
327
|
+
data: { report_form_tabs_target: "hiddenInput" } %>
|
|
328
|
+
|
|
329
|
+
<%= ui_segmented_buttons(
|
|
330
|
+
id: 'report-form-tabs',
|
|
331
|
+
aria_label: "Report form tabs",
|
|
332
|
+
items: [
|
|
333
|
+
{
|
|
334
|
+
id: 'report-form-tab-details',
|
|
335
|
+
label: "Details",
|
|
336
|
+
value: "details",
|
|
337
|
+
selected: selected_tab == "details",
|
|
338
|
+
aria: { controls: 'report-form-panel-details' },
|
|
339
|
+
data: {
|
|
340
|
+
action: "click->report-form-tabs#switch keydown->report-form-tabs#handleKeydown",
|
|
341
|
+
report_form_tabs_target: "tab",
|
|
342
|
+
report_tab_value: "details"
|
|
343
|
+
}
|
|
344
|
+
},
|
|
345
|
+
{
|
|
346
|
+
id: 'report-form-tab-targets',
|
|
347
|
+
label: "Targets",
|
|
348
|
+
value: "targets",
|
|
349
|
+
selected: selected_tab == "targets",
|
|
350
|
+
aria: { controls: 'report-form-panel-targets' },
|
|
351
|
+
data: {
|
|
352
|
+
action: "click->report-form-tabs#switch keydown->report-form-tabs#handleKeydown",
|
|
353
|
+
report_form_tabs_target: "tab",
|
|
354
|
+
report_tab_value: "targets"
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
]
|
|
358
|
+
) %>
|
|
359
|
+
|
|
360
|
+
<section id="report-form-panel-details"
|
|
361
|
+
role="tabpanel"
|
|
362
|
+
aria-labelledby="report-form-tab-details"
|
|
363
|
+
data-report-form-tabs-target="panel"
|
|
364
|
+
data-report-tab-panel="details"
|
|
365
|
+
class="<%= selected_tab == "details" ? nil : "hidden" %>"
|
|
366
|
+
<%= selected_tab == "details" ? nil : 'hidden' %>
|
|
367
|
+
aria-hidden="<%= (selected_tab != "details").to_s %>">
|
|
368
|
+
...details fields...
|
|
369
|
+
</section>
|
|
370
|
+
|
|
371
|
+
<section id="report-form-panel-targets"
|
|
372
|
+
role="tabpanel"
|
|
373
|
+
aria-labelledby="report-form-tab-targets"
|
|
374
|
+
data-report-form-tabs-target="panel"
|
|
375
|
+
data-report-tab-panel="targets"
|
|
376
|
+
class="<%= selected_tab == "targets" ? nil : "hidden" %>"
|
|
377
|
+
<%= selected_tab == "targets" ? nil : 'hidden' %>
|
|
378
|
+
aria-hidden="<%= (selected_tab != "targets").to_s %>">
|
|
379
|
+
...targets fields...
|
|
380
|
+
</section>
|
|
381
|
+
<% end %>
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
Example host controller for syncing the hidden field:
|
|
385
|
+
|
|
386
|
+
```js
|
|
387
|
+
import { Controller } from "@hotwired/stimulus"
|
|
388
|
+
|
|
389
|
+
export default class extends Controller {
|
|
390
|
+
static targets = ["hiddenInput", "panel", "tab"]
|
|
391
|
+
static values = { currentTab: String }
|
|
392
|
+
|
|
393
|
+
static TAB_KEYS = ["ArrowLeft", "ArrowRight", "Home", "End"]
|
|
394
|
+
|
|
395
|
+
connect() {
|
|
396
|
+
this.show(this.currentTabValue || this.hiddenInputTarget.value || "details")
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
switch(event) {
|
|
400
|
+
const tab = event.currentTarget.dataset.reportTabValue
|
|
401
|
+
if (!tab) return
|
|
402
|
+
|
|
403
|
+
this.show(tab)
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
handleKeydown(event) {
|
|
407
|
+
if (!this.constructor.TAB_KEYS.includes(event.key) || this.tabTargets.length === 0) return
|
|
408
|
+
|
|
409
|
+
event.preventDefault()
|
|
410
|
+
|
|
411
|
+
const currentIndex = this.tabTargets.indexOf(event.currentTarget)
|
|
412
|
+
if (currentIndex === -1) return
|
|
413
|
+
|
|
414
|
+
let nextIndex = currentIndex
|
|
415
|
+
|
|
416
|
+
if (event.key === "Home") {
|
|
417
|
+
nextIndex = 0
|
|
418
|
+
} else if (event.key === "End") {
|
|
419
|
+
nextIndex = this.tabTargets.length - 1
|
|
420
|
+
} else if (event.key === "ArrowRight") {
|
|
421
|
+
nextIndex = (currentIndex + 1) % this.tabTargets.length
|
|
422
|
+
} else if (event.key === "ArrowLeft") {
|
|
423
|
+
nextIndex = (currentIndex - 1 + this.tabTargets.length) % this.tabTargets.length
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const nextTab = this.tabTargets[nextIndex]
|
|
427
|
+
if (!nextTab) return
|
|
428
|
+
|
|
429
|
+
this.show(nextTab.dataset.reportTabValue)
|
|
430
|
+
nextTab.focus()
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
setSubmitTab(event) {
|
|
434
|
+
const tab = event.currentTarget.dataset.reportTabValue
|
|
435
|
+
if (!tab) return
|
|
436
|
+
|
|
437
|
+
this.hiddenInputTarget.value = tab
|
|
438
|
+
this.currentTabValue = tab
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
show(tab) {
|
|
442
|
+
this.hiddenInputTarget.value = tab
|
|
443
|
+
this.currentTabValue = tab
|
|
444
|
+
|
|
445
|
+
this.panelTargets.forEach(panel => {
|
|
446
|
+
const selected = panel.dataset.reportTabPanel === tab
|
|
447
|
+
panel.classList.toggle("hidden", !selected)
|
|
448
|
+
panel.hidden = !selected
|
|
449
|
+
panel.setAttribute("aria-hidden", (!selected).toString())
|
|
450
|
+
})
|
|
451
|
+
|
|
452
|
+
this.tabTargets.forEach(button => {
|
|
453
|
+
const selected = button.dataset.reportTabValue === tab
|
|
454
|
+
button.classList.toggle("is-selected", selected)
|
|
455
|
+
button.setAttribute("aria-selected", selected ? "true" : "false")
|
|
456
|
+
button.tabIndex = selected ? 0 : -1
|
|
457
|
+
})
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
On server rerender, read `params[:report_tab]` and pass that back into `selected:` so selected trigger and panel match previous submit state. If you have submit buttons that intentionally move the user into another tab before submit, call a dedicated `setSubmitTab` action first.
|
|
463
|
+
|
|
464
|
+
Use this mode when tabs live inside POST forms and state must survive validation failure, rerender, or submit-driven transitions.
|
|
465
|
+
|
|
466
|
+
## Accessibility Notes
|
|
467
|
+
|
|
468
|
+
`ui_segmented_buttons` already gives you tab-style trigger semantics, but it is still a primitive rather than a complete APG-perfect tabs system.
|
|
469
|
+
|
|
470
|
+
Current strengths:
|
|
471
|
+
|
|
472
|
+
- `role="tablist"` on wrapper
|
|
473
|
+
- `role="tab"` on triggers
|
|
474
|
+
- `aria-selected`
|
|
475
|
+
- roving `tabindex`
|
|
476
|
+
- keyboard handling via the shipped Stimulus controller (`ArrowLeft`, `ArrowRight`, `Home`, `End`)
|
|
477
|
+
- optional hidden input syncing via the shipped Stimulus controller
|
|
478
|
+
|
|
479
|
+
Current host responsibilities:
|
|
480
|
+
|
|
481
|
+
- render matching `role="tabpanel"` containers
|
|
482
|
+
- wire `id` / `aria-controls` / `aria-labelledby`
|
|
483
|
+
- keep only active panel visible
|
|
484
|
+
- keep `aria-hidden` aligned with hidden state when panels are hidden
|
|
485
|
+
- preserve selected state across params, Turbo renders, or form rerenders
|
|
486
|
+
|
|
487
|
+
Current gaps to be aware of:
|
|
488
|
+
|
|
489
|
+
- panel markup still belongs to the host app
|
|
490
|
+
- there is still no higher-level `ui_tabs` panel DSL
|
|
491
|
+
|
|
492
|
+
So: document and use `ui_segmented_buttons` as tabs trigger primitive, but do not treat it as a full tabs framework yet.
|
|
493
|
+
|
|
494
|
+
## Ownership Boundary
|
|
495
|
+
|
|
496
|
+
Baldur owns:
|
|
497
|
+
|
|
498
|
+
- segmented trigger markup
|
|
499
|
+
- selected-state styling
|
|
500
|
+
- baseline tab trigger semantics
|
|
501
|
+
- optional local switching controller shim
|
|
502
|
+
|
|
503
|
+
Host apps own:
|
|
504
|
+
|
|
505
|
+
- panel markup
|
|
506
|
+
- data loading
|
|
507
|
+
- URL / params / Turbo contract
|
|
508
|
+
- hidden field sync for forms
|
|
509
|
+
- richer accessibility polish beyond current primitive
|