better_ui 0.7.2 → 0.8.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 +37 -23
- data/app/components/better_ui/drawer/sidebar_component.rb +1 -0
- data/app/components/better_ui/tabs/container_component/container_component.html.erb +40 -0
- data/app/components/better_ui/tabs/container_component.rb +428 -0
- data/app/components/better_ui/tabs/panel_component/panel_component.html.erb +3 -0
- data/app/components/better_ui/tabs/panel_component.rb +105 -0
- data/app/components/better_ui/tabs/tab_component/tab_component.html.erb +9 -0
- data/app/components/better_ui/tabs/tab_component.rb +316 -0
- data/app/helpers/better_ui/application_helper.rb +74 -0
- data/lib/better_ui/engine.rb +7 -0
- data/lib/better_ui/version.rb +1 -1
- metadata +7 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8b3c7255ce65e6a53ed2ee42174475f656461303920e1a4e6c9912fe3ecd4d4a
|
|
4
|
+
data.tar.gz: 4c4c5063560d6edcb534fd1856869928e4b3f0456e7d86dea76f216bb3c4ae2e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b06273f6f5fbdd050f7f1fcdc20b1a15663af200f25d076472d31c77bc953e39a68db31633de2f64c888e0a0f0459c1fa8b75cefd4dc919bb4a18f2397da09de
|
|
7
|
+
data.tar.gz: 1ecb849cc505cda578e2c93493dcc8464e52ca3e64d55a618cb3d4181b1d6eb55130c0a3173c93d3761539ac0fd402e0c52a4a3a050b8df291f5dece55095739
|
data/README.md
CHANGED
|
@@ -44,13 +44,9 @@ registerControllers(application)
|
|
|
44
44
|
/* @import "@pandev-srl/better-ui/utilities"; */
|
|
45
45
|
```
|
|
46
46
|
|
|
47
|
+
**Start using components** - `bui_*` helpers are available automatically:
|
|
47
48
|
```erb
|
|
48
|
-
|
|
49
|
-
<%= render BetterUi::ButtonComponent.new(
|
|
50
|
-
label: "Get Started",
|
|
51
|
-
variant: "primary",
|
|
52
|
-
size: "lg"
|
|
53
|
-
) %>
|
|
49
|
+
<%= bui_button(label: "Get Started", variant: :primary, size: :lg) %>
|
|
54
50
|
```
|
|
55
51
|
|
|
56
52
|
## Features
|
|
@@ -59,6 +55,7 @@ registerControllers(application)
|
|
|
59
55
|
- **ViewComponent Architecture**: Encapsulated, testable, and reusable components
|
|
60
56
|
- **Tailwind CSS v4**: Leverages the latest Tailwind features with OKLCH color space
|
|
61
57
|
- **Fully Customizable**: 9 semantic color variants with complete theme control
|
|
58
|
+
- **View Helpers**: Concise `bui_*` helpers available automatically (no setup required)
|
|
62
59
|
- **Form Builder Integration**: Seamless integration with Rails forms via `UiFormBuilder`
|
|
63
60
|
- **Stimulus Controllers**: Interactive components with built-in JavaScript behaviors
|
|
64
61
|
- **Accessible by Default**: ARIA attributes and keyboard navigation support
|
|
@@ -86,11 +83,7 @@ A versatile button component with multiple styles, sizes, and states.
|
|
|
86
83
|
- **Features**: Loading states, icons, disabled states
|
|
87
84
|
|
|
88
85
|
```erb
|
|
89
|
-
<%=
|
|
90
|
-
label: "Save Changes",
|
|
91
|
-
variant: "success",
|
|
92
|
-
style: "solid"
|
|
93
|
-
) do |c| %>
|
|
86
|
+
<%= bui_button(label: "Save Changes", variant: :success, style: :solid) do |c| %>
|
|
94
87
|
<% c.with_icon_before { "💾" } %>
|
|
95
88
|
<% end %>
|
|
96
89
|
```
|
|
@@ -103,7 +96,7 @@ A flexible container component with customizable padding and optional slots.
|
|
|
103
96
|
- **Slots**: header, body, footer
|
|
104
97
|
|
|
105
98
|
```erb
|
|
106
|
-
<%=
|
|
99
|
+
<%= bui_card(size: :lg, style: :bordered) do |c| %>
|
|
107
100
|
<% c.with_header { "Card Title" } %>
|
|
108
101
|
<% c.with_body { "Card content goes here" } %>
|
|
109
102
|
<% c.with_footer { "Footer content" } %>
|
|
@@ -117,8 +110,8 @@ Display notifications, alerts, and validation messages with style.
|
|
|
117
110
|
- **Features**: Dismissible, auto-dismiss, titles, icons
|
|
118
111
|
|
|
119
112
|
```erb
|
|
120
|
-
<%=
|
|
121
|
-
variant:
|
|
113
|
+
<%= bui_action_messages(
|
|
114
|
+
variant: :danger,
|
|
122
115
|
title: "Validation Errors",
|
|
123
116
|
messages: @model.errors.full_messages,
|
|
124
117
|
dismissible: true,
|
|
@@ -132,7 +125,7 @@ Display notifications, alerts, and validation messages with style.
|
|
|
132
125
|
Standard text input with error handling and icon support.
|
|
133
126
|
|
|
134
127
|
```erb
|
|
135
|
-
<%=
|
|
128
|
+
<%= bui_text_input(
|
|
136
129
|
name: "user[email]",
|
|
137
130
|
label: "Email Address",
|
|
138
131
|
hint: "We'll never share your email",
|
|
@@ -146,7 +139,7 @@ Standard text input with error handling and icon support.
|
|
|
146
139
|
Numeric input with min/max validation and optional spinners.
|
|
147
140
|
|
|
148
141
|
```erb
|
|
149
|
-
<%=
|
|
142
|
+
<%= bui_number_input(
|
|
150
143
|
name: "product[price]",
|
|
151
144
|
label: "Price",
|
|
152
145
|
min: 0,
|
|
@@ -161,7 +154,7 @@ Numeric input with min/max validation and optional spinners.
|
|
|
161
154
|
Password field with visibility toggle functionality.
|
|
162
155
|
|
|
163
156
|
```erb
|
|
164
|
-
<%=
|
|
157
|
+
<%= bui_password_input(
|
|
165
158
|
name: "user[password]",
|
|
166
159
|
label: "Password",
|
|
167
160
|
hint: "Minimum 8 characters"
|
|
@@ -172,7 +165,7 @@ Password field with visibility toggle functionality.
|
|
|
172
165
|
Multi-line text input with resizing options.
|
|
173
166
|
|
|
174
167
|
```erb
|
|
175
|
-
<%=
|
|
168
|
+
<%= bui_textarea(
|
|
176
169
|
name: "post[content]",
|
|
177
170
|
label: "Content",
|
|
178
171
|
rows: 6,
|
|
@@ -185,7 +178,7 @@ Multi-line text input with resizing options.
|
|
|
185
178
|
Single checkbox with color variants and label positioning.
|
|
186
179
|
|
|
187
180
|
```erb
|
|
188
|
-
<%=
|
|
181
|
+
<%= bui_checkbox(
|
|
189
182
|
name: "user[terms]",
|
|
190
183
|
label: "I agree to the terms and conditions",
|
|
191
184
|
variant: :primary
|
|
@@ -196,7 +189,7 @@ Single checkbox with color variants and label positioning.
|
|
|
196
189
|
Multiple checkboxes for selecting from a collection.
|
|
197
190
|
|
|
198
191
|
```erb
|
|
199
|
-
<%=
|
|
192
|
+
<%= bui_checkbox_group(
|
|
200
193
|
name: "user[roles]",
|
|
201
194
|
collection: [["Admin", "admin"], ["Editor", "editor"]],
|
|
202
195
|
legend: "User Roles",
|
|
@@ -215,13 +208,13 @@ BetterUi provides a complete drawer layout system for building responsive admin
|
|
|
215
208
|
- **NavGroupComponent**: Grouped navigation with titles
|
|
216
209
|
|
|
217
210
|
```erb
|
|
218
|
-
<%=
|
|
211
|
+
<%= bui_drawer_layout do |layout| %>
|
|
219
212
|
<% layout.with_header do |header| %>
|
|
220
213
|
<% header.with_logo { "MyApp" } %>
|
|
221
214
|
<% end %>
|
|
222
215
|
<% layout.with_sidebar do |sidebar| %>
|
|
223
216
|
<% sidebar.with_navigation do %>
|
|
224
|
-
<%=
|
|
217
|
+
<%= bui_drawer_nav_group(title: "Menu") do |group| %>
|
|
225
218
|
<% group.with_item(label: "Dashboard", href: "/", active: true) %>
|
|
226
219
|
<% group.with_item(label: "Settings", href: "/settings") %>
|
|
227
220
|
<% end %>
|
|
@@ -247,6 +240,27 @@ BetterUi includes a custom form builder for seamless Rails form integration:
|
|
|
247
240
|
<% end %>
|
|
248
241
|
```
|
|
249
242
|
|
|
243
|
+
## Available View Helpers
|
|
244
|
+
|
|
245
|
+
| Helper | Component |
|
|
246
|
+
|--------|-----------|
|
|
247
|
+
| `bui_button` | ButtonComponent |
|
|
248
|
+
| `bui_card` | CardComponent |
|
|
249
|
+
| `bui_action_messages` | ActionMessagesComponent |
|
|
250
|
+
| `bui_text_input` | Forms::TextInputComponent |
|
|
251
|
+
| `bui_number_input` | Forms::NumberInputComponent |
|
|
252
|
+
| `bui_password_input` | Forms::PasswordInputComponent |
|
|
253
|
+
| `bui_textarea` | Forms::TextareaComponent |
|
|
254
|
+
| `bui_checkbox` | Forms::CheckboxComponent |
|
|
255
|
+
| `bui_checkbox_group` | Forms::CheckboxGroupComponent |
|
|
256
|
+
| `bui_drawer_layout` | Drawer::LayoutComponent |
|
|
257
|
+
| `bui_drawer_sidebar` | Drawer::SidebarComponent |
|
|
258
|
+
| `bui_drawer_header` | Drawer::HeaderComponent |
|
|
259
|
+
| `bui_drawer_nav_item` | Drawer::NavItemComponent |
|
|
260
|
+
| `bui_drawer_nav_group` | Drawer::NavGroupComponent |
|
|
261
|
+
|
|
262
|
+
> **Note**: You can also use ViewComponent directly with `render BetterUi::*Component.new(...)` if you prefer the explicit rendering syntax.
|
|
263
|
+
|
|
250
264
|
## Documentation
|
|
251
265
|
|
|
252
266
|
- [**Installation Guide**](doc/INSTALLATION.md) - Detailed setup and configuration instructions
|
|
@@ -343,4 +357,4 @@ Powered by:
|
|
|
343
357
|
|
|
344
358
|
## Acknowledgments
|
|
345
359
|
|
|
346
|
-
Special thanks to the Ruby on Rails community and all contributors who help make BetterUi better.
|
|
360
|
+
Special thanks to the Ruby on Rails community and all contributors who help make BetterUi better.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
<div class="<%= component_classes %>" <%= tag.attributes(html_attributes) %>>
|
|
2
|
+
<%# Tab list %>
|
|
3
|
+
<div role="tablist"
|
|
4
|
+
aria-label="Tabs"
|
|
5
|
+
class="<%= tablist_classes %> <%= tablist_order_class %>">
|
|
6
|
+
<% tabs.each do |tab| %>
|
|
7
|
+
<%= tab %>
|
|
8
|
+
<% end %>
|
|
9
|
+
</div>
|
|
10
|
+
|
|
11
|
+
<%# Panels container (only for JS mode) %>
|
|
12
|
+
<% if mode == :js && panels.any? %>
|
|
13
|
+
<div class="<%= panels_classes %> <%= panels_order_class %>">
|
|
14
|
+
<% panels.each do |panel| %>
|
|
15
|
+
<%= panel %>
|
|
16
|
+
<% end %>
|
|
17
|
+
</div>
|
|
18
|
+
<% end %>
|
|
19
|
+
|
|
20
|
+
<%# Loading overlay for Turbo mode %>
|
|
21
|
+
<% if mode == :turbo && show_loading %>
|
|
22
|
+
<div data-<%= controller_name %>-target="loader"
|
|
23
|
+
class="hidden fixed inset-0 z-50 pointer-events-none"
|
|
24
|
+
aria-hidden="true"
|
|
25
|
+
aria-live="polite">
|
|
26
|
+
<% if loader? %>
|
|
27
|
+
<%= loader %>
|
|
28
|
+
<% else %>
|
|
29
|
+
<div class="absolute bg-white/80 dark:bg-grayscale-900/80 flex items-center justify-center"
|
|
30
|
+
data-<%= controller_name %>-target="loaderOverlay">
|
|
31
|
+
<svg class="animate-spin h-8 w-8 <%= spinner_color_class %>" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
|
32
|
+
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
33
|
+
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
34
|
+
</svg>
|
|
35
|
+
<span class="sr-only">Caricamento...</span>
|
|
36
|
+
</div>
|
|
37
|
+
<% end %>
|
|
38
|
+
</div>
|
|
39
|
+
<% end %>
|
|
40
|
+
</div>
|
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterUi
|
|
4
|
+
module Tabs
|
|
5
|
+
# A flexible tabs container component with two operating modes.
|
|
6
|
+
#
|
|
7
|
+
# This component provides a tabbed interface with support for:
|
|
8
|
+
# - **JS mode**: Client-side tab switching (all content in DOM)
|
|
9
|
+
# - **Turbo mode**: Server-rendered content via Turbo Frames
|
|
10
|
+
#
|
|
11
|
+
# @example Basic JS mode tabs
|
|
12
|
+
# <%= render BetterUi::Tabs::ContainerComponent.new(mode: :js) do |tabs| %>
|
|
13
|
+
# <% tabs.with_tab(id: "profile", label: "Profile", active: true) %>
|
|
14
|
+
# <% tabs.with_tab(id: "settings", label: "Settings") %>
|
|
15
|
+
# <% tabs.with_panel(id: "profile", active: true) { "Profile content" } %>
|
|
16
|
+
# <% tabs.with_panel(id: "settings") { "Settings content" } %>
|
|
17
|
+
# <% end %>
|
|
18
|
+
#
|
|
19
|
+
# @example Turbo mode with Turbo Frames
|
|
20
|
+
# <%= render BetterUi::Tabs::ContainerComponent.new(mode: :turbo, frame_id: "tab-content") do |tabs| %>
|
|
21
|
+
# <% tabs.with_tab(id: "profile", label: "Profile", href: profile_path, active: true) %>
|
|
22
|
+
# <% tabs.with_tab(id: "settings", label: "Settings", href: settings_path) %>
|
|
23
|
+
# <% end %>
|
|
24
|
+
# <turbo-frame id="tab-content"><%= yield %></turbo-frame>
|
|
25
|
+
class ContainerComponent < ApplicationComponent
|
|
26
|
+
# Valid operating modes
|
|
27
|
+
MODES = %i[js turbo].freeze
|
|
28
|
+
|
|
29
|
+
# Tab visual styles
|
|
30
|
+
STYLES = %i[underline pills bordered].freeze
|
|
31
|
+
|
|
32
|
+
# Size configurations with tab-specific styling
|
|
33
|
+
SIZES = %i[xs sm md lg xl].freeze
|
|
34
|
+
|
|
35
|
+
# Tab alignment options
|
|
36
|
+
ALIGNMENTS = %i[start center end stretch].freeze
|
|
37
|
+
|
|
38
|
+
# Tab list positions
|
|
39
|
+
POSITIONS = %i[top bottom left right].freeze
|
|
40
|
+
|
|
41
|
+
# @!method with_tab
|
|
42
|
+
# Slot for rendering tab buttons/links
|
|
43
|
+
# @yieldparam [BetterUi::Tabs::TabComponent] tab the tab component instance
|
|
44
|
+
# @yieldreturn [String] the HTML content for the tab
|
|
45
|
+
renders_many :tabs, lambda { |**args|
|
|
46
|
+
TabComponent.new(
|
|
47
|
+
**args,
|
|
48
|
+
mode: @mode,
|
|
49
|
+
style: @style,
|
|
50
|
+
variant: @variant,
|
|
51
|
+
size: @size,
|
|
52
|
+
frame_id: @frame_id,
|
|
53
|
+
container_id: container_id
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
# @!method with_panel
|
|
58
|
+
# Slot for rendering tab panels (JS mode only)
|
|
59
|
+
# @yieldparam [BetterUi::Tabs::PanelComponent] panel the panel component instance
|
|
60
|
+
# @yieldreturn [String] the HTML content for the panel
|
|
61
|
+
renders_many :panels, lambda { |**args|
|
|
62
|
+
PanelComponent.new(**args, container_id: container_id)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
# @!method with_loader
|
|
66
|
+
# Slot for rendering a custom loader (Turbo mode only)
|
|
67
|
+
# @yieldreturn [String] the HTML content for the custom loader
|
|
68
|
+
renders_one :loader
|
|
69
|
+
|
|
70
|
+
# Initializes a new tabs container component.
|
|
71
|
+
#
|
|
72
|
+
# @param mode [Symbol] operating mode (:js, :turbo), defaults to :js
|
|
73
|
+
# @param style [Symbol] visual style (:underline, :pills, :bordered), defaults to :underline
|
|
74
|
+
# @param variant [Symbol] color variant from VARIANTS, defaults to :primary
|
|
75
|
+
# @param size [Symbol] size (:xs, :sm, :md, :lg, :xl), defaults to :md
|
|
76
|
+
# @param alignment [Symbol] tab alignment (:start, :center, :end, :stretch), defaults to :start
|
|
77
|
+
# @param position [Symbol] tab list position (:top, :bottom, :left, :right), defaults to :top
|
|
78
|
+
# @param frame_id [String, nil] Turbo Frame ID (required for turbo mode)
|
|
79
|
+
# @param default_tab [String, nil] ID of the default active tab
|
|
80
|
+
# @param persist [Boolean] persist active tab in URL hash or localStorage
|
|
81
|
+
# @param persist_key [String, nil] localStorage key for persistence
|
|
82
|
+
# @param show_loading [Boolean, nil] show loading indicator in turbo mode (default: true for turbo)
|
|
83
|
+
# @param loader_delay [Integer] delay in milliseconds before showing the loader (default: 1000)
|
|
84
|
+
# @param id [String, nil] explicit ID for the container (auto-generated if nil)
|
|
85
|
+
# @param options [Hash] additional HTML attributes
|
|
86
|
+
#
|
|
87
|
+
# @raise [ArgumentError] if mode is invalid
|
|
88
|
+
# @raise [ArgumentError] if style is invalid
|
|
89
|
+
# @raise [ArgumentError] if size is invalid
|
|
90
|
+
# @raise [ArgumentError] if alignment is invalid
|
|
91
|
+
# @raise [ArgumentError] if position is invalid
|
|
92
|
+
# @raise [ArgumentError] if turbo mode is used without frame_id
|
|
93
|
+
def initialize(
|
|
94
|
+
mode: :js,
|
|
95
|
+
style: :underline,
|
|
96
|
+
variant: :primary,
|
|
97
|
+
size: :md,
|
|
98
|
+
alignment: :start,
|
|
99
|
+
position: :top,
|
|
100
|
+
frame_id: nil,
|
|
101
|
+
default_tab: nil,
|
|
102
|
+
persist: false,
|
|
103
|
+
persist_key: nil,
|
|
104
|
+
show_loading: nil,
|
|
105
|
+
loader_delay: 1000,
|
|
106
|
+
id: nil,
|
|
107
|
+
**options
|
|
108
|
+
)
|
|
109
|
+
@mode = validate_mode(mode)
|
|
110
|
+
@style = validate_style(style)
|
|
111
|
+
@variant = validate_variant(variant)
|
|
112
|
+
@size = validate_size(size)
|
|
113
|
+
@alignment = validate_alignment(alignment)
|
|
114
|
+
@position = validate_position(position)
|
|
115
|
+
@frame_id = frame_id
|
|
116
|
+
@default_tab = default_tab
|
|
117
|
+
@persist = persist
|
|
118
|
+
@persist_key = persist_key
|
|
119
|
+
@show_loading = show_loading.nil? ? (@mode == :turbo) : show_loading
|
|
120
|
+
@loader_delay = loader_delay
|
|
121
|
+
@explicit_id = id
|
|
122
|
+
@options = options
|
|
123
|
+
|
|
124
|
+
validate_turbo_requirements!
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Returns the operating mode.
|
|
128
|
+
# @return [Symbol] the mode (:js or :turbo)
|
|
129
|
+
attr_reader :mode
|
|
130
|
+
|
|
131
|
+
# Returns the visual style.
|
|
132
|
+
# @return [Symbol] the style (:underline, :pills, or :bordered)
|
|
133
|
+
attr_reader :style
|
|
134
|
+
|
|
135
|
+
# Returns the color variant.
|
|
136
|
+
# @return [Symbol] the variant
|
|
137
|
+
attr_reader :variant
|
|
138
|
+
|
|
139
|
+
# Returns the size.
|
|
140
|
+
# @return [Symbol] the size
|
|
141
|
+
attr_reader :size
|
|
142
|
+
|
|
143
|
+
# Returns the tab alignment.
|
|
144
|
+
# @return [Symbol] the alignment
|
|
145
|
+
attr_reader :alignment
|
|
146
|
+
|
|
147
|
+
# Returns the tab list position.
|
|
148
|
+
# @return [Symbol] the position
|
|
149
|
+
attr_reader :position
|
|
150
|
+
|
|
151
|
+
# Returns the Turbo Frame ID.
|
|
152
|
+
# @return [String, nil] the frame_id
|
|
153
|
+
attr_reader :frame_id
|
|
154
|
+
|
|
155
|
+
# Returns the default active tab ID.
|
|
156
|
+
# @return [String, nil] the default_tab
|
|
157
|
+
attr_reader :default_tab
|
|
158
|
+
|
|
159
|
+
# Returns whether persistence is enabled.
|
|
160
|
+
# @return [Boolean] the persist flag
|
|
161
|
+
attr_reader :persist
|
|
162
|
+
|
|
163
|
+
# Returns the persistence key for localStorage.
|
|
164
|
+
# @return [String, nil] the persist_key
|
|
165
|
+
attr_reader :persist_key
|
|
166
|
+
|
|
167
|
+
# Returns whether loading indicator is shown in turbo mode.
|
|
168
|
+
# @return [Boolean] the show_loading flag
|
|
169
|
+
attr_reader :show_loading
|
|
170
|
+
|
|
171
|
+
# Returns the loader delay in milliseconds.
|
|
172
|
+
# @return [Integer] the loader_delay value
|
|
173
|
+
attr_reader :loader_delay
|
|
174
|
+
|
|
175
|
+
# Generates a unique container ID.
|
|
176
|
+
# @return [String] the container ID
|
|
177
|
+
def container_id
|
|
178
|
+
@container_id ||= @explicit_id || "tabs-#{SecureRandom.hex(4)}"
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
private
|
|
182
|
+
|
|
183
|
+
# Returns the complete CSS classes for the container.
|
|
184
|
+
# @return [String] the merged CSS class string
|
|
185
|
+
# @api private
|
|
186
|
+
def component_classes
|
|
187
|
+
css_classes([
|
|
188
|
+
"bui-tabs",
|
|
189
|
+
position_layout_class,
|
|
190
|
+
@options[:class]
|
|
191
|
+
].compact)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Returns layout class based on position.
|
|
195
|
+
# @return [String] the layout class
|
|
196
|
+
# @api private
|
|
197
|
+
def position_layout_class
|
|
198
|
+
case @position
|
|
199
|
+
when :left, :right then "flex"
|
|
200
|
+
else ""
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Returns CSS classes for the tab list container.
|
|
205
|
+
# @return [String] the merged CSS class string
|
|
206
|
+
# @api private
|
|
207
|
+
def tablist_classes
|
|
208
|
+
css_classes([
|
|
209
|
+
"bui-tabs__list",
|
|
210
|
+
"flex",
|
|
211
|
+
tablist_direction_class,
|
|
212
|
+
alignment_class,
|
|
213
|
+
style_container_class
|
|
214
|
+
].compact)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Returns flex direction based on position.
|
|
218
|
+
# @return [String] the direction class
|
|
219
|
+
# @api private
|
|
220
|
+
def tablist_direction_class
|
|
221
|
+
case @position
|
|
222
|
+
when :left, :right then "flex-col"
|
|
223
|
+
else "flex-row"
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Returns alignment classes.
|
|
228
|
+
# @return [String] the alignment class
|
|
229
|
+
# @api private
|
|
230
|
+
def alignment_class
|
|
231
|
+
case @alignment
|
|
232
|
+
when :start then "justify-start"
|
|
233
|
+
when :center then "justify-center"
|
|
234
|
+
when :end then "justify-end"
|
|
235
|
+
when :stretch then "justify-stretch"
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Returns style-specific container classes.
|
|
240
|
+
# @return [String] the style container class
|
|
241
|
+
# @api private
|
|
242
|
+
def style_container_class
|
|
243
|
+
case @style
|
|
244
|
+
when :underline then border_class_for_position
|
|
245
|
+
when :pills then "gap-1"
|
|
246
|
+
when :bordered then "gap-0"
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# Returns border class based on position.
|
|
251
|
+
# @return [String] the border class
|
|
252
|
+
# @api private
|
|
253
|
+
def border_class_for_position
|
|
254
|
+
case @position
|
|
255
|
+
when :bottom then "border-t border-grayscale-200"
|
|
256
|
+
when :left then "border-r border-grayscale-200"
|
|
257
|
+
when :right then "border-l border-grayscale-200"
|
|
258
|
+
else "border-b border-grayscale-200"
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# Returns CSS classes for the panels container.
|
|
263
|
+
# @return [String] the merged CSS class string
|
|
264
|
+
# @api private
|
|
265
|
+
def panels_classes
|
|
266
|
+
css_classes([
|
|
267
|
+
"bui-tabs__panels",
|
|
268
|
+
"flex-1"
|
|
269
|
+
])
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Returns order classes for tab list based on position.
|
|
273
|
+
# @return [String] the order class
|
|
274
|
+
# @api private
|
|
275
|
+
def tablist_order_class
|
|
276
|
+
case @position
|
|
277
|
+
when :bottom then "order-2"
|
|
278
|
+
when :right then "order-2"
|
|
279
|
+
else ""
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# Returns order classes for panels based on position.
|
|
284
|
+
# @return [String] the order class
|
|
285
|
+
# @api private
|
|
286
|
+
def panels_order_class
|
|
287
|
+
case @position
|
|
288
|
+
when :bottom then "order-1"
|
|
289
|
+
when :right then "order-1"
|
|
290
|
+
else ""
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# Returns the Stimulus controller identifier.
|
|
295
|
+
# @return [String] the controller name
|
|
296
|
+
# @api private
|
|
297
|
+
def controller_name
|
|
298
|
+
"better-ui--tabs--container"
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# Returns HTML attributes for the container element.
|
|
302
|
+
# @return [Hash] HTML attributes hash
|
|
303
|
+
# @api private
|
|
304
|
+
def html_attributes
|
|
305
|
+
@options.except(:class).merge(
|
|
306
|
+
id: container_id,
|
|
307
|
+
data: data_attributes
|
|
308
|
+
)
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
# Returns data attributes for Stimulus controller.
|
|
312
|
+
# @return [Hash] data attributes hash
|
|
313
|
+
# @api private
|
|
314
|
+
def data_attributes
|
|
315
|
+
attrs = {
|
|
316
|
+
controller: controller_name,
|
|
317
|
+
"#{controller_name}-mode-value": @mode,
|
|
318
|
+
"#{controller_name}-default-tab-value": @default_tab,
|
|
319
|
+
"#{controller_name}-persist-value": @persist,
|
|
320
|
+
"#{controller_name}-persist-key-value": @persist_key,
|
|
321
|
+
"#{controller_name}-frame-id-value": @frame_id,
|
|
322
|
+
"#{controller_name}-show-loading-value": @show_loading,
|
|
323
|
+
"#{controller_name}-loader-delay-value": @loader_delay
|
|
324
|
+
}.compact
|
|
325
|
+
(@options[:data] || {}).merge(attrs)
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# Validates the mode parameter.
|
|
329
|
+
# @param mode [Symbol] the mode to validate
|
|
330
|
+
# @return [Symbol] the validated mode
|
|
331
|
+
# @raise [ArgumentError] if mode is invalid
|
|
332
|
+
# @api private
|
|
333
|
+
def validate_mode(mode)
|
|
334
|
+
unless MODES.include?(mode)
|
|
335
|
+
raise ArgumentError, "Invalid mode: #{mode}. Must be one of: #{MODES.join(', ')}"
|
|
336
|
+
end
|
|
337
|
+
mode
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
# Validates the style parameter.
|
|
341
|
+
# @param style [Symbol] the style to validate
|
|
342
|
+
# @return [Symbol] the validated style
|
|
343
|
+
# @raise [ArgumentError] if style is invalid
|
|
344
|
+
# @api private
|
|
345
|
+
def validate_style(style)
|
|
346
|
+
unless STYLES.include?(style)
|
|
347
|
+
raise ArgumentError, "Invalid style: #{style}. Must be one of: #{STYLES.join(', ')}"
|
|
348
|
+
end
|
|
349
|
+
style
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
# Validates the variant parameter.
|
|
353
|
+
# @param variant [Symbol] the variant to validate
|
|
354
|
+
# @return [Symbol] the validated variant
|
|
355
|
+
# @raise [ArgumentError] if variant is invalid
|
|
356
|
+
# @api private
|
|
357
|
+
def validate_variant(variant)
|
|
358
|
+
unless VARIANTS.keys.include?(variant)
|
|
359
|
+
raise ArgumentError, "Invalid variant: #{variant}. Must be one of: #{VARIANTS.keys.join(', ')}"
|
|
360
|
+
end
|
|
361
|
+
variant
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
# Validates the size parameter.
|
|
365
|
+
# @param size [Symbol] the size to validate
|
|
366
|
+
# @return [Symbol] the validated size
|
|
367
|
+
# @raise [ArgumentError] if size is invalid
|
|
368
|
+
# @api private
|
|
369
|
+
def validate_size(size)
|
|
370
|
+
unless SIZES.include?(size)
|
|
371
|
+
raise ArgumentError, "Invalid size: #{size}. Must be one of: #{SIZES.join(', ')}"
|
|
372
|
+
end
|
|
373
|
+
size
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
# Validates the alignment parameter.
|
|
377
|
+
# @param alignment [Symbol] the alignment to validate
|
|
378
|
+
# @return [Symbol] the validated alignment
|
|
379
|
+
# @raise [ArgumentError] if alignment is invalid
|
|
380
|
+
# @api private
|
|
381
|
+
def validate_alignment(alignment)
|
|
382
|
+
unless ALIGNMENTS.include?(alignment)
|
|
383
|
+
raise ArgumentError, "Invalid alignment: #{alignment}. Must be one of: #{ALIGNMENTS.join(', ')}"
|
|
384
|
+
end
|
|
385
|
+
alignment
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
# Validates the position parameter.
|
|
389
|
+
# @param position [Symbol] the position to validate
|
|
390
|
+
# @return [Symbol] the validated position
|
|
391
|
+
# @raise [ArgumentError] if position is invalid
|
|
392
|
+
# @api private
|
|
393
|
+
def validate_position(position)
|
|
394
|
+
unless POSITIONS.include?(position)
|
|
395
|
+
raise ArgumentError, "Invalid position: #{position}. Must be one of: #{POSITIONS.join(', ')}"
|
|
396
|
+
end
|
|
397
|
+
position
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
# Validates turbo mode requirements.
|
|
401
|
+
# @raise [ArgumentError] if turbo mode is used without frame_id
|
|
402
|
+
# @api private
|
|
403
|
+
def validate_turbo_requirements!
|
|
404
|
+
if @mode == :turbo && @frame_id.nil?
|
|
405
|
+
raise ArgumentError, "frame_id is required when mode is :turbo"
|
|
406
|
+
end
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
# Returns the spinner color class for the loading indicator.
|
|
410
|
+
# Uses literal strings for Tailwind JIT compatibility.
|
|
411
|
+
# @return [String] the text color class
|
|
412
|
+
# @api private
|
|
413
|
+
def spinner_color_class
|
|
414
|
+
case @variant
|
|
415
|
+
when :primary then "text-primary-600"
|
|
416
|
+
when :secondary then "text-secondary-600"
|
|
417
|
+
when :accent then "text-accent-600"
|
|
418
|
+
when :success then "text-success-600"
|
|
419
|
+
when :danger then "text-danger-600"
|
|
420
|
+
when :warning then "text-warning-600"
|
|
421
|
+
when :info then "text-info-600"
|
|
422
|
+
when :light then "text-grayscale-600"
|
|
423
|
+
when :dark then "text-grayscale-800"
|
|
424
|
+
end
|
|
425
|
+
end
|
|
426
|
+
end
|
|
427
|
+
end
|
|
428
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterUi
|
|
4
|
+
module Tabs
|
|
5
|
+
# A tab panel component that contains the content for a single tab.
|
|
6
|
+
#
|
|
7
|
+
# This component renders the panel content with proper ARIA attributes
|
|
8
|
+
# for accessibility. Used in JS mode where all panel content is present
|
|
9
|
+
# in the DOM.
|
|
10
|
+
#
|
|
11
|
+
# @example Basic panel
|
|
12
|
+
# <%= render BetterUi::Tabs::PanelComponent.new(id: "profile") do %>
|
|
13
|
+
# <p>Profile content here</p>
|
|
14
|
+
# <% end %>
|
|
15
|
+
#
|
|
16
|
+
# @example Active panel
|
|
17
|
+
# <%= render BetterUi::Tabs::PanelComponent.new(id: "profile", active: true) do %>
|
|
18
|
+
# <p>This panel is initially visible</p>
|
|
19
|
+
# <% end %>
|
|
20
|
+
class PanelComponent < ApplicationComponent
|
|
21
|
+
# Initializes a new panel component.
|
|
22
|
+
#
|
|
23
|
+
# @param id [String] unique identifier matching the corresponding tab
|
|
24
|
+
# @param active [Boolean] whether this panel is initially visible
|
|
25
|
+
# @param container_id [String] parent container ID for ARIA
|
|
26
|
+
# @param options [Hash] additional HTML attributes
|
|
27
|
+
def initialize(
|
|
28
|
+
id:,
|
|
29
|
+
active: false,
|
|
30
|
+
container_id: nil,
|
|
31
|
+
**options
|
|
32
|
+
)
|
|
33
|
+
@id = id
|
|
34
|
+
@active = active
|
|
35
|
+
@container_id = container_id
|
|
36
|
+
@options = options
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Returns the panel ID.
|
|
40
|
+
# @return [String] the id
|
|
41
|
+
attr_reader :id
|
|
42
|
+
|
|
43
|
+
# Returns whether this panel is active (visible).
|
|
44
|
+
# @return [Boolean] the active state
|
|
45
|
+
attr_reader :active
|
|
46
|
+
|
|
47
|
+
# Returns the panel element ID.
|
|
48
|
+
# @return [String] the panel element ID
|
|
49
|
+
def panel_element_id
|
|
50
|
+
"#{@container_id}-panel-#{@id}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Returns the corresponding tab element ID.
|
|
54
|
+
# @return [String] the tab element ID
|
|
55
|
+
def tab_element_id
|
|
56
|
+
"#{@container_id}-tab-#{@id}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
# Returns the complete CSS classes for the panel.
|
|
62
|
+
# @return [String] the merged CSS class string
|
|
63
|
+
# @api private
|
|
64
|
+
def component_classes
|
|
65
|
+
css_classes([
|
|
66
|
+
"bui-tabs__panel",
|
|
67
|
+
visibility_classes,
|
|
68
|
+
@options[:class]
|
|
69
|
+
].compact)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Returns visibility classes based on active state.
|
|
73
|
+
# @return [String] visibility classes
|
|
74
|
+
# @api private
|
|
75
|
+
def visibility_classes
|
|
76
|
+
@active ? "" : "hidden"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Returns HTML attributes for the panel element.
|
|
80
|
+
# @return [Hash] HTML attributes hash
|
|
81
|
+
# @api private
|
|
82
|
+
def html_attributes
|
|
83
|
+
@options.except(:class).merge(
|
|
84
|
+
id: panel_element_id,
|
|
85
|
+
class: component_classes,
|
|
86
|
+
role: "tabpanel",
|
|
87
|
+
"aria-labelledby": tab_element_id,
|
|
88
|
+
tabindex: "0",
|
|
89
|
+
data: data_attributes
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Returns data attributes for Stimulus.
|
|
94
|
+
# @return [Hash] data attributes hash
|
|
95
|
+
# @api private
|
|
96
|
+
def data_attributes
|
|
97
|
+
attrs = {
|
|
98
|
+
"better-ui--tabs--container-target": "panel",
|
|
99
|
+
"panel-id": @id
|
|
100
|
+
}
|
|
101
|
+
(@options[:data] || {}).merge(attrs)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<%= content_tag(element_tag, html_attributes) do %>
|
|
2
|
+
<% if icon? %>
|
|
3
|
+
<span class="bui-tabs__tab-icon"><%= icon %></span>
|
|
4
|
+
<% end %>
|
|
5
|
+
<span class="bui-tabs__tab-label"><%= label %></span>
|
|
6
|
+
<% if badge? %>
|
|
7
|
+
<span class="bui-tabs__tab-badge"><%= badge %></span>
|
|
8
|
+
<% end %>
|
|
9
|
+
<% end %>
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterUi
|
|
4
|
+
module Tabs
|
|
5
|
+
# A single tab component rendered as a button (JS mode) or link (Turbo mode).
|
|
6
|
+
#
|
|
7
|
+
# This component renders the clickable tab element with proper ARIA attributes
|
|
8
|
+
# and styling based on the parent container's configuration.
|
|
9
|
+
#
|
|
10
|
+
# @example Basic tab
|
|
11
|
+
# <%= render BetterUi::Tabs::TabComponent.new(id: "profile", label: "Profile") %>
|
|
12
|
+
#
|
|
13
|
+
# @example Tab with icon and badge
|
|
14
|
+
# <%= render BetterUi::Tabs::TabComponent.new(id: "messages", label: "Messages") do |tab| %>
|
|
15
|
+
# <% tab.with_icon { icon_svg } %>
|
|
16
|
+
# <% tab.with_badge { "3" } %>
|
|
17
|
+
# <% end %>
|
|
18
|
+
class TabComponent < ApplicationComponent
|
|
19
|
+
# @!method with_icon
|
|
20
|
+
# Slot for rendering an icon before the label
|
|
21
|
+
# @yieldreturn [String] the HTML content for the icon
|
|
22
|
+
renders_one :icon
|
|
23
|
+
|
|
24
|
+
# @!method with_badge
|
|
25
|
+
# Slot for rendering a badge after the label
|
|
26
|
+
# @yieldreturn [String] the HTML content for the badge
|
|
27
|
+
renders_one :badge
|
|
28
|
+
|
|
29
|
+
# Initializes a new tab component.
|
|
30
|
+
#
|
|
31
|
+
# @param id [String] unique identifier for this tab
|
|
32
|
+
# @param label [String] display text for the tab
|
|
33
|
+
# @param href [String, nil] URL for Turbo mode navigation
|
|
34
|
+
# @param active [Boolean] whether this tab is initially active
|
|
35
|
+
# @param disabled [Boolean] whether this tab is disabled
|
|
36
|
+
# @param mode [Symbol] operating mode passed from container (:js, :turbo)
|
|
37
|
+
# @param style [Symbol] visual style passed from container
|
|
38
|
+
# @param variant [Symbol] color variant passed from container
|
|
39
|
+
# @param size [Symbol] size passed from container
|
|
40
|
+
# @param frame_id [String, nil] Turbo Frame ID passed from container
|
|
41
|
+
# @param container_id [String] parent container ID for ARIA
|
|
42
|
+
# @param options [Hash] additional HTML attributes
|
|
43
|
+
def initialize(
|
|
44
|
+
id:,
|
|
45
|
+
label:,
|
|
46
|
+
href: nil,
|
|
47
|
+
active: false,
|
|
48
|
+
disabled: false,
|
|
49
|
+
mode: :js,
|
|
50
|
+
style: :underline,
|
|
51
|
+
variant: :primary,
|
|
52
|
+
size: :md,
|
|
53
|
+
frame_id: nil,
|
|
54
|
+
container_id: nil,
|
|
55
|
+
**options
|
|
56
|
+
)
|
|
57
|
+
@id = id
|
|
58
|
+
@label = label
|
|
59
|
+
@href = href
|
|
60
|
+
@active = active
|
|
61
|
+
@disabled = disabled
|
|
62
|
+
@mode = mode
|
|
63
|
+
@style = style
|
|
64
|
+
@variant = variant
|
|
65
|
+
@size = size
|
|
66
|
+
@frame_id = frame_id
|
|
67
|
+
@container_id = container_id
|
|
68
|
+
@options = options
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Returns the tab ID.
|
|
72
|
+
# @return [String] the id
|
|
73
|
+
attr_reader :id
|
|
74
|
+
|
|
75
|
+
# Returns the tab label.
|
|
76
|
+
# @return [String] the label
|
|
77
|
+
attr_reader :label
|
|
78
|
+
|
|
79
|
+
# Returns whether this tab is active.
|
|
80
|
+
# @return [Boolean] the active state
|
|
81
|
+
attr_reader :active
|
|
82
|
+
|
|
83
|
+
# Returns whether this tab is disabled.
|
|
84
|
+
# @return [Boolean] the disabled state
|
|
85
|
+
attr_reader :disabled
|
|
86
|
+
|
|
87
|
+
# Returns the associated panel ID.
|
|
88
|
+
# @return [String] the panel element ID
|
|
89
|
+
def panel_id
|
|
90
|
+
"#{@container_id}-panel-#{@id}"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Returns the tab element ID.
|
|
94
|
+
# @return [String] the tab element ID
|
|
95
|
+
def tab_element_id
|
|
96
|
+
"#{@container_id}-tab-#{@id}"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
# Returns the complete CSS classes for the tab.
|
|
102
|
+
# @return [String] the merged CSS class string
|
|
103
|
+
# @api private
|
|
104
|
+
def component_classes
|
|
105
|
+
css_classes([
|
|
106
|
+
"bui-tabs__tab",
|
|
107
|
+
base_classes,
|
|
108
|
+
size_classes,
|
|
109
|
+
style_classes,
|
|
110
|
+
state_classes,
|
|
111
|
+
@options[:class]
|
|
112
|
+
].compact)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Returns base classes for all tabs.
|
|
116
|
+
# @return [String] base classes
|
|
117
|
+
# @api private
|
|
118
|
+
def base_classes
|
|
119
|
+
"inline-flex items-center gap-2 font-medium transition-colors duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Returns size-specific classes.
|
|
123
|
+
# @return [String] size classes
|
|
124
|
+
# @api private
|
|
125
|
+
def size_classes
|
|
126
|
+
case @size
|
|
127
|
+
when :xs then "px-2 py-1 text-xs"
|
|
128
|
+
when :sm then "px-3 py-1.5 text-sm"
|
|
129
|
+
when :md then "px-4 py-2 text-sm"
|
|
130
|
+
when :lg then "px-5 py-2.5 text-base"
|
|
131
|
+
when :xl then "px-6 py-3 text-lg"
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Returns style-specific classes.
|
|
136
|
+
# @return [String] style classes
|
|
137
|
+
# @api private
|
|
138
|
+
def style_classes
|
|
139
|
+
case @style
|
|
140
|
+
when :underline then underline_style_classes
|
|
141
|
+
when :pills then pills_style_classes
|
|
142
|
+
when :bordered then bordered_style_classes
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Returns underline style classes.
|
|
147
|
+
# @return [String] style classes
|
|
148
|
+
# @api private
|
|
149
|
+
def underline_style_classes
|
|
150
|
+
if @active
|
|
151
|
+
active_underline_classes
|
|
152
|
+
else
|
|
153
|
+
"border-b-2 border-transparent text-grayscale-600 hover:text-grayscale-900 hover:border-grayscale-300"
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Returns active underline classes based on variant.
|
|
158
|
+
# @return [String] style classes
|
|
159
|
+
# @api private
|
|
160
|
+
def active_underline_classes
|
|
161
|
+
base = "border-b-2"
|
|
162
|
+
case @variant
|
|
163
|
+
when :primary then "#{base} border-primary-600 text-primary-600"
|
|
164
|
+
when :secondary then "#{base} border-secondary-600 text-secondary-600"
|
|
165
|
+
when :accent then "#{base} border-accent-600 text-accent-600"
|
|
166
|
+
when :success then "#{base} border-success-600 text-success-600"
|
|
167
|
+
when :danger then "#{base} border-danger-600 text-danger-600"
|
|
168
|
+
when :warning then "#{base} border-warning-600 text-warning-600"
|
|
169
|
+
when :info then "#{base} border-info-600 text-info-600"
|
|
170
|
+
when :light then "#{base} border-grayscale-400 text-grayscale-700"
|
|
171
|
+
when :dark then "#{base} border-grayscale-900 text-grayscale-900"
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Returns pills style classes.
|
|
176
|
+
# @return [String] style classes
|
|
177
|
+
# @api private
|
|
178
|
+
def pills_style_classes
|
|
179
|
+
if @active
|
|
180
|
+
active_pills_classes
|
|
181
|
+
else
|
|
182
|
+
"rounded-lg text-grayscale-600 hover:text-grayscale-900 hover:bg-grayscale-100"
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Returns active pills classes based on variant.
|
|
187
|
+
# @return [String] style classes
|
|
188
|
+
# @api private
|
|
189
|
+
def active_pills_classes
|
|
190
|
+
base = "rounded-lg"
|
|
191
|
+
case @variant
|
|
192
|
+
when :primary then "#{base} bg-primary-600 text-white"
|
|
193
|
+
when :secondary then "#{base} bg-secondary-600 text-white"
|
|
194
|
+
when :accent then "#{base} bg-accent-600 text-white"
|
|
195
|
+
when :success then "#{base} bg-success-600 text-white"
|
|
196
|
+
when :danger then "#{base} bg-danger-600 text-white"
|
|
197
|
+
when :warning then "#{base} bg-warning-500 text-white"
|
|
198
|
+
when :info then "#{base} bg-info-600 text-white"
|
|
199
|
+
when :light then "#{base} bg-grayscale-200 text-grayscale-800"
|
|
200
|
+
when :dark then "#{base} bg-grayscale-800 text-white"
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Returns bordered style classes.
|
|
205
|
+
# @return [String] style classes
|
|
206
|
+
# @api private
|
|
207
|
+
def bordered_style_classes
|
|
208
|
+
if @active
|
|
209
|
+
active_bordered_classes
|
|
210
|
+
else
|
|
211
|
+
"border border-transparent text-grayscale-600 hover:text-grayscale-900 rounded-t-lg -mb-px"
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Returns active bordered classes based on variant.
|
|
216
|
+
# @return [String] style classes
|
|
217
|
+
# @api private
|
|
218
|
+
def active_bordered_classes
|
|
219
|
+
base = "border border-grayscale-200 rounded-t-lg -mb-px bg-white"
|
|
220
|
+
border_bottom = "border-b-white"
|
|
221
|
+
case @variant
|
|
222
|
+
when :primary then "#{base} #{border_bottom} text-primary-600"
|
|
223
|
+
when :secondary then "#{base} #{border_bottom} text-secondary-600"
|
|
224
|
+
when :accent then "#{base} #{border_bottom} text-accent-600"
|
|
225
|
+
when :success then "#{base} #{border_bottom} text-success-600"
|
|
226
|
+
when :danger then "#{base} #{border_bottom} text-danger-600"
|
|
227
|
+
when :warning then "#{base} #{border_bottom} text-warning-600"
|
|
228
|
+
when :info then "#{base} #{border_bottom} text-info-600"
|
|
229
|
+
when :light then "#{base} #{border_bottom} text-grayscale-700"
|
|
230
|
+
when :dark then "#{base} #{border_bottom} text-grayscale-900"
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Returns state classes for disabled or focus.
|
|
235
|
+
# @return [String] state classes
|
|
236
|
+
# @api private
|
|
237
|
+
def state_classes
|
|
238
|
+
if @disabled
|
|
239
|
+
"opacity-50 cursor-not-allowed"
|
|
240
|
+
else
|
|
241
|
+
"cursor-pointer"
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Returns the element tag (button for JS mode, anchor for Turbo mode).
|
|
246
|
+
# @return [Symbol] the tag name
|
|
247
|
+
# @api private
|
|
248
|
+
def element_tag
|
|
249
|
+
@mode == :turbo ? :a : :button
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# Returns HTML attributes for the tab element.
|
|
253
|
+
# @return [Hash] HTML attributes hash
|
|
254
|
+
# @api private
|
|
255
|
+
def html_attributes
|
|
256
|
+
attrs = @options.except(:class).merge(
|
|
257
|
+
id: tab_element_id,
|
|
258
|
+
class: component_classes,
|
|
259
|
+
role: "tab",
|
|
260
|
+
"aria-selected": @active.to_s,
|
|
261
|
+
"aria-controls": panel_id,
|
|
262
|
+
tabindex: @active ? "0" : "-1",
|
|
263
|
+
data: data_attributes
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
if @mode == :turbo
|
|
267
|
+
attrs[:href] = @href
|
|
268
|
+
attrs["data-turbo-frame"] = @frame_id if @frame_id
|
|
269
|
+
else
|
|
270
|
+
attrs[:type] = "button"
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
attrs[:disabled] = true if @disabled && @mode == :js
|
|
274
|
+
attrs["aria-disabled"] = "true" if @disabled
|
|
275
|
+
|
|
276
|
+
attrs
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# Returns data attributes for Stimulus.
|
|
280
|
+
# @return [Hash] data attributes hash
|
|
281
|
+
# @api private
|
|
282
|
+
def data_attributes
|
|
283
|
+
attrs = {
|
|
284
|
+
"better-ui--tabs--container-target": "tab",
|
|
285
|
+
"tab-id": @id,
|
|
286
|
+
"active-classes": active_style_classes_for_data,
|
|
287
|
+
"inactive-classes": inactive_style_classes_for_data,
|
|
288
|
+
action: "click->better-ui--tabs--container#selectTab keydown->better-ui--tabs--container#handleKeydown"
|
|
289
|
+
}
|
|
290
|
+
(@options[:data] || {}).merge(attrs)
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# Returns active style classes for storing in data attribute.
|
|
294
|
+
# @return [String] active classes
|
|
295
|
+
# @api private
|
|
296
|
+
def active_style_classes_for_data
|
|
297
|
+
case @style
|
|
298
|
+
when :underline then active_underline_classes
|
|
299
|
+
when :pills then active_pills_classes
|
|
300
|
+
when :bordered then active_bordered_classes
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# Returns inactive style classes for storing in data attribute.
|
|
305
|
+
# @return [String] inactive classes
|
|
306
|
+
# @api private
|
|
307
|
+
def inactive_style_classes_for_data
|
|
308
|
+
case @style
|
|
309
|
+
when :underline then "border-b-2 border-transparent text-grayscale-600 hover:text-grayscale-900 hover:border-grayscale-300"
|
|
310
|
+
when :pills then "rounded-lg text-grayscale-600 hover:text-grayscale-900 hover:bg-grayscale-100"
|
|
311
|
+
when :bordered then "border border-transparent text-grayscale-600 hover:text-grayscale-900 rounded-t-lg -mb-px"
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
end
|
|
@@ -325,5 +325,79 @@ module BetterUi
|
|
|
325
325
|
def bui_drawer_nav_group(**options, &block)
|
|
326
326
|
render BetterUi::Drawer::NavGroupComponent.new(**options), &block
|
|
327
327
|
end
|
|
328
|
+
|
|
329
|
+
# ============================================
|
|
330
|
+
# Tabs Components
|
|
331
|
+
# ============================================
|
|
332
|
+
|
|
333
|
+
# Renders a tabs container component.
|
|
334
|
+
#
|
|
335
|
+
# @param options [Hash] Options passed to Tabs::ContainerComponent
|
|
336
|
+
# @option options [Symbol] :mode Operating mode (:js, :turbo)
|
|
337
|
+
# @option options [Symbol] :style Visual style (:underline, :pills, :bordered)
|
|
338
|
+
# @option options [Symbol] :variant Color variant (:primary, :secondary, etc.)
|
|
339
|
+
# @option options [Symbol] :size Size (:xs, :sm, :md, :lg, :xl)
|
|
340
|
+
# @option options [Symbol] :alignment Tab alignment (:start, :center, :end, :stretch)
|
|
341
|
+
# @option options [Symbol] :position Tab list position (:top, :bottom, :left, :right)
|
|
342
|
+
# @option options [String, nil] :frame_id Turbo Frame ID (required for turbo mode)
|
|
343
|
+
# @option options [String, nil] :default_tab ID of the default active tab
|
|
344
|
+
# @option options [Boolean] :persist Persist active tab state
|
|
345
|
+
# @option options [String, nil] :persist_key localStorage key for persistence
|
|
346
|
+
# @yield [tabs] Block with tabs slots
|
|
347
|
+
# @yieldparam tabs [BetterUi::Tabs::ContainerComponent] The tabs container
|
|
348
|
+
# @return [String] Rendered HTML
|
|
349
|
+
#
|
|
350
|
+
# @example JS mode tabs
|
|
351
|
+
# <%= bui_tabs(mode: :js, style: :underline) do |tabs| %>
|
|
352
|
+
# <% tabs.with_tab(id: "profile", label: "Profile", active: true) %>
|
|
353
|
+
# <% tabs.with_tab(id: "settings", label: "Settings") %>
|
|
354
|
+
# <% tabs.with_panel(id: "profile", active: true) { "Profile content" } %>
|
|
355
|
+
# <% tabs.with_panel(id: "settings") { "Settings content" } %>
|
|
356
|
+
# <% end %>
|
|
357
|
+
#
|
|
358
|
+
# @example Turbo mode tabs
|
|
359
|
+
# <%= bui_tabs(mode: :turbo, frame_id: "tab-content") do |tabs| %>
|
|
360
|
+
# <% tabs.with_tab(id: "profile", label: "Profile", href: profile_path, active: true) %>
|
|
361
|
+
# <% tabs.with_tab(id: "settings", label: "Settings", href: settings_path) %>
|
|
362
|
+
# <% end %>
|
|
363
|
+
def bui_tabs(**options, &block)
|
|
364
|
+
render BetterUi::Tabs::ContainerComponent.new(**options), &block
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
# Renders a standalone tab component (used outside container context).
|
|
368
|
+
#
|
|
369
|
+
# @param id [String] Unique identifier for this tab
|
|
370
|
+
# @param label [String] Display text for the tab
|
|
371
|
+
# @param options [Hash] Options passed to Tabs::TabComponent
|
|
372
|
+
# @option options [String, nil] :href URL for Turbo mode navigation
|
|
373
|
+
# @option options [Boolean] :active Whether this tab is initially active
|
|
374
|
+
# @option options [Boolean] :disabled Whether this tab is disabled
|
|
375
|
+
# @yield [tab] Block with tab slots
|
|
376
|
+
# @return [String] Rendered HTML
|
|
377
|
+
#
|
|
378
|
+
# @example Tab with icon
|
|
379
|
+
# <%= bui_tab(id: "messages", label: "Messages") do |tab| %>
|
|
380
|
+
# <% tab.with_icon { icon_svg } %>
|
|
381
|
+
# <% tab.with_badge { "3" } %>
|
|
382
|
+
# <% end %>
|
|
383
|
+
def bui_tab(id:, label:, **options, &block)
|
|
384
|
+
render BetterUi::Tabs::TabComponent.new(id: id, label: label, **options), &block
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
# Renders a standalone tab panel component (used outside container context).
|
|
388
|
+
#
|
|
389
|
+
# @param id [String] Unique identifier matching the corresponding tab
|
|
390
|
+
# @param options [Hash] Options passed to Tabs::PanelComponent
|
|
391
|
+
# @option options [Boolean] :active Whether this panel is initially visible
|
|
392
|
+
# @yield Panel content
|
|
393
|
+
# @return [String] Rendered HTML
|
|
394
|
+
#
|
|
395
|
+
# @example Basic panel
|
|
396
|
+
# <%= bui_tab_panel(id: "profile", active: true) do %>
|
|
397
|
+
# <p>Profile content here</p>
|
|
398
|
+
# <% end %>
|
|
399
|
+
def bui_tab_panel(id:, **options, &block)
|
|
400
|
+
render BetterUi::Tabs::PanelComponent.new(id: id, **options), &block
|
|
401
|
+
end
|
|
328
402
|
end
|
|
329
403
|
end
|
data/lib/better_ui/engine.rb
CHANGED
|
@@ -40,5 +40,12 @@ module BetterUi
|
|
|
40
40
|
Lookbook.config.preview_srcdoc = true
|
|
41
41
|
end
|
|
42
42
|
end
|
|
43
|
+
|
|
44
|
+
# Auto-include view helpers in host application
|
|
45
|
+
initializer "better_ui.helpers" do
|
|
46
|
+
ActiveSupport.on_load(:action_view) do
|
|
47
|
+
include BetterUi::ApplicationHelper
|
|
48
|
+
end
|
|
49
|
+
end
|
|
43
50
|
end
|
|
44
51
|
end
|
data/lib/better_ui/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: better_ui
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.8.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Umberto Peserico
|
|
@@ -114,6 +114,12 @@ files:
|
|
|
114
114
|
- app/components/better_ui/forms/text_input_component/text_input_component.html.erb
|
|
115
115
|
- app/components/better_ui/forms/textarea_component.rb
|
|
116
116
|
- app/components/better_ui/forms/textarea_component/textarea_component.html.erb
|
|
117
|
+
- app/components/better_ui/tabs/container_component.rb
|
|
118
|
+
- app/components/better_ui/tabs/container_component/container_component.html.erb
|
|
119
|
+
- app/components/better_ui/tabs/panel_component.rb
|
|
120
|
+
- app/components/better_ui/tabs/panel_component/panel_component.html.erb
|
|
121
|
+
- app/components/better_ui/tabs/tab_component.rb
|
|
122
|
+
- app/components/better_ui/tabs/tab_component/tab_component.html.erb
|
|
117
123
|
- app/controllers/better_ui/application_controller.rb
|
|
118
124
|
- app/form_builders/better_ui/ui_form_builder.rb
|
|
119
125
|
- app/helpers/better_ui/application_helper.rb
|