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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +10 -0
  3. data/TODO.md +27 -7
  4. data/app/assets/javascripts/baldur/controllers/segmented_tabs_controller.js +53 -2
  5. data/app/assets/javascripts/baldur/controllers/theme_controller.js +4 -3
  6. data/app/assets/stylesheets/baldur/application/components/auth-page.css +5 -6
  7. data/app/assets/stylesheets/baldur/application/components/table.css +17 -7
  8. data/app/helpers/baldur/ui_helper.rb +11 -2
  9. data/app/helpers/baldur/ui_helper_feedback.rb +19 -2
  10. data/app/views/baldur/components/_button.html.erb +4 -0
  11. data/app/views/baldur/components/_segmented_buttons.html.erb +14 -7
  12. data/app/views/baldur/components/_snackbar_stack.html.erb +10 -6
  13. data/app/views/baldur/components/_table.html.erb +6 -4
  14. data/app/views/baldur/optional/_auth_page.html.erb +2 -2
  15. data/baldur.gemspec +4 -1
  16. data/context7.json +17 -0
  17. data/docs/alerts-and-snackbars.md +72 -0
  18. data/docs/auth.md +66 -0
  19. data/docs/forms.md +267 -0
  20. data/docs/installation.md +63 -0
  21. data/docs/marketing.md +77 -0
  22. data/docs/modals-and-panels.md +55 -0
  23. data/docs/security.md +11 -0
  24. data/docs/sidebar.md +105 -0
  25. data/docs/styling.md +34 -0
  26. data/docs/tables.md +173 -0
  27. data/docs/tabs-and-segmented-controls.md +509 -0
  28. data/docs/theme.md +118 -0
  29. data/lib/baldur/version.rb +1 -1
  30. data/llms-full.txt +179 -0
  31. data/llms.txt +35 -0
  32. data/test/hidden_field_helper_test.rb +23 -0
  33. data/test/segmented_buttons_helper_test.rb +85 -0
  34. data/test/snackbar_stack_helper_test.rb +121 -0
  35. data/test/table_helper_test.rb +118 -0
  36. data/test/text_field_helper_test.rb +40 -0
  37. data/test/theme_toggle_helper_test.rb +2 -0
  38. 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