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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 64c0ed9d31f8392770eaedb20fe0727444877a4fa8886b6241bf70c5d093b03e
4
- data.tar.gz: bbf85b45c167057d13b403d3270eb0bf744cab5d1b2646ee3c6ed88c6cdfceca
3
+ metadata.gz: 8b3c7255ce65e6a53ed2ee42174475f656461303920e1a4e6c9912fe3ecd4d4a
4
+ data.tar.gz: 4c4c5063560d6edcb534fd1856869928e4b3f0456e7d86dea76f216bb3c4ae2e
5
5
  SHA512:
6
- metadata.gz: 9fa81ccba17b2fc8bf7922ebb4e41a38328cd8439d851008dce1eb1a9b06e47b0481db570f2377ca4a69b65ae91814306584ec24f0d1514e2114763b34db4d89
7
- data.tar.gz: 92ac9e0d3d760f148f25c0b0fa11e2d57aa7d56d8cf367df637b2521deb8f7bdc11b59063e8fd523e11db0c3de4d8276e824ef1388fada0c71199c4fe05d5395
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
- <!-- In your views -->
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
- <%= render BetterUi::ButtonComponent.new(
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
- <%= render BetterUi::CardComponent.new(size: :lg, style: :bordered) do |c| %>
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
- <%= render BetterUi::ActionMessagesComponent.new(
121
- variant: "danger",
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
- <%= render BetterUi::Forms::TextInputComponent.new(
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
- <%= render BetterUi::Forms::NumberInputComponent.new(
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
- <%= render BetterUi::Forms::PasswordInputComponent.new(
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
- <%= render BetterUi::Forms::TextareaComponent.new(
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
- <%= render BetterUi::Forms::CheckboxComponent.new(
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
- <%= render BetterUi::Forms::CheckboxGroupComponent.new(
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
- <%= render BetterUi::Drawer::LayoutComponent.new do |layout| %>
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
- <%= render BetterUi::Drawer::NavGroupComponent.new(title: "Menu") do |group| %>
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.
@@ -183,6 +183,7 @@ module BetterUi
183
183
  "flex-1",
184
184
  "overflow-y-auto",
185
185
  "p-4",
186
+ "space-y-6",
186
187
  @navigation_classes
187
188
  ].compact)
188
189
  end
@@ -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,3 @@
1
+ <div <%= tag.attributes(html_attributes) %>>
2
+ <%= content %>
3
+ </div>
@@ -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
@@ -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
@@ -1,3 +1,3 @@
1
1
  module BetterUi
2
- VERSION = "0.7.2"
2
+ VERSION = "0.8.0"
3
3
  end
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.7.2
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