better_ui 0.6.0 → 0.7.1
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 +257 -212
- data/Rakefile +11 -2
- data/app/components/better_ui/action_messages_component/action_messages_component.html.erb +48 -0
- data/app/components/better_ui/action_messages_component.rb +544 -0
- data/app/components/better_ui/application_component.rb +66 -0
- data/app/components/better_ui/button_component/button_component.html.erb +31 -0
- data/app/components/better_ui/button_component.rb +307 -0
- data/app/components/better_ui/card_component/card_component.html.erb +17 -0
- data/app/components/better_ui/card_component.rb +460 -0
- data/app/components/better_ui/drawer/header_component/header_component.html.erb +24 -0
- data/app/components/better_ui/drawer/header_component.rb +238 -0
- data/app/components/better_ui/drawer/layout_component/layout_component.html.erb +44 -0
- data/app/components/better_ui/drawer/layout_component.rb +270 -0
- data/app/components/better_ui/drawer/nav_group_component/nav_group_component.html.erb +10 -0
- data/app/components/better_ui/drawer/nav_group_component.rb +155 -0
- data/app/components/better_ui/drawer/nav_item_component/nav_item_component.html.erb +13 -0
- data/app/components/better_ui/drawer/nav_item_component.rb +225 -0
- data/app/components/better_ui/drawer/sidebar_component/sidebar_component.html.erb +17 -0
- data/app/components/better_ui/drawer/sidebar_component.rb +263 -0
- data/app/components/better_ui/forms/base_component.rb +450 -0
- data/app/components/better_ui/forms/checkbox_component/checkbox_component.html.erb +28 -0
- data/app/components/better_ui/forms/checkbox_component.rb +419 -0
- data/app/components/better_ui/forms/checkbox_group_component/checkbox_group_component.html.erb +40 -0
- data/app/components/better_ui/forms/checkbox_group_component.rb +363 -0
- data/app/components/better_ui/forms/number_input_component/number_input_component.html.erb +40 -0
- data/app/components/better_ui/forms/number_input_component.rb +320 -0
- data/app/components/better_ui/forms/password_input_component/password_input_component.html.erb +71 -0
- data/app/components/better_ui/forms/password_input_component.rb +206 -0
- data/app/components/better_ui/forms/text_input_component/text_input_component.html.erb +40 -0
- data/app/components/better_ui/forms/text_input_component.rb +258 -0
- data/app/components/better_ui/forms/textarea_component/textarea_component.html.erb +40 -0
- data/app/components/better_ui/forms/textarea_component.rb +329 -0
- data/app/form_builders/better_ui/ui_form_builder.rb +467 -0
- data/app/helpers/better_ui/application_helper.rb +325 -58
- data/app/views/layouts/better_ui/application.html.erb +1 -1
- data/config/routes.rb +1 -0
- data/lib/better_ui/engine.rb +34 -5
- data/lib/better_ui/version.rb +1 -1
- data/lib/better_ui.rb +32 -5
- data/lib/generators/better_ui/install/USAGE +44 -0
- data/lib/generators/better_ui/install/install_generator.rb +87 -0
- data/lib/generators/better_ui/install/templates/better_ui_theme.css.tt +280 -0
- data/lib/tasks/better_ui_tasks.rake +39 -4
- metadata +55 -203
- data/app/components/better_ui/application/card/component.html.erb +0 -20
- data/app/components/better_ui/application/card/component.rb +0 -214
- data/app/components/better_ui/application/main/component.html.erb +0 -9
- data/app/components/better_ui/application/main/component.rb +0 -123
- data/app/components/better_ui/application/navbar/component.html.erb +0 -92
- data/app/components/better_ui/application/navbar/component.rb +0 -136
- data/app/components/better_ui/application/sidebar/component.html.erb +0 -249
- data/app/components/better_ui/application/sidebar/component.rb +0 -187
- data/app/components/better_ui/general/accordion/component.html.erb +0 -5
- data/app/components/better_ui/general/accordion/component.rb +0 -92
- data/app/components/better_ui/general/accordion/item_component.html.erb +0 -12
- data/app/components/better_ui/general/accordion/item_component.rb +0 -176
- data/app/components/better_ui/general/alert/component.html.erb +0 -32
- data/app/components/better_ui/general/alert/component.rb +0 -242
- data/app/components/better_ui/general/avatar/component.html.erb +0 -20
- data/app/components/better_ui/general/avatar/component.rb +0 -301
- data/app/components/better_ui/general/badge/component.html.erb +0 -23
- data/app/components/better_ui/general/badge/component.rb +0 -248
- data/app/components/better_ui/general/breadcrumb/component.html.erb +0 -15
- data/app/components/better_ui/general/breadcrumb/component.rb +0 -187
- data/app/components/better_ui/general/button/component.html.erb +0 -34
- data/app/components/better_ui/general/button/component.rb +0 -214
- data/app/components/better_ui/general/divider/component.html.erb +0 -10
- data/app/components/better_ui/general/divider/component.rb +0 -226
- data/app/components/better_ui/general/dropdown/component.html.erb +0 -28
- data/app/components/better_ui/general/dropdown/component.rb +0 -192
- data/app/components/better_ui/general/dropdown/divider_component.html.erb +0 -1
- data/app/components/better_ui/general/dropdown/divider_component.rb +0 -41
- data/app/components/better_ui/general/dropdown/item_component.html.erb +0 -6
- data/app/components/better_ui/general/dropdown/item_component.rb +0 -119
- data/app/components/better_ui/general/field/component.html.erb +0 -27
- data/app/components/better_ui/general/field/component.rb +0 -37
- data/app/components/better_ui/general/grid/cell_component.html.erb +0 -3
- data/app/components/better_ui/general/grid/cell_component.rb +0 -390
- data/app/components/better_ui/general/grid/component.html.erb +0 -3
- data/app/components/better_ui/general/grid/component.rb +0 -301
- data/app/components/better_ui/general/heading/component.html.erb +0 -22
- data/app/components/better_ui/general/heading/component.rb +0 -257
- data/app/components/better_ui/general/icon/component.html.erb +0 -7
- data/app/components/better_ui/general/icon/component.rb +0 -240
- data/app/components/better_ui/general/input/checkbox/component.html.erb +0 -5
- data/app/components/better_ui/general/input/checkbox/component.rb +0 -238
- data/app/components/better_ui/general/input/datetime/component.html.erb +0 -5
- data/app/components/better_ui/general/input/datetime/component.rb +0 -223
- data/app/components/better_ui/general/input/pin/component.html.erb +0 -1
- data/app/components/better_ui/general/input/pin/component.rb +0 -201
- data/app/components/better_ui/general/input/radio/component.html.erb +0 -5
- data/app/components/better_ui/general/input/radio/component.rb +0 -230
- data/app/components/better_ui/general/input/rating/component.html.erb +0 -4
- data/app/components/better_ui/general/input/rating/component.rb +0 -272
- data/app/components/better_ui/general/input/select/component.html.erb +0 -78
- data/app/components/better_ui/general/input/select/component.rb +0 -249
- data/app/components/better_ui/general/input/select/select_component.html.erb +0 -5
- data/app/components/better_ui/general/input/select/select_component.rb +0 -37
- data/app/components/better_ui/general/input/text/component.html.erb +0 -5
- data/app/components/better_ui/general/input/text/component.rb +0 -171
- data/app/components/better_ui/general/input/textarea/component.html.erb +0 -5
- data/app/components/better_ui/general/input/textarea/component.rb +0 -166
- data/app/components/better_ui/general/input/toggle/component.html.erb +0 -5
- data/app/components/better_ui/general/input/toggle/component.rb +0 -242
- data/app/components/better_ui/general/link/component.html.erb +0 -18
- data/app/components/better_ui/general/link/component.rb +0 -258
- data/app/components/better_ui/general/modal/component.html.erb +0 -5
- data/app/components/better_ui/general/modal/component.rb +0 -47
- data/app/components/better_ui/general/modal/modal_component.html.erb +0 -52
- data/app/components/better_ui/general/modal/modal_component.rb +0 -160
- data/app/components/better_ui/general/pagination/component.html.erb +0 -85
- data/app/components/better_ui/general/pagination/component.rb +0 -216
- data/app/components/better_ui/general/panel/component.html.erb +0 -28
- data/app/components/better_ui/general/panel/component.rb +0 -249
- data/app/components/better_ui/general/progress/component.html.erb +0 -11
- data/app/components/better_ui/general/progress/component.rb +0 -160
- data/app/components/better_ui/general/spinner/component.html.erb +0 -35
- data/app/components/better_ui/general/spinner/component.rb +0 -93
- data/app/components/better_ui/general/table/component.html.erb +0 -5
- data/app/components/better_ui/general/table/component.rb +0 -217
- data/app/components/better_ui/general/table/tbody_component.html.erb +0 -3
- data/app/components/better_ui/general/table/tbody_component.rb +0 -30
- data/app/components/better_ui/general/table/td_component.html.erb +0 -3
- data/app/components/better_ui/general/table/td_component.rb +0 -44
- data/app/components/better_ui/general/table/tfoot_component.html.erb +0 -3
- data/app/components/better_ui/general/table/tfoot_component.rb +0 -28
- data/app/components/better_ui/general/table/th_component.html.erb +0 -6
- data/app/components/better_ui/general/table/th_component.rb +0 -51
- data/app/components/better_ui/general/table/thead_component.html.erb +0 -3
- data/app/components/better_ui/general/table/thead_component.rb +0 -28
- data/app/components/better_ui/general/table/tr_component.html.erb +0 -3
- data/app/components/better_ui/general/table/tr_component.rb +0 -30
- data/app/components/better_ui/general/tabs/component.html.erb +0 -11
- data/app/components/better_ui/general/tabs/component.rb +0 -120
- data/app/components/better_ui/general/tabs/panel_component.html.erb +0 -3
- data/app/components/better_ui/general/tabs/panel_component.rb +0 -37
- data/app/components/better_ui/general/tabs/tab_component.html.erb +0 -13
- data/app/components/better_ui/general/tabs/tab_component.rb +0 -111
- data/app/components/better_ui/general/tag/component.html.erb +0 -3
- data/app/components/better_ui/general/tag/component.rb +0 -104
- data/app/components/better_ui/general/text/component.html.erb +0 -1
- data/app/components/better_ui/general/text/component.rb +0 -194
- data/app/components/better_ui/general/tooltip/component.html.erb +0 -7
- data/app/components/better_ui/general/tooltip/component.rb +0 -239
- data/app/helpers/better_ui/application/components/card/card_helper.rb +0 -96
- data/app/helpers/better_ui/application/components/card.rb +0 -11
- data/app/helpers/better_ui/application/components/main/main_helper.rb +0 -64
- data/app/helpers/better_ui/application/components/navbar/navbar_helper.rb +0 -77
- data/app/helpers/better_ui/application/components/sidebar/sidebar_helper.rb +0 -51
- data/app/helpers/better_ui/general/components/accordion/accordion_helper.rb +0 -73
- data/app/helpers/better_ui/general/components/alert/alert_helper.rb +0 -57
- data/app/helpers/better_ui/general/components/avatar/avatar_helper.rb +0 -29
- data/app/helpers/better_ui/general/components/badge/badge_helper.rb +0 -53
- data/app/helpers/better_ui/general/components/breadcrumb/breadcrumb_helper.rb +0 -37
- data/app/helpers/better_ui/general/components/button/button_helper.rb +0 -65
- data/app/helpers/better_ui/general/components/container/container_helper.rb +0 -60
- data/app/helpers/better_ui/general/components/divider/divider_helper.rb +0 -63
- data/app/helpers/better_ui/general/components/dropdown/divider_helper.rb +0 -32
- data/app/helpers/better_ui/general/components/dropdown/dropdown_helper.rb +0 -88
- data/app/helpers/better_ui/general/components/dropdown/item_helper.rb +0 -68
- data/app/helpers/better_ui/general/components/field/field_helper.rb +0 -26
- data/app/helpers/better_ui/general/components/grid/grid_helper.rb +0 -145
- data/app/helpers/better_ui/general/components/heading/heading_helper.rb +0 -72
- data/app/helpers/better_ui/general/components/icon/icon_helper.rb +0 -16
- data/app/helpers/better_ui/general/components/input/checkbox/checkbox_helper.rb +0 -81
- data/app/helpers/better_ui/general/components/input/datetime/datetime_helper.rb +0 -91
- data/app/helpers/better_ui/general/components/input/pin/pin_helper.rb +0 -76
- data/app/helpers/better_ui/general/components/input/radio/radio_helper.rb +0 -79
- data/app/helpers/better_ui/general/components/input/radio_group/radio_group_helper.rb +0 -124
- data/app/helpers/better_ui/general/components/input/rating/rating_helper.rb +0 -70
- data/app/helpers/better_ui/general/components/input/select/select_helper.rb +0 -86
- data/app/helpers/better_ui/general/components/input/text/text_helper.rb +0 -138
- data/app/helpers/better_ui/general/components/input/textarea/textarea_helper.rb +0 -73
- data/app/helpers/better_ui/general/components/input/toggle/toggle_helper.rb +0 -77
- data/app/helpers/better_ui/general/components/link/link_helper.rb +0 -89
- data/app/helpers/better_ui/general/components/modal/modal_helper.rb +0 -85
- data/app/helpers/better_ui/general/components/pagination/pagination_helper.rb +0 -82
- data/app/helpers/better_ui/general/components/panel/panel_helper.rb +0 -83
- data/app/helpers/better_ui/general/components/progress/progress_helper.rb +0 -53
- data/app/helpers/better_ui/general/components/spinner/spinner_helper.rb +0 -19
- data/app/helpers/better_ui/general/components/table/table_helper.rb +0 -53
- data/app/helpers/better_ui/general/components/table/tbody_helper.rb +0 -13
- data/app/helpers/better_ui/general/components/table/td_helper.rb +0 -19
- data/app/helpers/better_ui/general/components/table/tfoot_helper.rb +0 -13
- data/app/helpers/better_ui/general/components/table/th_helper.rb +0 -19
- data/app/helpers/better_ui/general/components/table/thead_helper.rb +0 -13
- data/app/helpers/better_ui/general/components/table/tr_helper.rb +0 -13
- data/app/helpers/better_ui/general/components/tabs/panel_helper.rb +0 -62
- data/app/helpers/better_ui/general/components/tabs/tab_helper.rb +0 -55
- data/app/helpers/better_ui/general/components/tabs/tabs_helper.rb +0 -95
- data/app/helpers/better_ui/general/components/tag/tag_helper.rb +0 -26
- data/app/helpers/better_ui/general/components/text/text_helper.rb +0 -83
- data/app/helpers/better_ui/general/components/tooltip/tooltip_helper.rb +0 -60
- data/app/jobs/better_ui/application_job.rb +0 -4
- data/app/mailers/better_ui/application_mailer.rb +0 -6
- data/config/initializers/lookbook.rb +0 -23
- data/lib/better_ui/railtie.rb +0 -20
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterUi
|
|
4
|
+
# ActionMessagesComponent displays a list of messages with customizable styles and variants.
|
|
5
|
+
#
|
|
6
|
+
# This component is designed for displaying form validation errors, flash notifications,
|
|
7
|
+
# success messages, warnings, and other user feedback. It supports multiple visual styles
|
|
8
|
+
# (solid, soft, outline, ghost), all semantic color variants, optional titles, dismissible
|
|
9
|
+
# functionality, and auto-dismiss timers.
|
|
10
|
+
#
|
|
11
|
+
# Features:
|
|
12
|
+
# - 9 color variants (primary, secondary, accent, success, danger, warning, info, light, dark)
|
|
13
|
+
# - 4 visual styles (solid, soft, outline, ghost)
|
|
14
|
+
# - Optional title/heading
|
|
15
|
+
# - Dismissible with close button
|
|
16
|
+
# - Auto-dismiss after N seconds
|
|
17
|
+
# - Stimulus controller integration for interactivity
|
|
18
|
+
# - Icon slot for custom icons (via ViewComponent slots)
|
|
19
|
+
#
|
|
20
|
+
# @example Basic usage
|
|
21
|
+
# <%= render BetterUi::ActionMessagesComponent.new(messages: ["This is a message"]) %>
|
|
22
|
+
#
|
|
23
|
+
# @example Form validation errors
|
|
24
|
+
# <%= render BetterUi::ActionMessagesComponent.new(
|
|
25
|
+
# variant: :danger,
|
|
26
|
+
# title: "Please correct the following errors:",
|
|
27
|
+
# messages: @user.errors.full_messages
|
|
28
|
+
# ) %>
|
|
29
|
+
#
|
|
30
|
+
# @example Success notification with auto-dismiss
|
|
31
|
+
# <%= render BetterUi::ActionMessagesComponent.new(
|
|
32
|
+
# variant: :success,
|
|
33
|
+
# style: :solid,
|
|
34
|
+
# dismissible: true,
|
|
35
|
+
# auto_dismiss: 5,
|
|
36
|
+
# messages: ["Your changes have been saved."]
|
|
37
|
+
# ) %>
|
|
38
|
+
#
|
|
39
|
+
# @example With custom styling
|
|
40
|
+
# <%= render BetterUi::ActionMessagesComponent.new(
|
|
41
|
+
# variant: :warning,
|
|
42
|
+
# style: :outline,
|
|
43
|
+
# title: "Warning",
|
|
44
|
+
# messages: ["This action cannot be undone"],
|
|
45
|
+
# container_classes: "shadow-lg"
|
|
46
|
+
# ) %>
|
|
47
|
+
#
|
|
48
|
+
# @see ApplicationComponent
|
|
49
|
+
class ActionMessagesComponent < ApplicationComponent
|
|
50
|
+
attr_reader :messages, :variant, :style, :dismissible, :auto_dismiss, :title, :container_classes
|
|
51
|
+
|
|
52
|
+
# Initialize the ActionMessages component
|
|
53
|
+
#
|
|
54
|
+
# @param messages [Array<String>] List of messages to display
|
|
55
|
+
# @param variant [Symbol] Color variant (:primary, :secondary, :accent, :success, :danger, :warning, :info, :light, :dark)
|
|
56
|
+
# @param style [Symbol] Visual style (:solid, :soft, :outline, :ghost)
|
|
57
|
+
# @param dismissible [Boolean] Whether to show a dismiss button
|
|
58
|
+
# @param auto_dismiss [Integer, Float, nil] Auto-dismiss after N seconds (nil to disable)
|
|
59
|
+
# @param title [String, nil] Optional title/heading above messages
|
|
60
|
+
# @param container_classes [String, nil] Custom CSS classes to merge with container
|
|
61
|
+
# @param options [Hash] Additional HTML attributes (id, data, aria, etc.)
|
|
62
|
+
def initialize(
|
|
63
|
+
messages: [],
|
|
64
|
+
variant: :info,
|
|
65
|
+
style: :soft,
|
|
66
|
+
dismissible: false,
|
|
67
|
+
auto_dismiss: nil,
|
|
68
|
+
title: nil,
|
|
69
|
+
container_classes: nil,
|
|
70
|
+
**options
|
|
71
|
+
)
|
|
72
|
+
# Convert messages to array (handles nil and single strings)
|
|
73
|
+
@messages = Array(messages)
|
|
74
|
+
@variant = variant.to_sym
|
|
75
|
+
@style = style.to_sym
|
|
76
|
+
@dismissible = dismissible
|
|
77
|
+
@auto_dismiss = auto_dismiss
|
|
78
|
+
@title = title
|
|
79
|
+
@container_classes = container_classes
|
|
80
|
+
@options = options
|
|
81
|
+
|
|
82
|
+
# Validate parameters on initialization
|
|
83
|
+
validate_variant!
|
|
84
|
+
validate_style!
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Build CSS classes for the main container
|
|
88
|
+
# Combines base classes (rounded, padding) with style-specific classes and custom classes
|
|
89
|
+
# @return [String] Merged CSS class string
|
|
90
|
+
def component_classes
|
|
91
|
+
base_classes = [ "rounded-lg", "p-4", "relative" ]
|
|
92
|
+
style_classes = send("#{@style}_classes") # Dynamically call solid_classes, soft_classes, etc.
|
|
93
|
+
|
|
94
|
+
# Use TailwindMerge to intelligently merge classes (handles conflicts)
|
|
95
|
+
css_classes(base_classes, style_classes, @container_classes)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Build HTML attributes for the component
|
|
99
|
+
# Adds Stimulus controller and auto-dismiss data attribute if configured
|
|
100
|
+
# @return [Hash] HTML attributes hash
|
|
101
|
+
def component_attributes
|
|
102
|
+
attrs = @options.dup
|
|
103
|
+
attrs[:data] ||= {}
|
|
104
|
+
# Register Stimulus controller for dismiss/auto-dismiss functionality
|
|
105
|
+
attrs[:data][:controller] = "better-ui--action-messages"
|
|
106
|
+
|
|
107
|
+
# Add auto-dismiss value if configured (Stimulus will read this)
|
|
108
|
+
if @auto_dismiss.present? && @auto_dismiss.to_f > 0
|
|
109
|
+
attrs[:data][:"better-ui--action-messages-auto-dismiss-value"] = @auto_dismiss.to_f
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
attrs
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# CSS classes for the title element
|
|
116
|
+
# @return [String] Merged CSS class string
|
|
117
|
+
def title_classes
|
|
118
|
+
css_classes("font-semibold", "mb-2", title_color_classes)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# CSS classes for the content wrapper (contains icon slot and message list)
|
|
122
|
+
# @return [String] Merged CSS class string
|
|
123
|
+
def content_wrapper_classes
|
|
124
|
+
css_classes("flex", "gap-3")
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# CSS classes for the message list (<ul>)
|
|
128
|
+
# @return [String] Merged CSS class string
|
|
129
|
+
def list_classes
|
|
130
|
+
css_classes("list-none", "list-inside", "space-y-1", "flex-1")
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# CSS classes for individual message list items (<li>)
|
|
134
|
+
# @return [String] Merged CSS class string
|
|
135
|
+
def list_item_classes
|
|
136
|
+
css_classes("text-sm")
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# CSS classes for the dismiss button
|
|
140
|
+
# @return [String] Merged CSS class string
|
|
141
|
+
def dismiss_button_classes
|
|
142
|
+
button_base = [ "absolute", "top-3", "right-3", "p-1", "rounded", "transition-colors" ]
|
|
143
|
+
button_colors = dismiss_button_color_classes
|
|
144
|
+
|
|
145
|
+
css_classes(button_base, button_colors)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
private
|
|
149
|
+
|
|
150
|
+
# Validates that the variant is one of the allowed variants.
|
|
151
|
+
#
|
|
152
|
+
# @raise [ArgumentError] if variant is not in ApplicationComponent::VARIANTS
|
|
153
|
+
# @api private
|
|
154
|
+
def validate_variant!
|
|
155
|
+
unless BetterUi::ApplicationComponent::VARIANTS.key?(@variant)
|
|
156
|
+
raise ArgumentError, "Invalid variant: #{@variant}. Must be one of #{BetterUi::ApplicationComponent::VARIANTS.keys.join(', ')}"
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Validates that the style is one of the allowed styles.
|
|
161
|
+
#
|
|
162
|
+
# @raise [ArgumentError] if style is not valid (solid, soft, outline, ghost)
|
|
163
|
+
# @api private
|
|
164
|
+
def validate_style!
|
|
165
|
+
valid_styles = [ :solid, :soft, :outline, :ghost ]
|
|
166
|
+
unless valid_styles.include?(@style)
|
|
167
|
+
raise ArgumentError, "Invalid style: #{@style}. Must be one of #{valid_styles.join(', ')}"
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# ============================================================================
|
|
172
|
+
# STYLE METHODS
|
|
173
|
+
# ============================================================================
|
|
174
|
+
# Each style method returns hardcoded CSS class strings for Tailwind JIT.
|
|
175
|
+
# Dynamic class generation (e.g., "bg-#{variant}-600") breaks Tailwind's
|
|
176
|
+
# static scanner, so we use case statements with explicit class strings.
|
|
177
|
+
# ============================================================================
|
|
178
|
+
|
|
179
|
+
# Solid style: full color background with white/dark text.
|
|
180
|
+
#
|
|
181
|
+
# Best for: Bold, high-contrast messages
|
|
182
|
+
# @return [Array<String>] array of CSS classes for solid style
|
|
183
|
+
# @api private
|
|
184
|
+
def solid_classes
|
|
185
|
+
bg_classes = case @variant
|
|
186
|
+
when :primary
|
|
187
|
+
[ "bg-primary-600" ]
|
|
188
|
+
when :secondary
|
|
189
|
+
[ "bg-secondary-500" ]
|
|
190
|
+
when :accent
|
|
191
|
+
[ "bg-accent-500" ]
|
|
192
|
+
when :success
|
|
193
|
+
[ "bg-success-600" ]
|
|
194
|
+
when :danger
|
|
195
|
+
[ "bg-danger-600" ]
|
|
196
|
+
when :warning
|
|
197
|
+
[ "bg-warning-500" ]
|
|
198
|
+
when :info
|
|
199
|
+
[ "bg-info-500" ]
|
|
200
|
+
when :light
|
|
201
|
+
[ "bg-grayscale-100" ]
|
|
202
|
+
when :dark
|
|
203
|
+
[ "bg-grayscale-900" ]
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
text_classes = solid_text_color_classes
|
|
207
|
+
|
|
208
|
+
bg_classes + text_classes
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Soft style: light background (shade 50) with colored text and subtle border.
|
|
212
|
+
#
|
|
213
|
+
# Best for: Gentle, non-intrusive messages (default style)
|
|
214
|
+
# @return [Array<String>] array of CSS classes for soft style
|
|
215
|
+
# @api private
|
|
216
|
+
def soft_classes
|
|
217
|
+
bg_classes = case @variant
|
|
218
|
+
when :primary
|
|
219
|
+
[ "bg-primary-50" ]
|
|
220
|
+
when :secondary
|
|
221
|
+
[ "bg-secondary-50" ]
|
|
222
|
+
when :accent
|
|
223
|
+
[ "bg-accent-50" ]
|
|
224
|
+
when :success
|
|
225
|
+
[ "bg-success-50" ]
|
|
226
|
+
when :danger
|
|
227
|
+
[ "bg-danger-50" ]
|
|
228
|
+
when :warning
|
|
229
|
+
[ "bg-warning-50" ]
|
|
230
|
+
when :info
|
|
231
|
+
[ "bg-info-50" ]
|
|
232
|
+
when :light
|
|
233
|
+
[ "bg-grayscale-50" ]
|
|
234
|
+
when :dark
|
|
235
|
+
[ "bg-grayscale-100" ]
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
border_classes = case @variant
|
|
239
|
+
when :primary
|
|
240
|
+
[ "border", "border-primary-200" ]
|
|
241
|
+
when :secondary
|
|
242
|
+
[ "border", "border-secondary-200" ]
|
|
243
|
+
when :accent
|
|
244
|
+
[ "border", "border-accent-200" ]
|
|
245
|
+
when :success
|
|
246
|
+
[ "border", "border-success-200" ]
|
|
247
|
+
when :danger
|
|
248
|
+
[ "border", "border-danger-200" ]
|
|
249
|
+
when :warning
|
|
250
|
+
[ "border", "border-warning-200" ]
|
|
251
|
+
when :info
|
|
252
|
+
[ "border", "border-info-200" ]
|
|
253
|
+
when :light
|
|
254
|
+
[ "border", "border-grayscale-200" ]
|
|
255
|
+
when :dark
|
|
256
|
+
[ "border", "border-grayscale-300" ]
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
text_classes = soft_text_color_classes
|
|
260
|
+
|
|
261
|
+
bg_classes + border_classes + text_classes
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# Outline style: white background with thick colored border.
|
|
265
|
+
#
|
|
266
|
+
# Best for: Clean, professional look with emphasis on border
|
|
267
|
+
# @return [Array<String>] array of CSS classes for outline style
|
|
268
|
+
# @api private
|
|
269
|
+
def outline_classes
|
|
270
|
+
border_classes = case @variant
|
|
271
|
+
when :primary
|
|
272
|
+
[ "border-2", "border-primary-500", "bg-white" ]
|
|
273
|
+
when :secondary
|
|
274
|
+
[ "border-2", "border-secondary-500", "bg-white" ]
|
|
275
|
+
when :accent
|
|
276
|
+
[ "border-2", "border-accent-500", "bg-white" ]
|
|
277
|
+
when :success
|
|
278
|
+
[ "border-2", "border-success-500", "bg-white" ]
|
|
279
|
+
when :danger
|
|
280
|
+
[ "border-2", "border-danger-500", "bg-white" ]
|
|
281
|
+
when :warning
|
|
282
|
+
[ "border-2", "border-warning-500", "bg-white" ]
|
|
283
|
+
when :info
|
|
284
|
+
[ "border-2", "border-info-500", "bg-white" ]
|
|
285
|
+
when :light
|
|
286
|
+
[ "border-2", "border-grayscale-300", "bg-white" ]
|
|
287
|
+
when :dark
|
|
288
|
+
[ "border-2", "border-grayscale-700", "bg-white" ]
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
text_classes = outline_text_color_classes
|
|
292
|
+
|
|
293
|
+
border_classes + text_classes
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# Ghost style: transparent background with colored text and hover effect.
|
|
297
|
+
#
|
|
298
|
+
# Best for: Subtle, minimal messages that blend with the page
|
|
299
|
+
# @return [Array<String>] array of CSS classes for ghost style
|
|
300
|
+
# @api private
|
|
301
|
+
def ghost_classes
|
|
302
|
+
text_classes = ghost_text_color_classes
|
|
303
|
+
hover_classes = ghost_hover_classes
|
|
304
|
+
|
|
305
|
+
text_classes + hover_classes
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# ============================================================================
|
|
309
|
+
# TEXT COLOR HELPERS
|
|
310
|
+
# ============================================================================
|
|
311
|
+
# These methods return text colors appropriate for each style.
|
|
312
|
+
# ============================================================================
|
|
313
|
+
|
|
314
|
+
# Text colors for solid style backgrounds.
|
|
315
|
+
#
|
|
316
|
+
# @return [Array<String>] array of text color classes for solid style
|
|
317
|
+
# @api private
|
|
318
|
+
def solid_text_color_classes
|
|
319
|
+
case @variant
|
|
320
|
+
when :light
|
|
321
|
+
[ "text-grayscale-900" ]
|
|
322
|
+
when :dark
|
|
323
|
+
[ "text-white" ]
|
|
324
|
+
when :warning
|
|
325
|
+
[ "text-grayscale-900" ]
|
|
326
|
+
else
|
|
327
|
+
[ "text-white" ]
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# Text colors for soft style backgrounds.
|
|
332
|
+
#
|
|
333
|
+
# @return [Array<String>] array of text color classes for soft style
|
|
334
|
+
# @api private
|
|
335
|
+
def soft_text_color_classes
|
|
336
|
+
case @variant
|
|
337
|
+
when :primary
|
|
338
|
+
[ "text-primary-900" ]
|
|
339
|
+
when :secondary
|
|
340
|
+
[ "text-secondary-900" ]
|
|
341
|
+
when :accent
|
|
342
|
+
[ "text-accent-900" ]
|
|
343
|
+
when :success
|
|
344
|
+
[ "text-success-900" ]
|
|
345
|
+
when :danger
|
|
346
|
+
[ "text-danger-900" ]
|
|
347
|
+
when :warning
|
|
348
|
+
[ "text-warning-900" ]
|
|
349
|
+
when :info
|
|
350
|
+
[ "text-info-900" ]
|
|
351
|
+
when :light
|
|
352
|
+
[ "text-grayscale-900" ]
|
|
353
|
+
when :dark
|
|
354
|
+
[ "text-grayscale-900" ]
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
# Text colors for outline style.
|
|
359
|
+
#
|
|
360
|
+
# @return [Array<String>] array of text color classes for outline style
|
|
361
|
+
# @api private
|
|
362
|
+
def outline_text_color_classes
|
|
363
|
+
case @variant
|
|
364
|
+
when :primary
|
|
365
|
+
[ "text-primary-700" ]
|
|
366
|
+
when :secondary
|
|
367
|
+
[ "text-secondary-700" ]
|
|
368
|
+
when :accent
|
|
369
|
+
[ "text-accent-700" ]
|
|
370
|
+
when :success
|
|
371
|
+
[ "text-success-700" ]
|
|
372
|
+
when :danger
|
|
373
|
+
[ "text-danger-700" ]
|
|
374
|
+
when :warning
|
|
375
|
+
[ "text-warning-700" ]
|
|
376
|
+
when :info
|
|
377
|
+
[ "text-info-700" ]
|
|
378
|
+
when :light
|
|
379
|
+
[ "text-grayscale-700" ]
|
|
380
|
+
when :dark
|
|
381
|
+
[ "text-grayscale-900" ]
|
|
382
|
+
end
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
# Text colors for ghost style.
|
|
386
|
+
#
|
|
387
|
+
# @return [Array<String>] array of text color classes for ghost style
|
|
388
|
+
# @api private
|
|
389
|
+
def ghost_text_color_classes
|
|
390
|
+
case @variant
|
|
391
|
+
when :primary
|
|
392
|
+
[ "text-primary-600" ]
|
|
393
|
+
when :secondary
|
|
394
|
+
[ "text-secondary-600" ]
|
|
395
|
+
when :accent
|
|
396
|
+
[ "text-accent-600" ]
|
|
397
|
+
when :success
|
|
398
|
+
[ "text-success-600" ]
|
|
399
|
+
when :danger
|
|
400
|
+
[ "text-danger-600" ]
|
|
401
|
+
when :warning
|
|
402
|
+
[ "text-warning-600" ]
|
|
403
|
+
when :info
|
|
404
|
+
[ "text-info-600" ]
|
|
405
|
+
when :light
|
|
406
|
+
[ "text-grayscale-600" ]
|
|
407
|
+
when :dark
|
|
408
|
+
[ "text-grayscale-900" ]
|
|
409
|
+
end
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
# Hover background colors for ghost style.
|
|
413
|
+
#
|
|
414
|
+
# @return [Array<String>] array of hover background color classes
|
|
415
|
+
# @api private
|
|
416
|
+
def ghost_hover_classes
|
|
417
|
+
case @variant
|
|
418
|
+
when :primary
|
|
419
|
+
[ "hover:bg-primary-50" ]
|
|
420
|
+
when :secondary
|
|
421
|
+
[ "hover:bg-secondary-50" ]
|
|
422
|
+
when :accent
|
|
423
|
+
[ "hover:bg-accent-50" ]
|
|
424
|
+
when :success
|
|
425
|
+
[ "hover:bg-success-50" ]
|
|
426
|
+
when :danger
|
|
427
|
+
[ "hover:bg-danger-50" ]
|
|
428
|
+
when :warning
|
|
429
|
+
[ "hover:bg-warning-50" ]
|
|
430
|
+
when :info
|
|
431
|
+
[ "hover:bg-info-50" ]
|
|
432
|
+
when :light
|
|
433
|
+
[ "hover:bg-grayscale-50" ]
|
|
434
|
+
when :dark
|
|
435
|
+
[ "hover:bg-grayscale-100" ]
|
|
436
|
+
end
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
# Get appropriate text color for title based on current style.
|
|
440
|
+
#
|
|
441
|
+
# Delegates to the corresponding text color method for the active style.
|
|
442
|
+
#
|
|
443
|
+
# @return [Array<String>] array of text color classes for the title
|
|
444
|
+
# @api private
|
|
445
|
+
def title_color_classes
|
|
446
|
+
case @style
|
|
447
|
+
when :solid
|
|
448
|
+
solid_text_color_classes
|
|
449
|
+
when :soft
|
|
450
|
+
soft_text_color_classes
|
|
451
|
+
when :outline
|
|
452
|
+
outline_text_color_classes
|
|
453
|
+
when :ghost
|
|
454
|
+
ghost_text_color_classes
|
|
455
|
+
end
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
# Get appropriate colors for dismiss button based on style and variant.
|
|
459
|
+
#
|
|
460
|
+
# Button colors must provide good contrast and visibility for all style
|
|
461
|
+
# and variant combinations.
|
|
462
|
+
#
|
|
463
|
+
# @return [Array<String>] array of text and hover background color classes for dismiss button
|
|
464
|
+
# @api private
|
|
465
|
+
def dismiss_button_color_classes
|
|
466
|
+
case @style
|
|
467
|
+
when :solid
|
|
468
|
+
case @variant
|
|
469
|
+
when :light
|
|
470
|
+
[ "text-grayscale-700", "hover:bg-grayscale-200" ]
|
|
471
|
+
when :dark
|
|
472
|
+
[ "text-grayscale-300", "hover:bg-grayscale-800" ]
|
|
473
|
+
when :warning
|
|
474
|
+
[ "text-grayscale-700", "hover:bg-warning-600" ]
|
|
475
|
+
else
|
|
476
|
+
[ "text-white", "hover:bg-black", "hover:bg-opacity-10" ]
|
|
477
|
+
end
|
|
478
|
+
when :soft
|
|
479
|
+
case @variant
|
|
480
|
+
when :primary
|
|
481
|
+
[ "text-primary-700", "hover:bg-primary-100" ]
|
|
482
|
+
when :secondary
|
|
483
|
+
[ "text-secondary-700", "hover:bg-secondary-100" ]
|
|
484
|
+
when :accent
|
|
485
|
+
[ "text-accent-700", "hover:bg-accent-100" ]
|
|
486
|
+
when :success
|
|
487
|
+
[ "text-success-700", "hover:bg-success-100" ]
|
|
488
|
+
when :danger
|
|
489
|
+
[ "text-danger-700", "hover:bg-danger-100" ]
|
|
490
|
+
when :warning
|
|
491
|
+
[ "text-warning-700", "hover:bg-warning-100" ]
|
|
492
|
+
when :info
|
|
493
|
+
[ "text-info-700", "hover:bg-info-100" ]
|
|
494
|
+
when :light
|
|
495
|
+
[ "text-grayscale-700", "hover:bg-grayscale-100" ]
|
|
496
|
+
when :dark
|
|
497
|
+
[ "text-grayscale-700", "hover:bg-grayscale-200" ]
|
|
498
|
+
end
|
|
499
|
+
when :outline
|
|
500
|
+
case @variant
|
|
501
|
+
when :primary
|
|
502
|
+
[ "text-primary-600", "hover:bg-primary-50" ]
|
|
503
|
+
when :secondary
|
|
504
|
+
[ "text-secondary-600", "hover:bg-secondary-50" ]
|
|
505
|
+
when :accent
|
|
506
|
+
[ "text-accent-600", "hover:bg-accent-50" ]
|
|
507
|
+
when :success
|
|
508
|
+
[ "text-success-600", "hover:bg-success-50" ]
|
|
509
|
+
when :danger
|
|
510
|
+
[ "text-danger-600", "hover:bg-danger-50" ]
|
|
511
|
+
when :warning
|
|
512
|
+
[ "text-warning-600", "hover:bg-warning-50" ]
|
|
513
|
+
when :info
|
|
514
|
+
[ "text-info-600", "hover:bg-info-50" ]
|
|
515
|
+
when :light
|
|
516
|
+
[ "text-grayscale-600", "hover:bg-grayscale-50" ]
|
|
517
|
+
when :dark
|
|
518
|
+
[ "text-grayscale-800", "hover:bg-grayscale-100" ]
|
|
519
|
+
end
|
|
520
|
+
when :ghost
|
|
521
|
+
case @variant
|
|
522
|
+
when :primary
|
|
523
|
+
[ "text-primary-600", "hover:bg-primary-100" ]
|
|
524
|
+
when :secondary
|
|
525
|
+
[ "text-secondary-600", "hover:bg-secondary-100" ]
|
|
526
|
+
when :accent
|
|
527
|
+
[ "text-accent-600", "hover:bg-accent-100" ]
|
|
528
|
+
when :success
|
|
529
|
+
[ "text-success-600", "hover:bg-success-100" ]
|
|
530
|
+
when :danger
|
|
531
|
+
[ "text-danger-600", "hover:bg-danger-100" ]
|
|
532
|
+
when :warning
|
|
533
|
+
[ "text-warning-600", "hover:bg-warning-100" ]
|
|
534
|
+
when :info
|
|
535
|
+
[ "text-info-600", "hover:bg-info-100" ]
|
|
536
|
+
when :light
|
|
537
|
+
[ "text-grayscale-600", "hover:bg-grayscale-100" ]
|
|
538
|
+
when :dark
|
|
539
|
+
[ "text-grayscale-800", "hover:bg-grayscale-200" ]
|
|
540
|
+
end
|
|
541
|
+
end
|
|
542
|
+
end
|
|
543
|
+
end
|
|
544
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterUi
|
|
4
|
+
# Base component class for all ViewComponents in BetterUi.
|
|
5
|
+
#
|
|
6
|
+
# This class provides:
|
|
7
|
+
# - Common configuration for all components
|
|
8
|
+
# - Shared helper methods
|
|
9
|
+
# - Consistent behavior across components
|
|
10
|
+
#
|
|
11
|
+
# Usage:
|
|
12
|
+
# class BetterUi::MyComponent < BetterUi::ApplicationComponent
|
|
13
|
+
# # component implementation
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
# Best Practices:
|
|
17
|
+
# - Use composition over inheritance
|
|
18
|
+
# - Keep instance methods private
|
|
19
|
+
# - Pass data explicitly (avoid global state)
|
|
20
|
+
# - Test against rendered content, not internals
|
|
21
|
+
class ApplicationComponent < ViewComponent::Base
|
|
22
|
+
# Enable content areas (slots) by default
|
|
23
|
+
# Allows components to accept flexible content blocks
|
|
24
|
+
# Example: <%= render(BetterUi::MyComponent.new) do |c| %>
|
|
25
|
+
# <% c.with_header { "Title" } %>
|
|
26
|
+
# <% end %>
|
|
27
|
+
|
|
28
|
+
# Color variant definitions with default shades
|
|
29
|
+
#
|
|
30
|
+
# This is the single source of truth for all color variants used throughout BetterUi.
|
|
31
|
+
# Use VARIANTS.keys to iterate over available variants in components and preview templates.
|
|
32
|
+
#
|
|
33
|
+
# Related CSS definitions:
|
|
34
|
+
# - CSS custom properties defined in host app via generator (better_ui_theme.css @theme inline)
|
|
35
|
+
# - Typography color utilities: .text-heading-{variant} (@layer utilities)
|
|
36
|
+
#
|
|
37
|
+
# Note: Case statements in components must use hardcoded Tailwind class strings
|
|
38
|
+
# (e.g., "bg-primary-600") for the JIT compiler to detect them at build time.
|
|
39
|
+
# The VARIANTS constant is used for iteration and validation only.
|
|
40
|
+
VARIANTS = {
|
|
41
|
+
primary: 600, # Strong, trustworthy actions
|
|
42
|
+
secondary: 500, # Neutral, supporting elements
|
|
43
|
+
accent: 500, # Highlights and special features
|
|
44
|
+
success: 600, # Positive actions, confirmations
|
|
45
|
+
danger: 600, # Destructive actions, errors
|
|
46
|
+
warning: 500, # Caution, alerts
|
|
47
|
+
info: 500, # Informational, tips
|
|
48
|
+
light: 100, # Light backgrounds and light text
|
|
49
|
+
dark: 900 # Dark backgrounds and dark text
|
|
50
|
+
}.freeze
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
# Helper to merge CSS classes intelligently using TailwindMerge
|
|
55
|
+
# Resolves conflicting Tailwind utility classes
|
|
56
|
+
#
|
|
57
|
+
# @param classes [Array<String>] CSS class names to merge
|
|
58
|
+
# @return [String] Merged CSS classes
|
|
59
|
+
#
|
|
60
|
+
# Example:
|
|
61
|
+
# css_classes("px-4 py-2", "px-6") #=> "py-2 px-6"
|
|
62
|
+
def css_classes(*classes)
|
|
63
|
+
TailwindMerge::Merger.new.merge(classes.compact.join(" "))
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
<% if link? %>
|
|
2
|
+
<a <%= tag.attributes(component_attributes) %> class="<%= component_classes %>">
|
|
3
|
+
<span data-better-ui--button-target="spinner" class="<%= show_loader ? '' : 'hidden' %>">
|
|
4
|
+
<svg class="animate-spin <%= SIZES[size][:icon] %>" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" aria-hidden="true">
|
|
5
|
+
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
6
|
+
<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>
|
|
7
|
+
</svg>
|
|
8
|
+
<span class="sr-only">Loading...</span>
|
|
9
|
+
</span>
|
|
10
|
+
<span data-better-ui--button-target="content" class="<%= show_loader ? 'hidden' : '' %> inline-flex items-center <%= SIZES[size][:gap] %>">
|
|
11
|
+
<%= icon_before if icon_before? %>
|
|
12
|
+
<%= content %>
|
|
13
|
+
<%= icon_after if icon_after? %>
|
|
14
|
+
</span>
|
|
15
|
+
</a>
|
|
16
|
+
<% else %>
|
|
17
|
+
<button <%= tag.attributes(component_attributes) %> class="<%= component_classes %>">
|
|
18
|
+
<span data-better-ui--button-target="spinner" class="<%= show_loader ? '' : 'hidden' %>">
|
|
19
|
+
<svg class="animate-spin <%= SIZES[size][:icon] %>" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" aria-hidden="true">
|
|
20
|
+
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
21
|
+
<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>
|
|
22
|
+
</svg>
|
|
23
|
+
<span class="sr-only">Loading...</span>
|
|
24
|
+
</span>
|
|
25
|
+
<span data-better-ui--button-target="content" class="<%= show_loader ? 'hidden' : '' %> inline-flex items-center <%= SIZES[size][:gap] %>">
|
|
26
|
+
<%= icon_before if icon_before? %>
|
|
27
|
+
<%= content %>
|
|
28
|
+
<%= icon_after if icon_after? %>
|
|
29
|
+
</span>
|
|
30
|
+
</button>
|
|
31
|
+
<% end %>
|