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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 173fbb6ff876c4440db1b17b3edb60f7d9c9bee99fdf18d59a733c411897d5b1
|
|
4
|
+
data.tar.gz: 6a51c87730719ddb3155a7eb70d7c6ab6f257ce66b0fb0b23a94582434665beb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c02cccdbe76f6e8b19a76dc70fe932b6ccb303a381de63dd9270f1414cd658ec310d5d965f8649c1d44a6fb1f62eeceacc92ab9a0af8a3b33b9ba051836d43b0
|
|
7
|
+
data.tar.gz: fe5a8cbd70bbe930a4f738de6ab52b15ce793ef2c60e1117026d22c6388910c1774e5c879581f28a825651e201b11fc6bb1d5819a8f77355cb120af0ca0ee67b
|
data/README.md
CHANGED
|
@@ -72,12 +72,22 @@ Render a sidebar with navigation and a main content area:
|
|
|
72
72
|
- [Styling](docs/styling.md)
|
|
73
73
|
- [Sidebar](docs/sidebar.md)
|
|
74
74
|
- [Auth](docs/auth.md)
|
|
75
|
+
- [Forms](docs/forms.md)
|
|
75
76
|
- [Modals and Panels](docs/modals-and-panels.md)
|
|
76
77
|
- [Alerts and Snackbars](docs/alerts-and-snackbars.md)
|
|
78
|
+
- [Tabs and Segmented Controls](docs/tabs-and-segmented-controls.md)
|
|
77
79
|
- [Tables](docs/tables.md)
|
|
78
80
|
- [Marketing](docs/marketing.md)
|
|
79
81
|
- [Security](docs/security.md)
|
|
80
82
|
|
|
83
|
+
## LLM / Context7 Docs
|
|
84
|
+
|
|
85
|
+
- [llms.txt](llms.txt) for agent-friendly doc discovery and page summaries.
|
|
86
|
+
- [llms-full.txt](llms-full.txt) for a denser, one-file integration guide.
|
|
87
|
+
- [context7.json](context7.json) scopes Context7 parsing to Baldur's primary docs and agent rules.
|
|
88
|
+
|
|
89
|
+
These files are intended for Context7 and other doc-ingestion tools that look for machine-readable documentation entrypoints.
|
|
90
|
+
|
|
81
91
|
## Security
|
|
82
92
|
|
|
83
93
|
See [docs/security.md](docs/security.md) for artifact verification, MFA requirements, and vulnerability reporting.
|
data/TODO.md
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
# Baldur TODO
|
|
2
2
|
|
|
3
|
-
- [ ] Consider `pagy` gem for tables component
|
|
4
|
-
|
|
5
3
|
## Install and Verification
|
|
6
4
|
- [ ] Harden `baldur:install` so host assumptions are reduced
|
|
7
5
|
- [ ] Audit generated controller shims against all shipped components
|
|
@@ -9,11 +7,11 @@
|
|
|
9
7
|
- [ ] Add end-to-end install verification in dummy app
|
|
10
8
|
|
|
11
9
|
## Showcase App and Docs
|
|
12
|
-
- [ ] Add agent-friendly docs, fetchable by Context7
|
|
13
10
|
- [ ] Add a dedicated dummy app in the extracted gem repo for visual smoke checks
|
|
14
11
|
- [ ] Add a component inventory/showcase page in that dummy app
|
|
15
12
|
- [ ] Add interaction showcase pages for modal, sidebar, menu select, snackbar, and `panel_secondary`
|
|
16
13
|
- [ ] Add copy-paste examples for core surfaces from the showcase app back into docs
|
|
14
|
+
- [ ] Add a dummy-app example showing segmented tabs inside a form with hidden tab state
|
|
17
15
|
|
|
18
16
|
## Starter Templates
|
|
19
17
|
- [ ] Add dedicated password reset templates
|
|
@@ -29,6 +27,31 @@
|
|
|
29
27
|
- [ ] Bind labels, hints, errors, and invalid states automatically
|
|
30
28
|
- [ ] Document model-bound form usage
|
|
31
29
|
|
|
30
|
+
## Buttons
|
|
31
|
+
- [x] `ui_button` should support `formaction` and `formmethod` attributes so host apps can use it as a form-action button (e.g. Turbo-frame-aware submit that posts to a different endpoint)
|
|
32
|
+
- Currently host apps fall back to raw `<button>` or `button_tag` because `ui_button` only forwards `type`, `data`, `aria`, `disabled`, and `form`.
|
|
33
|
+
- Add passthrough for `formaction` and `formmethod` in both `ui_button` helper signature and `baldur/components/button` partial.
|
|
34
|
+
- Accept `value:` and `name:` too so it can double as a named submit button.
|
|
35
|
+
- Verify with a dummy-app example that submits within a Turbo Frame to a custom formaction.
|
|
36
|
+
- Why: keeps host code free of custom HTML button wiring and lets `ui_button` act as a full submit/FAB replacement.
|
|
37
|
+
- [ ] Document interplay between `ui_button`, hidden state fields, and segmented tabs for multi-panel forms
|
|
38
|
+
- avoid host apps inventing ad hoc flow-state patterns just to support review/commit loops
|
|
39
|
+
|
|
40
|
+
## Tabs and Segmented Controls
|
|
41
|
+
- [x] Add a first-class `ui_tabs` primitive or documented tabs pattern built on `ui_segmented_buttons`
|
|
42
|
+
- selected tab trigger
|
|
43
|
+
- tab panels
|
|
44
|
+
- ARIA wiring
|
|
45
|
+
- keyboard behavior
|
|
46
|
+
- hidden / inactive panel handling
|
|
47
|
+
- [x] Provide a small Stimulus controller for segmented-button tabs so host apps do not hand-roll `show/hide` logic
|
|
48
|
+
- [x] Support syncing selected tab into a hidden input for form-backed workflows
|
|
49
|
+
- [x] Document when tabs should be:
|
|
50
|
+
- instant client-side state
|
|
51
|
+
- Turbo GET navigation
|
|
52
|
+
- preserved across POST / redirect / render flows
|
|
53
|
+
- [ ] Consider adding a higher-level `ui_tabs` helper on top of the documented segmented-buttons pattern once host usage stabilizes
|
|
54
|
+
|
|
32
55
|
## Tables and Resource Screens
|
|
33
56
|
- [ ] Add a higher-level resource index pattern on top of existing table primitives
|
|
34
57
|
- [ ] Support search, filters, row actions, bulk select, and empty states
|
|
@@ -37,13 +60,10 @@
|
|
|
37
60
|
|
|
38
61
|
## Theming
|
|
39
62
|
- [ ] Add a small set of starter theme presets
|
|
40
|
-
- [ ] Add first-class `ui_theme_toggle` helper/component so hosts do not need to copy Mimir toggle partial
|
|
41
|
-
- [ ] Improve dark-mode/theme controller documentation
|
|
42
|
-
- [ ] Theme toggle on auth page templates need top rail
|
|
43
|
-
- [ ] Document brand-token customization more clearly
|
|
44
63
|
|
|
45
64
|
## Accessibility
|
|
46
65
|
- [ ] Audit keyboard and focus behavior across interactive components
|
|
66
|
+
- [ ] ADA WCAG 2.1 AA compliance
|
|
47
67
|
- [ ] Add accessibility-focused tests for core surfaces
|
|
48
68
|
- [ ] Document a11y guarantees and known gaps
|
|
49
69
|
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { Controller } from '@hotwired/stimulus'
|
|
2
2
|
|
|
3
3
|
export default class extends Controller {
|
|
4
|
-
static targets = ['tab', 'panel']
|
|
4
|
+
static targets = ['hiddenInput', 'tab', 'panel']
|
|
5
5
|
static values = { active: String }
|
|
6
6
|
|
|
7
|
+
static TAB_KEYS = ['ArrowLeft', 'ArrowRight', 'Home', 'End']
|
|
8
|
+
|
|
7
9
|
connect() {
|
|
8
10
|
const initial = this.activeValue || this.tabTargets[0]?.dataset.tabValue
|
|
9
11
|
if (initial) {
|
|
@@ -19,20 +21,69 @@ export default class extends Controller {
|
|
|
19
21
|
}
|
|
20
22
|
}
|
|
21
23
|
|
|
24
|
+
handleKeydown(event) {
|
|
25
|
+
if (!this.constructor.TAB_KEYS.includes(event.key) || this.tabTargets.length === 0) return
|
|
26
|
+
|
|
27
|
+
event.preventDefault()
|
|
28
|
+
|
|
29
|
+
const currentIndex = this.tabTargets.indexOf(event.currentTarget)
|
|
30
|
+
if (currentIndex === -1) return
|
|
31
|
+
|
|
32
|
+
let nextIndex = currentIndex
|
|
33
|
+
|
|
34
|
+
if (event.key === 'Home') {
|
|
35
|
+
nextIndex = this.nextEnabledIndex(0, 1)
|
|
36
|
+
} else if (event.key === 'End') {
|
|
37
|
+
nextIndex = this.nextEnabledIndex(this.tabTargets.length - 1, -1)
|
|
38
|
+
} else if (event.key === 'ArrowRight') {
|
|
39
|
+
nextIndex = this.nextEnabledIndex((currentIndex + 1) % this.tabTargets.length, 1)
|
|
40
|
+
} else if (event.key === 'ArrowLeft') {
|
|
41
|
+
nextIndex = this.nextEnabledIndex((currentIndex - 1 + this.tabTargets.length) % this.tabTargets.length, -1)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const nextTab = this.tabTargets[nextIndex]
|
|
45
|
+
if (!nextTab) return
|
|
46
|
+
|
|
47
|
+
this.show(nextTab.dataset.tabValue)
|
|
48
|
+
nextTab.focus()
|
|
49
|
+
}
|
|
50
|
+
|
|
22
51
|
show(value) {
|
|
23
52
|
this.tabTargets.forEach((tab) => {
|
|
24
53
|
const selected = tab.dataset.tabValue === value
|
|
25
54
|
tab.classList.toggle('is-selected', selected)
|
|
26
|
-
tab.setAttribute('aria-selected', selected)
|
|
55
|
+
tab.setAttribute('aria-selected', selected ? 'true' : 'false')
|
|
27
56
|
tab.tabIndex = selected ? 0 : -1
|
|
28
57
|
})
|
|
29
58
|
|
|
30
59
|
this.panelTargets.forEach((panel) => {
|
|
31
60
|
const selected = panel.dataset.tabValue === value
|
|
32
61
|
panel.classList.toggle('hidden', !selected)
|
|
62
|
+
panel.hidden = !selected
|
|
33
63
|
panel.setAttribute('aria-hidden', (!selected).toString())
|
|
34
64
|
})
|
|
35
65
|
|
|
66
|
+
if (this.hasHiddenInputTarget) {
|
|
67
|
+
this.hiddenInputTarget.value = value
|
|
68
|
+
}
|
|
69
|
+
|
|
36
70
|
this.activeValue = value
|
|
37
71
|
}
|
|
72
|
+
|
|
73
|
+
nextEnabledIndex(startIndex, direction) {
|
|
74
|
+
let index = startIndex
|
|
75
|
+
|
|
76
|
+
for (let count = 0; count < this.tabTargets.length; count += 1) {
|
|
77
|
+
const tab = this.tabTargets[index]
|
|
78
|
+
if (tab && !this.isDisabled(tab)) return index
|
|
79
|
+
|
|
80
|
+
index = (index + direction + this.tabTargets.length) % this.tabTargets.length
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return startIndex
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
isDisabled(tab) {
|
|
87
|
+
return tab.getAttribute('aria-disabled') === 'true' || tab.disabled
|
|
88
|
+
}
|
|
38
89
|
}
|
|
@@ -27,12 +27,13 @@ export default class ThemeController extends Controller {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
getCurrentTheme() {
|
|
30
|
-
// 1. Check localStorage
|
|
31
30
|
const stored = getFromStorage(this.storageKeyValue);
|
|
32
31
|
if (stored && this.themesValue.includes(stored)) return stored;
|
|
33
32
|
|
|
34
|
-
|
|
35
|
-
return
|
|
33
|
+
const systemTheme = getSystemPreference("color-scheme", this.themesValue);
|
|
34
|
+
if (systemTheme && this.themesValue.includes(systemTheme)) return systemTheme;
|
|
35
|
+
|
|
36
|
+
return this.themesValue[0] || "light";
|
|
36
37
|
}
|
|
37
38
|
|
|
38
39
|
handleToggleChange(toggle) {
|
|
@@ -4,13 +4,12 @@
|
|
|
4
4
|
min-height: 100vh;
|
|
5
5
|
align-items: center;
|
|
6
6
|
justify-content: center;
|
|
7
|
-
padding: var(--space-6);
|
|
8
|
-
background: var(--color-surface-low);
|
|
7
|
+
padding: var(--space-6, 1.5rem);
|
|
8
|
+
background: var(--color-surface-low, var(--color-surface, #f8fafc));
|
|
9
9
|
}
|
|
10
10
|
|
|
11
11
|
.auth-page__container {
|
|
12
|
-
width: 32rem;
|
|
13
|
-
max-width: 100%;
|
|
12
|
+
width: min(32rem, 100%);
|
|
14
13
|
margin-inline: auto;
|
|
15
14
|
}
|
|
16
15
|
|
|
@@ -18,12 +17,12 @@
|
|
|
18
17
|
display: flex;
|
|
19
18
|
align-items: center;
|
|
20
19
|
justify-content: flex-end;
|
|
21
|
-
margin-bottom: var(--space-4);
|
|
20
|
+
margin-bottom: var(--space-4, 1rem);
|
|
22
21
|
}
|
|
23
22
|
|
|
24
23
|
.auth-page__brand {
|
|
25
24
|
display: flex;
|
|
26
25
|
justify-content: center;
|
|
27
|
-
margin-bottom: var(--space-6);
|
|
26
|
+
margin-bottom: var(--space-6, 1.5rem);
|
|
28
27
|
}
|
|
29
28
|
}
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
.table-card__header {
|
|
26
26
|
display: flex;
|
|
27
27
|
flex-wrap: wrap;
|
|
28
|
-
align-items:
|
|
28
|
+
align-items: flex-start;
|
|
29
29
|
justify-content: space-between;
|
|
30
30
|
gap: var(--space-3);
|
|
31
31
|
padding: var(--space-5) var(--space-6);
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
|
|
36
36
|
.table-card__header-main {
|
|
37
37
|
min-width: 0;
|
|
38
|
-
flex: 1 1
|
|
38
|
+
flex: 1 1 auto;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
.table-card__header-title-row {
|
|
@@ -52,7 +52,8 @@
|
|
|
52
52
|
|
|
53
53
|
.table-card__header-side {
|
|
54
54
|
min-width: 0;
|
|
55
|
-
flex:
|
|
55
|
+
flex: 0 0 auto;
|
|
56
|
+
align-self: flex-start;
|
|
56
57
|
display: flex;
|
|
57
58
|
flex-wrap: wrap;
|
|
58
59
|
justify-content: flex-end;
|
|
@@ -216,13 +217,22 @@
|
|
|
216
217
|
background-color: color-mix(in srgb, var(--color-primary) 8%, var(--color-surface-highest));
|
|
217
218
|
}
|
|
218
219
|
|
|
219
|
-
.table tbody td:first-child
|
|
220
|
-
|
|
220
|
+
.table tbody td:first-child {
|
|
221
|
+
font-weight: 600;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/*
|
|
225
|
+
* Opt-in modifier for tables where the last column should be visually
|
|
226
|
+
* emphasized (e.g. a total or primary action column). Default tables leave
|
|
227
|
+
* the last body cell at normal weight so numeric columns are not
|
|
228
|
+
* unexpectedly bold.
|
|
229
|
+
*/
|
|
230
|
+
.table--emphasize-last-column tbody td:last-child {
|
|
221
231
|
font-weight: 600;
|
|
222
232
|
}
|
|
223
233
|
|
|
224
|
-
.table
|
|
225
|
-
.table
|
|
234
|
+
.table th.text-right,
|
|
235
|
+
.table td.text-right {
|
|
226
236
|
text-align: right;
|
|
227
237
|
}
|
|
228
238
|
|
|
@@ -19,6 +19,10 @@ module Baldur
|
|
|
19
19
|
baldur_render 'baldur/components/button', **options
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
+
def ui_hidden_field_tag(name, value = nil, options = {})
|
|
23
|
+
hidden_field_tag(name, value, options)
|
|
24
|
+
end
|
|
25
|
+
|
|
22
26
|
def ui_action_row(primary_button:, secondary_button: nil, extra_buttons: [], classes: nil)
|
|
23
27
|
buttons = []
|
|
24
28
|
buttons << secondary_button if secondary_button.present?
|
|
@@ -214,8 +218,13 @@ module Baldur
|
|
|
214
218
|
action: action
|
|
215
219
|
end
|
|
216
220
|
|
|
217
|
-
def ui_segmented_buttons(items:, aria_label: 'Tabs', classes: nil)
|
|
218
|
-
baldur_render 'baldur/components/segmented_buttons',
|
|
221
|
+
def ui_segmented_buttons(items:, aria_label: 'Tabs', classes: nil, id: nil, data: nil)
|
|
222
|
+
baldur_render 'baldur/components/segmented_buttons',
|
|
223
|
+
items: items,
|
|
224
|
+
aria_label: aria_label,
|
|
225
|
+
classes: classes,
|
|
226
|
+
id: id,
|
|
227
|
+
data: data
|
|
219
228
|
end
|
|
220
229
|
|
|
221
230
|
def ui_tooltip(text:, content:, show_icon: true, icon: 'circle-help', variant: :link, wrapper_class: nil,
|
|
@@ -19,8 +19,21 @@ module Baldur
|
|
|
19
19
|
class_name: class_name
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
-
def ui_snackbar_stack(snackbars: [])
|
|
23
|
-
baldur_render 'baldur/components/snackbar_stack',
|
|
22
|
+
def ui_snackbar_stack(snackbars: [], id: nil, class_name: nil, data: nil)
|
|
23
|
+
baldur_render 'baldur/components/snackbar_stack',
|
|
24
|
+
snackbars: normalize_snackbars(snackbars),
|
|
25
|
+
id: id,
|
|
26
|
+
class_name: class_name,
|
|
27
|
+
data: data
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def ui_snackbar_turbo_stream(flash, target: 'snackbar-stack', **stack_options)
|
|
31
|
+
raise_missing_turbo_stream_helper! unless respond_to?(:turbo_stream)
|
|
32
|
+
|
|
33
|
+
turbo_stream.update(
|
|
34
|
+
target,
|
|
35
|
+
html: ui_snackbar_stack(snackbars: snackbar_flash_payloads(flash), id: target, **stack_options)
|
|
36
|
+
)
|
|
24
37
|
end
|
|
25
38
|
|
|
26
39
|
FLASH_SNACKBAR_VARIANTS = { success: :success, notice: :notice, alert: :error, warning: :warning }.freeze
|
|
@@ -115,5 +128,9 @@ module Baldur
|
|
|
115
128
|
rescue NoMethodError
|
|
116
129
|
false
|
|
117
130
|
end
|
|
131
|
+
|
|
132
|
+
def raise_missing_turbo_stream_helper!
|
|
133
|
+
raise ArgumentError, "ui_snackbar_turbo_stream requires turbo-rails and a Turbo Stream view context"
|
|
134
|
+
end
|
|
118
135
|
end
|
|
119
136
|
end
|
|
@@ -75,6 +75,10 @@
|
|
|
75
75
|
data: local_assigns[:data],
|
|
76
76
|
aria: local_assigns[:aria],
|
|
77
77
|
form: local_assigns[:form],
|
|
78
|
+
formaction: local_assigns[:formaction],
|
|
79
|
+
formmethod: local_assigns[:formmethod],
|
|
80
|
+
value: local_assigns[:value],
|
|
81
|
+
name: local_assigns[:name],
|
|
78
82
|
disabled: disabled do %>
|
|
79
83
|
<%= content %>
|
|
80
84
|
<% end %>
|
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
<%
|
|
2
2
|
segments = Array(items)
|
|
3
3
|
return if segments.empty?
|
|
4
|
+
wrapper_classes = ['segmented', classes].compact.join(' ')
|
|
5
|
+
wrapper_data = (local_assigns[:data] || {}).stringify_keys
|
|
4
6
|
%>
|
|
5
|
-
|
|
7
|
+
<%= tag.div class: wrapper_classes,
|
|
8
|
+
id: local_assigns[:id],
|
|
9
|
+
data: wrapper_data,
|
|
10
|
+
role: 'tablist',
|
|
11
|
+
aria: { label: aria_label } do %>
|
|
6
12
|
<% segments.each do |segment| %>
|
|
13
|
+
<% segment = segment.respond_to?(:symbolize_keys) ? segment.symbolize_keys : segment %>
|
|
7
14
|
<% label = segment[:label].to_s %>
|
|
8
15
|
<% badge_label = segment[:badge_label].to_s.presence %>
|
|
9
16
|
<% icon = segment[:icon].presence %>
|
|
@@ -11,7 +18,9 @@
|
|
|
11
18
|
<% disabled = !!segment[:disabled] %>
|
|
12
19
|
<% value = segment[:value].presence || label.parameterize %>
|
|
13
20
|
<% icon_only = icon.present? && label.blank? %>
|
|
14
|
-
<% data_attributes = (segment[:data] || {}).merge(value
|
|
21
|
+
<% data_attributes = (segment[:data] || {}).stringify_keys.merge('value' => value) %>
|
|
22
|
+
<% button_aria = (segment[:aria] || {}).symbolize_keys %>
|
|
23
|
+
<% button_aria = button_aria.reverse_merge(selected: selected, disabled: disabled) %>
|
|
15
24
|
<% button_classes = [
|
|
16
25
|
"segmented__button",
|
|
17
26
|
("is-selected" if selected),
|
|
@@ -22,11 +31,9 @@
|
|
|
22
31
|
<% button_options = {
|
|
23
32
|
type: "button",
|
|
24
33
|
class: button_classes,
|
|
34
|
+
id: segment[:id],
|
|
25
35
|
role: "tab",
|
|
26
|
-
aria:
|
|
27
|
-
selected: selected,
|
|
28
|
-
disabled: disabled
|
|
29
|
-
},
|
|
36
|
+
aria: button_aria,
|
|
30
37
|
tabindex: selected ? 0 : -1,
|
|
31
38
|
data: data_attributes
|
|
32
39
|
} %>
|
|
@@ -48,4 +55,4 @@
|
|
|
48
55
|
<% end %>
|
|
49
56
|
<% end %>
|
|
50
57
|
<% end %>
|
|
51
|
-
|
|
58
|
+
<% end %>
|
|
@@ -1,8 +1,12 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
<%
|
|
2
|
+
stack_classes = ["snackbar-stack", local_assigns[:class_name]].compact.join(" ")
|
|
3
|
+
stack_data = { "baldur-snackbar-stack" => true }.merge((local_assigns[:data] || {}).stringify_keys)
|
|
4
|
+
%>
|
|
5
|
+
<%= tag.div class: stack_classes,
|
|
6
|
+
id: local_assigns[:id],
|
|
7
|
+
data: stack_data,
|
|
8
|
+
role: "status",
|
|
9
|
+
aria: { live: "polite", atomic: "true" } do %>
|
|
6
10
|
<% Array(snackbars).each do |snackbar| %>
|
|
7
11
|
<%= render "baldur/components/snackbar", **snackbar %>
|
|
8
12
|
<% end %>
|
|
@@ -10,4 +14,4 @@
|
|
|
10
14
|
<template data-baldur-snackbar-template>
|
|
11
15
|
<%= render "baldur/components/snackbar", template: true %>
|
|
12
16
|
</template>
|
|
13
|
-
|
|
17
|
+
<% end %>
|
|
@@ -14,16 +14,18 @@
|
|
|
14
14
|
sort_key = sort_config[:key].to_s
|
|
15
15
|
sort_direction = sort_config[:direction].to_s.downcase == "asc" ? "asc" : "desc"
|
|
16
16
|
sort_path_builder = local_assigns[:sort_path_builder]
|
|
17
|
-
header_classes = "px-6 py-4 text-
|
|
17
|
+
header_classes = "px-6 py-4 text-xs font-semibold uppercase tracking-wide text-[color:var(--color-on-surface-variant)]"
|
|
18
18
|
cell_classes = "px-6 py-4 text-sm text-[color:var(--color-on-surface)]"
|
|
19
|
-
|
|
19
|
+
emphasize_last_column = local_assigns[:emphasize_last_column].present?
|
|
20
|
+
table_modifier_class = emphasize_last_column ? "table--emphasize-last-column" : nil
|
|
21
|
+
table_classes = ["table", table_modifier_class, local_assigns[:table_class], local_assigns[:class]].compact.join(" ")
|
|
20
22
|
%>
|
|
21
23
|
<div class="table-card__scroll">
|
|
22
24
|
<table class="<%= table_classes %>">
|
|
23
25
|
<thead>
|
|
24
26
|
<tr>
|
|
25
27
|
<% normalized_columns.each do |column| %>
|
|
26
|
-
<% column_header_class = [header_classes, column[:header_class]].compact.join(" ") %>
|
|
28
|
+
<% column_header_class = [header_classes, ("text-right" if column[:numeric]), column[:header_class]].compact.join(" ") %>
|
|
27
29
|
<% column_sort_key = (column[:sort_key] || column[:key]).to_s %>
|
|
28
30
|
<% sortable = !!column[:sortable] && sort_path_builder.respond_to?(:call) && column_sort_key.present? %>
|
|
29
31
|
<% sorted = sortable && sort_key == column_sort_key %>
|
|
@@ -106,7 +108,7 @@
|
|
|
106
108
|
%>
|
|
107
109
|
<%= tag.tr(**attributes) do %>
|
|
108
110
|
<% cells.each_with_index do |cell, index| %>
|
|
109
|
-
<% column_cell_class = [cell_classes, normalized_columns[index]&.fetch(:cell_class, nil)].compact.join(" ") %>
|
|
111
|
+
<% column_cell_class = [cell_classes, ("text-right" if normalized_columns[index]&.fetch(:numeric, nil)), normalized_columns[index]&.fetch(:cell_class, nil)].compact.join(" ") %>
|
|
110
112
|
<td class="<%= column_cell_class %>"><%= cell %></td>
|
|
111
113
|
<% end %>
|
|
112
114
|
<% end %>
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
auth_card_class = local_assigns[:card_class]
|
|
5
5
|
%>
|
|
6
6
|
|
|
7
|
-
<div class="<%= auth_shell_classes %>">
|
|
8
|
-
<div class="auth-page__container">
|
|
7
|
+
<div class="<%= auth_shell_classes %> min-h-screen flex items-center justify-center p-6 bg-base-200">
|
|
8
|
+
<div class="auth-page__container w-full max-w-lg mx-auto">
|
|
9
9
|
<% if local_assigns[:top_rail].present? %>
|
|
10
10
|
<div class="auth-page__top-rail">
|
|
11
11
|
<%= local_assigns[:top_rail] %>
|
data/baldur.gemspec
CHANGED
|
@@ -17,8 +17,11 @@ Gem::Specification.new do |spec|
|
|
|
17
17
|
|
|
18
18
|
spec.files = Dir.chdir(__dir__) do
|
|
19
19
|
Dir[
|
|
20
|
-
'{app,config,lib,script,test}/**/*',
|
|
20
|
+
'{app,config,docs,lib,script,test}/**/*',
|
|
21
21
|
'README.md',
|
|
22
|
+
'llms.txt',
|
|
23
|
+
'llms-full.txt',
|
|
24
|
+
'context7.json',
|
|
22
25
|
'TODO.md',
|
|
23
26
|
'LICENSE',
|
|
24
27
|
'SECURITY.md',
|
data/context7.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://context7.com/schema/context7.json",
|
|
3
|
+
"projectTitle": "Baldur",
|
|
4
|
+
"description": "Opinionated Rails UI engine for Propshaft, importmap-rails, stimulus-rails, and tailwindcss-rails host apps.",
|
|
5
|
+
"folders": [
|
|
6
|
+
"docs"
|
|
7
|
+
],
|
|
8
|
+
"excludeFiles": [
|
|
9
|
+
"TODO.md"
|
|
10
|
+
],
|
|
11
|
+
"rules": [
|
|
12
|
+
"Target Rails host apps using Propshaft, importmap-rails, stimulus-rails, and tailwindcss-rails.",
|
|
13
|
+
"Prefer Baldur ui_* helpers over ad hoc duplicated component markup when a helper exists.",
|
|
14
|
+
"Treat optional surfaces as generator-backed and verify install requirements before suggesting their use.",
|
|
15
|
+
"Turbo support is opt-in. ui_snackbar_turbo_stream requires turbo-rails in the host app."
|
|
16
|
+
]
|
|
17
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# Alerts and Snackbars
|
|
2
|
+
|
|
3
|
+
## Alerts
|
|
4
|
+
|
|
5
|
+
Use `ui_alert` for inline status surfaces. Alerts support optional inline actions and opt-in collapsed state:
|
|
6
|
+
|
|
7
|
+
```erb
|
|
8
|
+
<%= ui_alert(
|
|
9
|
+
variant: :warning,
|
|
10
|
+
title: "Data freshness warning",
|
|
11
|
+
actions: ui_button(label: "Upload Latest Data", href: new_ecommerce_import_path, variant: :primary, size: :sm),
|
|
12
|
+
collapsible: true,
|
|
13
|
+
collapse_key: "tenant-#{current_tenant.id}-executive-pulse-stale-data"
|
|
14
|
+
) do %>
|
|
15
|
+
<p>Latest available data is 10 days old.</p>
|
|
16
|
+
<% end %>
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Collapsed alerts stay inline and can be re-expanded with the built-in `More` summary action.
|
|
20
|
+
|
|
21
|
+
## Snackbars
|
|
22
|
+
|
|
23
|
+
Use semantic snackbar tones:
|
|
24
|
+
|
|
25
|
+
- `:success` for green success states
|
|
26
|
+
- `:notice` for blue notice/info states
|
|
27
|
+
- `:warning` for amber warning states
|
|
28
|
+
- `:error` for red error states
|
|
29
|
+
|
|
30
|
+
Host apps should map `flash[:notice]` to `:notice` and `flash[:alert]` to `:error` unless they have a stronger semantic signal available.
|
|
31
|
+
|
|
32
|
+
### Snackbar stack layout
|
|
33
|
+
|
|
34
|
+
Give the global stack an `id` so it can be targeted from the server:
|
|
35
|
+
|
|
36
|
+
```erb
|
|
37
|
+
<%= ui_snackbar_stack(
|
|
38
|
+
snackbars: snackbar_flash_payloads(flash),
|
|
39
|
+
id: "snackbar-stack"
|
|
40
|
+
) %>
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Use `class_name:` and `data:` to extend the wrapper. Baldur always keeps the base class `snackbar-stack` and the `data-baldur-snackbar-stack` hook.
|
|
44
|
+
|
|
45
|
+
### Turbo Stream updates
|
|
46
|
+
|
|
47
|
+
For Hotwire apps, call `ui_snackbar_turbo_stream` from a Turbo Stream view to refresh the stack in place:
|
|
48
|
+
|
|
49
|
+
```erb
|
|
50
|
+
<%# app/views/users/create.turbo_stream.erb %>
|
|
51
|
+
<%= ui_snackbar_turbo_stream(flash) %>
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
This is the same as:
|
|
55
|
+
|
|
56
|
+
```erb
|
|
57
|
+
<%= turbo_stream.update(
|
|
58
|
+
"snackbar-stack",
|
|
59
|
+
html: ui_snackbar_stack(
|
|
60
|
+
snackbars: snackbar_flash_payloads(flash),
|
|
61
|
+
id: "snackbar-stack"
|
|
62
|
+
)
|
|
63
|
+
) %>
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Change the target with `target:`:
|
|
67
|
+
|
|
68
|
+
```erb
|
|
69
|
+
<%= ui_snackbar_turbo_stream(flash, target: "frame-snackbar-stack") %>
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
`ui_snackbar_turbo_stream` is opt-in. If the host app does not use `turbo-rails`, calling it raises a clear `ArgumentError` at runtime. Non-Turbo apps can continue using `ui_snackbar_stack` with flash payloads the normal way.
|
data/docs/auth.md
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# Auth
|
|
2
|
+
|
|
3
|
+
## When to Use
|
|
4
|
+
|
|
5
|
+
Use `ui_auth_page` for sign-in, sign-up, and password-reset layouts. It is available after base install.
|
|
6
|
+
|
|
7
|
+
## Quick Example
|
|
8
|
+
|
|
9
|
+
```erb
|
|
10
|
+
<%= ui_auth_page(title: "Sign in", description: nil, brand_path: root_path) do %>
|
|
11
|
+
<%= yield %>
|
|
12
|
+
<% end %>
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Flash Messages
|
|
16
|
+
|
|
17
|
+
Auth flash messaging can be rendered inside the card by passing `notice:` / `alert:`:
|
|
18
|
+
|
|
19
|
+
```erb
|
|
20
|
+
<%= ui_auth_page(title: "Sign in", description: nil, brand_path: root_path, notice: notice, alert: alert) do %>
|
|
21
|
+
<%= yield %>
|
|
22
|
+
<% end %>
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Top Rail
|
|
26
|
+
|
|
27
|
+
Pass `top_rail:` to add an action bar above the brand lockup. The most common use is a theme toggle:
|
|
28
|
+
|
|
29
|
+
```erb
|
|
30
|
+
<%= ui_auth_page(
|
|
31
|
+
title: "Sign in",
|
|
32
|
+
description: nil,
|
|
33
|
+
brand_path: root_path,
|
|
34
|
+
top_rail: ui_theme_toggle,
|
|
35
|
+
notice: notice,
|
|
36
|
+
alert: alert
|
|
37
|
+
) do %>
|
|
38
|
+
<%= yield %>
|
|
39
|
+
<% end %>
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
The `top_rail` slot accepts any rendered content, so it works for other controls too:
|
|
43
|
+
|
|
44
|
+
```erb
|
|
45
|
+
<%= ui_auth_page(
|
|
46
|
+
title: "Sign in",
|
|
47
|
+
description: nil,
|
|
48
|
+
brand_path: root_path,
|
|
49
|
+
top_rail: "<span class='text-sm text-base-content/60'>v2.1.0</span>".html_safe
|
|
50
|
+
) do %>
|
|
51
|
+
<%= yield %>
|
|
52
|
+
<% end %>
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
The host must mount `data-controller="theme"` on a parent element (typically `<body>`) for the theme toggle to function. See `docs/theme.md` for full controller documentation.
|
|
56
|
+
|
|
57
|
+
## Theme Toggle
|
|
58
|
+
|
|
59
|
+
`ui_theme_toggle` renders a light/dark switch wired to the Baldur theme controller:
|
|
60
|
+
|
|
61
|
+
```erb
|
|
62
|
+
<%= ui_theme_toggle %>
|
|
63
|
+
<%= ui_theme_toggle(aria_label: "Switch appearance", classes: "my-extra-class") %>
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Requires `data-controller="theme"` on a parent element. See `docs/theme.md` for setup.
|