better_ui 0.7.2 → 0.9.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 +86 -29
- data/app/components/better_ui/application_component.rb +34 -0
- data/app/components/better_ui/avatar_component/avatar_component.html.erb +15 -0
- data/app/components/better_ui/avatar_component.rb +227 -0
- data/app/components/better_ui/badge_component/badge_component.html.erb +16 -0
- data/app/components/better_ui/badge_component.rb +114 -0
- data/app/components/better_ui/breadcrumb/breadcrumb_component/breadcrumb_component.html.erb +12 -0
- data/app/components/better_ui/breadcrumb/breadcrumb_component.rb +148 -0
- data/app/components/better_ui/breadcrumb/item_component/item_component.html.erb +11 -0
- data/app/components/better_ui/breadcrumb/item_component.rb +78 -0
- data/app/components/better_ui/card_component.rb +45 -11
- data/app/components/better_ui/concerns/inline_label_styles.rb +137 -0
- data/app/components/better_ui/container_component/container_component.html.erb +3 -0
- data/app/components/better_ui/container_component.rb +143 -0
- data/app/components/better_ui/dialog/alert_component/alert_component.html.erb +61 -0
- data/app/components/better_ui/dialog/alert_component.rb +78 -0
- data/app/components/better_ui/dialog/confirm_component/confirm_component.html.erb +67 -0
- data/app/components/better_ui/dialog/confirm_component.rb +80 -0
- data/app/components/better_ui/dialog/dialog_component/dialog_component.html.erb +44 -0
- data/app/components/better_ui/dialog/dialog_component.rb +81 -0
- data/app/components/better_ui/divider_component/divider_component.html.erb +11 -0
- data/app/components/better_ui/divider_component.rb +344 -0
- data/app/components/better_ui/drawer/sidebar_component.rb +1 -0
- data/app/components/better_ui/dropdown/divider_component/divider_component.html.erb +1 -0
- data/app/components/better_ui/dropdown/divider_component.rb +20 -0
- data/app/components/better_ui/dropdown/dropdown_component/dropdown_component.html.erb +19 -0
- data/app/components/better_ui/dropdown/dropdown_component.rb +108 -0
- data/app/components/better_ui/dropdown/header_component/header_component.html.erb +3 -0
- data/app/components/better_ui/dropdown/header_component.rb +25 -0
- data/app/components/better_ui/dropdown/item_component/item_component.html.erb +7 -0
- data/app/components/better_ui/dropdown/item_component.rb +97 -0
- data/app/components/better_ui/fa_icon_component/fa_icon_component.html.erb +1 -0
- data/app/components/better_ui/fa_icon_component.rb +165 -0
- data/app/components/better_ui/forms/base_component.rb +3 -1
- data/app/components/better_ui/forms/select_component/select_component.html.erb +86 -0
- data/app/components/better_ui/forms/select_component.rb +347 -0
- data/app/components/better_ui/forms/text_input_component.rb +24 -2
- data/app/components/better_ui/heading_component/heading_component.html.erb +11 -0
- data/app/components/better_ui/heading_component.rb +259 -0
- data/app/components/better_ui/link_component/link_component.html.erb +5 -0
- data/app/components/better_ui/link_component.rb +169 -0
- data/app/components/better_ui/progress_component/progress_component.html.erb +15 -0
- data/app/components/better_ui/progress_component.rb +98 -0
- data/app/components/better_ui/spinner_component/spinner_component.html.erb +11 -0
- data/app/components/better_ui/spinner_component.rb +70 -0
- data/app/components/better_ui/table/cell_component/cell_component.html.erb +3 -0
- data/app/components/better_ui/table/cell_component.rb +84 -0
- data/app/components/better_ui/table/column_component.rb +75 -0
- data/app/components/better_ui/table/header_cell_component/header_cell_component.html.erb +18 -0
- data/app/components/better_ui/table/header_cell_component.rb +138 -0
- data/app/components/better_ui/table/header_component/header_component.html.erb +5 -0
- data/app/components/better_ui/table/header_component.rb +37 -0
- data/app/components/better_ui/table/row_component/row_component.html.erb +5 -0
- data/app/components/better_ui/table/row_component.rb +88 -0
- data/app/components/better_ui/table/table_component/table_component.html.erb +90 -0
- data/app/components/better_ui/table/table_component.rb +467 -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/components/better_ui/tag_component/tag_component.html.erb +33 -0
- data/app/components/better_ui/tag_component.rb +114 -0
- data/app/components/better_ui/tooltip_component/tooltip_component.html.erb +11 -0
- data/app/components/better_ui/tooltip_component.rb +154 -0
- data/app/form_builders/better_ui/ui_form_builder.rb +90 -0
- data/app/helpers/better_ui/application_helper.rb +575 -0
- data/lib/better_ui/engine.rb +7 -0
- data/lib/better_ui/version.rb +1 -1
- metadata +63 -3
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterUi
|
|
4
|
+
class FaIconComponent < ApplicationComponent
|
|
5
|
+
STYLES = {
|
|
6
|
+
regular: "fa-regular",
|
|
7
|
+
solid: "fa-solid",
|
|
8
|
+
light: "fa-light",
|
|
9
|
+
thin: "fa-thin",
|
|
10
|
+
brands: "fa-brands"
|
|
11
|
+
}.freeze
|
|
12
|
+
|
|
13
|
+
SIZES = {
|
|
14
|
+
xs: "fa-xs",
|
|
15
|
+
sm: "fa-sm",
|
|
16
|
+
md: nil,
|
|
17
|
+
lg: "fa-lg",
|
|
18
|
+
xl: "fa-xl",
|
|
19
|
+
"2xl": "fa-2xl"
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
FLIPS = {
|
|
23
|
+
horizontal: "fa-flip-horizontal",
|
|
24
|
+
vertical: "fa-flip-vertical",
|
|
25
|
+
both: "fa-flip-both"
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
ROTATIONS = {
|
|
29
|
+
90 => "fa-rotate-90",
|
|
30
|
+
180 => "fa-rotate-180",
|
|
31
|
+
270 => "fa-rotate-270"
|
|
32
|
+
}.freeze
|
|
33
|
+
|
|
34
|
+
def initialize(
|
|
35
|
+
name:,
|
|
36
|
+
style: :regular,
|
|
37
|
+
variant: nil,
|
|
38
|
+
size: :md,
|
|
39
|
+
spin: false,
|
|
40
|
+
pulse: false,
|
|
41
|
+
flip: nil,
|
|
42
|
+
rotate: nil,
|
|
43
|
+
fixed_width: false,
|
|
44
|
+
container_classes: nil,
|
|
45
|
+
**options
|
|
46
|
+
)
|
|
47
|
+
@name = name
|
|
48
|
+
@style = validate_style(style)
|
|
49
|
+
@variant = validate_variant(variant)
|
|
50
|
+
@size = validate_size(size)
|
|
51
|
+
@spin = spin
|
|
52
|
+
@pulse = pulse
|
|
53
|
+
@flip = validate_flip(flip)
|
|
54
|
+
@rotate = validate_rotate(rotate)
|
|
55
|
+
@fixed_width = fixed_width
|
|
56
|
+
@container_classes = container_classes
|
|
57
|
+
@options = options
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
attr_reader :name, :style, :variant, :size, :spin, :pulse, :flip, :rotate,
|
|
63
|
+
:fixed_width, :container_classes, :options
|
|
64
|
+
|
|
65
|
+
def component_classes
|
|
66
|
+
css_classes([
|
|
67
|
+
style_class,
|
|
68
|
+
icon_class,
|
|
69
|
+
size_class,
|
|
70
|
+
variant_class,
|
|
71
|
+
animation_classes,
|
|
72
|
+
flip_class,
|
|
73
|
+
rotate_class,
|
|
74
|
+
fixed_width_class,
|
|
75
|
+
@container_classes
|
|
76
|
+
].flatten.compact)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def style_class
|
|
80
|
+
STYLES[@style]
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def icon_class
|
|
84
|
+
"fa-#{@name}"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def size_class
|
|
88
|
+
SIZES[@size]
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def variant_class
|
|
92
|
+
case @variant
|
|
93
|
+
when nil then nil
|
|
94
|
+
when :primary then "text-primary-600"
|
|
95
|
+
when :secondary then "text-secondary-500"
|
|
96
|
+
when :accent then "text-accent-500"
|
|
97
|
+
when :success then "text-success-600"
|
|
98
|
+
when :danger then "text-danger-600"
|
|
99
|
+
when :warning then "text-warning-500"
|
|
100
|
+
when :info then "text-info-500"
|
|
101
|
+
when :light then "text-grayscale-400"
|
|
102
|
+
when :dark then "text-grayscale-800"
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def animation_classes
|
|
107
|
+
classes = []
|
|
108
|
+
classes << "fa-spin" if @spin
|
|
109
|
+
classes << "fa-pulse" if @pulse
|
|
110
|
+
classes
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def flip_class
|
|
114
|
+
return nil unless @flip
|
|
115
|
+
FLIPS[@flip]
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def rotate_class
|
|
119
|
+
return nil unless @rotate
|
|
120
|
+
ROTATIONS[@rotate]
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def fixed_width_class
|
|
124
|
+
@fixed_width ? "fa-fw" : nil
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def validate_style(style)
|
|
128
|
+
unless STYLES.key?(style)
|
|
129
|
+
raise ArgumentError, "Invalid style: #{style}. Must be one of: #{STYLES.keys.join(", ")}"
|
|
130
|
+
end
|
|
131
|
+
style
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def validate_variant(variant)
|
|
135
|
+
return nil if variant.nil?
|
|
136
|
+
unless BetterUi::ApplicationComponent::VARIANTS.key?(variant)
|
|
137
|
+
raise ArgumentError, "Invalid variant: #{variant}. Must be one of: #{BetterUi::ApplicationComponent::VARIANTS.keys.join(", ")}"
|
|
138
|
+
end
|
|
139
|
+
variant
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def validate_size(size)
|
|
143
|
+
unless SIZES.key?(size)
|
|
144
|
+
raise ArgumentError, "Invalid size: #{size}. Must be one of: #{SIZES.keys.join(", ")}"
|
|
145
|
+
end
|
|
146
|
+
size
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def validate_flip(flip)
|
|
150
|
+
return nil if flip.nil?
|
|
151
|
+
unless FLIPS.key?(flip)
|
|
152
|
+
raise ArgumentError, "Invalid flip: #{flip}. Must be one of: #{FLIPS.keys.join(", ")}"
|
|
153
|
+
end
|
|
154
|
+
flip
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def validate_rotate(rotate)
|
|
158
|
+
return nil if rotate.nil?
|
|
159
|
+
unless ROTATIONS.key?(rotate)
|
|
160
|
+
raise ArgumentError, "Invalid rotate: #{rotate}. Must be one of: #{ROTATIONS.keys.join(", ")}"
|
|
161
|
+
end
|
|
162
|
+
rotate
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
@@ -92,6 +92,7 @@ module BetterUi
|
|
|
92
92
|
readonly: false,
|
|
93
93
|
required: false,
|
|
94
94
|
errors: nil,
|
|
95
|
+
shadow: :sm,
|
|
95
96
|
container_classes: nil,
|
|
96
97
|
label_classes: nil,
|
|
97
98
|
input_classes: nil,
|
|
@@ -109,6 +110,7 @@ module BetterUi
|
|
|
109
110
|
@readonly = readonly
|
|
110
111
|
@required = required
|
|
111
112
|
@errors = Array(errors).compact.reject(&:blank?)
|
|
113
|
+
@shadow = normalize_shadow(shadow)
|
|
112
114
|
@container_classes = container_classes
|
|
113
115
|
@label_classes = label_classes
|
|
114
116
|
@input_classes = input_classes
|
|
@@ -238,7 +240,7 @@ module BetterUi
|
|
|
238
240
|
"w-full",
|
|
239
241
|
"rounded-md",
|
|
240
242
|
"border",
|
|
241
|
-
|
|
243
|
+
SHADOWS[@shadow],
|
|
242
244
|
"transition-colors",
|
|
243
245
|
"duration-200"
|
|
244
246
|
]
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
<div class="<%= wrapper_classes %>">
|
|
2
|
+
<% if @label.present? %>
|
|
3
|
+
<label for="<%= input_id %>" class="<%= label_element_classes %>">
|
|
4
|
+
<%= @label %>
|
|
5
|
+
<% if @required %>
|
|
6
|
+
<span class="text-danger-600">*</span>
|
|
7
|
+
<% end %>
|
|
8
|
+
</label>
|
|
9
|
+
<% end %>
|
|
10
|
+
|
|
11
|
+
<div class="relative" data-controller="better-ui--forms--select"
|
|
12
|
+
data-better-ui--forms--select-clearable-value="<%= @clearable %>"
|
|
13
|
+
data-better-ui--forms--select-placeholder-value="<%= placeholder_text %>"
|
|
14
|
+
data-better-ui--forms--select-disabled-value="<%= @disabled %>"
|
|
15
|
+
data-better-ui--forms--select-readonly-value="<%= @readonly %>">
|
|
16
|
+
|
|
17
|
+
<input <%= tag.attributes(hidden_input_attributes) %> />
|
|
18
|
+
|
|
19
|
+
<button <%= tag.attributes(trigger_attributes) %>>
|
|
20
|
+
<% if prefix_icon.present? %>
|
|
21
|
+
<span class="flex items-center mr-2">
|
|
22
|
+
<%= prefix_icon %>
|
|
23
|
+
</span>
|
|
24
|
+
<% end %>
|
|
25
|
+
|
|
26
|
+
<span class="<%= trigger_text_classes %>"
|
|
27
|
+
data-better-ui--forms--select-target="display">
|
|
28
|
+
<%= has_selection? ? selected_label : placeholder_text %>
|
|
29
|
+
</span>
|
|
30
|
+
|
|
31
|
+
<% if @clearable %>
|
|
32
|
+
<span class="<%= has_selection? ? 'flex items-center ml-1' : 'hidden flex items-center ml-1' %>"
|
|
33
|
+
data-better-ui--forms--select-target="clearButton"
|
|
34
|
+
data-action="click->better-ui--forms--select#clear">
|
|
35
|
+
<svg class="<%= caret_icon_size %> text-gray-400 hover:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
36
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
|
37
|
+
</svg>
|
|
38
|
+
</span>
|
|
39
|
+
<% end %>
|
|
40
|
+
|
|
41
|
+
<span class="flex items-center ml-2"
|
|
42
|
+
data-better-ui--forms--select-target="caret">
|
|
43
|
+
<svg class="<%= caret_icon_size %> text-gray-400 transition-transform duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
44
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
|
45
|
+
</svg>
|
|
46
|
+
</span>
|
|
47
|
+
</button>
|
|
48
|
+
|
|
49
|
+
<ul id="<%= listbox_id %>"
|
|
50
|
+
role="listbox"
|
|
51
|
+
class="<%= dropdown_element_classes %>"
|
|
52
|
+
data-better-ui--forms--select-target="listbox"
|
|
53
|
+
data-action="keydown->better-ui--forms--select#handleListboxKeydown">
|
|
54
|
+
<% @collection.each_with_index do |item, index| %>
|
|
55
|
+
<% value = item_value(item) %>
|
|
56
|
+
<% label = item_label(item) %>
|
|
57
|
+
<% selected = has_selection? && @value.to_s == value %>
|
|
58
|
+
<li role="option"
|
|
59
|
+
id="<%= input_id %>_option_<%= index %>"
|
|
60
|
+
class="<%= option_element_classes(selected) %>"
|
|
61
|
+
data-value="<%= value %>"
|
|
62
|
+
data-label="<%= label %>"
|
|
63
|
+
aria-selected="<%= selected %>"
|
|
64
|
+
data-better-ui--forms--select-target="option"
|
|
65
|
+
data-action="click->better-ui--forms--select#selectOption"
|
|
66
|
+
tabindex="-1">
|
|
67
|
+
<%= label %>
|
|
68
|
+
</li>
|
|
69
|
+
<% end %>
|
|
70
|
+
</ul>
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
<% if @hint.present? %>
|
|
74
|
+
<div class="<%= hint_element_classes %>">
|
|
75
|
+
<%= @hint %>
|
|
76
|
+
</div>
|
|
77
|
+
<% end %>
|
|
78
|
+
|
|
79
|
+
<% if has_errors? %>
|
|
80
|
+
<div class="<%= errors_element_classes %>">
|
|
81
|
+
<% @errors.each do |error| %>
|
|
82
|
+
<div><%= error %></div>
|
|
83
|
+
<% end %>
|
|
84
|
+
</div>
|
|
85
|
+
<% end %>
|
|
86
|
+
</div>
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterUi
|
|
4
|
+
module Forms
|
|
5
|
+
# A custom dropdown select component for choosing a single option from a collection.
|
|
6
|
+
#
|
|
7
|
+
# This component extends {BaseComponent} to provide a custom select dropdown with
|
|
8
|
+
# keyboard navigation, type-ahead search, and full ARIA support. It uses a hidden
|
|
9
|
+
# input for form submission and a Stimulus controller for interactivity.
|
|
10
|
+
#
|
|
11
|
+
# @example Basic select
|
|
12
|
+
# <%= render BetterUi::Forms::SelectComponent.new(
|
|
13
|
+
# name: "user[country]",
|
|
14
|
+
# collection: [["Italy", "it"], ["France", "fr"], ["Germany", "de"]],
|
|
15
|
+
# label: "Country"
|
|
16
|
+
# ) %>
|
|
17
|
+
#
|
|
18
|
+
# @example With prefix icon
|
|
19
|
+
# <%= render BetterUi::Forms::SelectComponent.new(
|
|
20
|
+
# name: "country",
|
|
21
|
+
# collection: [["Italy", "it"], ["France", "fr"]],
|
|
22
|
+
# label: "Country",
|
|
23
|
+
# clearable: true
|
|
24
|
+
# ) do |component| %>
|
|
25
|
+
# <% component.with_prefix_icon do %>
|
|
26
|
+
# <svg class="h-5 w-5 text-gray-400">...</svg>
|
|
27
|
+
# <% end %>
|
|
28
|
+
# <% end %>
|
|
29
|
+
#
|
|
30
|
+
# @example Simple array collection
|
|
31
|
+
# <%= render BetterUi::Forms::SelectComponent.new(
|
|
32
|
+
# name: "color",
|
|
33
|
+
# collection: ["Red", "Blue", "Green"],
|
|
34
|
+
# placeholder: "Pick a color"
|
|
35
|
+
# ) %>
|
|
36
|
+
#
|
|
37
|
+
# @see BaseComponent
|
|
38
|
+
# @see BetterUi::UiFormBuilder#bui_select
|
|
39
|
+
class SelectComponent < BaseComponent
|
|
40
|
+
renders_one :prefix_icon
|
|
41
|
+
|
|
42
|
+
# @param name [String] the name attribute for the hidden input field
|
|
43
|
+
# @param collection [Array] array of values or [label, value] pairs
|
|
44
|
+
# @param clearable [Boolean] whether to show a clear button
|
|
45
|
+
# @param dropdown_classes [String, nil] custom CSS classes for the dropdown
|
|
46
|
+
# @param (see BaseComponent#initialize)
|
|
47
|
+
def initialize(
|
|
48
|
+
name:,
|
|
49
|
+
collection: [],
|
|
50
|
+
clearable: false,
|
|
51
|
+
dropdown_classes: nil,
|
|
52
|
+
value: nil,
|
|
53
|
+
label: nil,
|
|
54
|
+
hint: nil,
|
|
55
|
+
placeholder: nil,
|
|
56
|
+
size: :md,
|
|
57
|
+
disabled: false,
|
|
58
|
+
readonly: false,
|
|
59
|
+
required: false,
|
|
60
|
+
errors: nil,
|
|
61
|
+
shadow: :sm,
|
|
62
|
+
container_classes: nil,
|
|
63
|
+
label_classes: nil,
|
|
64
|
+
input_classes: nil,
|
|
65
|
+
hint_classes: nil,
|
|
66
|
+
error_classes: nil,
|
|
67
|
+
**options
|
|
68
|
+
)
|
|
69
|
+
@collection = collection
|
|
70
|
+
@clearable = clearable
|
|
71
|
+
@dropdown_classes = dropdown_classes
|
|
72
|
+
super(
|
|
73
|
+
name: name,
|
|
74
|
+
value: value,
|
|
75
|
+
label: label,
|
|
76
|
+
hint: hint,
|
|
77
|
+
placeholder: placeholder,
|
|
78
|
+
size: size,
|
|
79
|
+
disabled: disabled,
|
|
80
|
+
readonly: readonly,
|
|
81
|
+
required: required,
|
|
82
|
+
errors: errors,
|
|
83
|
+
shadow: shadow,
|
|
84
|
+
container_classes: container_classes,
|
|
85
|
+
label_classes: label_classes,
|
|
86
|
+
input_classes: input_classes,
|
|
87
|
+
hint_classes: hint_classes,
|
|
88
|
+
error_classes: error_classes,
|
|
89
|
+
**options
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
# Extracts the label from a collection item.
|
|
96
|
+
#
|
|
97
|
+
# @param item [String, Array] the collection item
|
|
98
|
+
# @return [String] the label text
|
|
99
|
+
def item_label(item)
|
|
100
|
+
item.is_a?(Array) ? item.first.to_s : item.to_s
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Extracts the value from a collection item.
|
|
104
|
+
#
|
|
105
|
+
# @param item [String, Array] the collection item
|
|
106
|
+
# @return [String] the value
|
|
107
|
+
def item_value(item)
|
|
108
|
+
item.is_a?(Array) ? item.last.to_s : item.to_s
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Finds the label for the currently selected value.
|
|
112
|
+
#
|
|
113
|
+
# @return [String, nil] the label of the selected option, or nil
|
|
114
|
+
def selected_label
|
|
115
|
+
return nil unless has_selection?
|
|
116
|
+
|
|
117
|
+
selected_item = @collection.find { |item| item_value(item) == @value.to_s }
|
|
118
|
+
selected_item ? item_label(selected_item) : @value.to_s
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Checks if there is a current selection.
|
|
122
|
+
#
|
|
123
|
+
# @return [Boolean] true if a value is present
|
|
124
|
+
def has_selection?
|
|
125
|
+
@value.present?
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Generates a unique ID based on the input name.
|
|
129
|
+
#
|
|
130
|
+
# @return [String] the generated ID
|
|
131
|
+
def input_id
|
|
132
|
+
@name.to_s.gsub(/\[|\]/, "_").gsub(/_+/, "_").chomp("_")
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Generates a unique ID for the listbox element.
|
|
136
|
+
#
|
|
137
|
+
# @return [String] the listbox element ID
|
|
138
|
+
def listbox_id
|
|
139
|
+
"#{input_id}_listbox"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Returns CSS classes for the trigger button element.
|
|
143
|
+
#
|
|
144
|
+
# @return [String] merged CSS classes
|
|
145
|
+
def trigger_element_classes
|
|
146
|
+
css_classes([
|
|
147
|
+
trigger_base_classes,
|
|
148
|
+
size_input_classes,
|
|
149
|
+
trigger_state_classes,
|
|
150
|
+
@input_classes
|
|
151
|
+
].flatten.compact)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Returns the base CSS classes for the trigger element.
|
|
155
|
+
# Uses flex instead of block (unlike text inputs) for icon layout.
|
|
156
|
+
#
|
|
157
|
+
# @return [Array<String>] base trigger CSS classes
|
|
158
|
+
def trigger_base_classes
|
|
159
|
+
[
|
|
160
|
+
"block",
|
|
161
|
+
"w-full",
|
|
162
|
+
"rounded-md",
|
|
163
|
+
"border",
|
|
164
|
+
SHADOWS[@shadow],
|
|
165
|
+
"transition-colors",
|
|
166
|
+
"duration-200",
|
|
167
|
+
"flex",
|
|
168
|
+
"items-center",
|
|
169
|
+
"text-left"
|
|
170
|
+
]
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Returns state-specific trigger classes with correct cursor.
|
|
174
|
+
# Cursor-pointer is only applied in normal and error states.
|
|
175
|
+
#
|
|
176
|
+
# @return [Array<String>] state CSS classes
|
|
177
|
+
def trigger_state_classes
|
|
178
|
+
if @disabled
|
|
179
|
+
disabled_classes
|
|
180
|
+
elsif @readonly
|
|
181
|
+
readonly_classes
|
|
182
|
+
elsif has_errors?
|
|
183
|
+
error_state_classes + [ "cursor-pointer" ]
|
|
184
|
+
else
|
|
185
|
+
normal_state_classes + [ "cursor-pointer" ]
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Returns CSS classes for the trigger display text.
|
|
190
|
+
#
|
|
191
|
+
# @return [String] merged CSS classes
|
|
192
|
+
def trigger_text_classes
|
|
193
|
+
if has_selection?
|
|
194
|
+
css_classes([ "flex-1", "truncate", "text-gray-900" ])
|
|
195
|
+
else
|
|
196
|
+
css_classes([ "flex-1", "truncate", "text-gray-400" ])
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Returns CSS classes for the dropdown listbox.
|
|
201
|
+
#
|
|
202
|
+
# @return [String] merged CSS classes
|
|
203
|
+
def dropdown_element_classes
|
|
204
|
+
css_classes([
|
|
205
|
+
"absolute",
|
|
206
|
+
"z-50",
|
|
207
|
+
"w-full",
|
|
208
|
+
"mt-1",
|
|
209
|
+
"bg-white",
|
|
210
|
+
"border",
|
|
211
|
+
"border-gray-300",
|
|
212
|
+
"rounded-md",
|
|
213
|
+
"shadow-lg",
|
|
214
|
+
"max-h-60",
|
|
215
|
+
"overflow-auto",
|
|
216
|
+
"py-1",
|
|
217
|
+
"hidden",
|
|
218
|
+
@dropdown_classes
|
|
219
|
+
].flatten.compact)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Returns CSS classes for an option element.
|
|
223
|
+
#
|
|
224
|
+
# @param selected [Boolean] whether this option is currently selected
|
|
225
|
+
# @return [String] merged CSS classes
|
|
226
|
+
def option_element_classes(selected)
|
|
227
|
+
css_classes([
|
|
228
|
+
"px-3",
|
|
229
|
+
"py-2",
|
|
230
|
+
"cursor-pointer",
|
|
231
|
+
"truncate",
|
|
232
|
+
option_size_classes,
|
|
233
|
+
selected ? "bg-primary-50 text-primary-700 font-medium" : "text-gray-900",
|
|
234
|
+
"hover:bg-gray-100"
|
|
235
|
+
].flatten.compact)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# Returns size-specific classes for option text.
|
|
239
|
+
#
|
|
240
|
+
# @return [String] the text size class
|
|
241
|
+
def option_size_classes
|
|
242
|
+
case @size
|
|
243
|
+
when :xs then "text-xs"
|
|
244
|
+
when :sm then "text-sm"
|
|
245
|
+
when :md then "text-base"
|
|
246
|
+
when :lg then "text-lg"
|
|
247
|
+
when :xl then "text-xl"
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# Returns SVG size classes for the caret icon.
|
|
252
|
+
#
|
|
253
|
+
# @return [String] the size classes
|
|
254
|
+
def caret_icon_size
|
|
255
|
+
case @size
|
|
256
|
+
when :xs, :sm then "w-4 h-4"
|
|
257
|
+
when :md, :lg then "w-5 h-5"
|
|
258
|
+
when :xl then "w-6 h-6"
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# Returns the placeholder text with a default fallback.
|
|
263
|
+
#
|
|
264
|
+
# @return [String] the placeholder text
|
|
265
|
+
def placeholder_text
|
|
266
|
+
@placeholder || "Select..."
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# Overrides disabled_classes to use cursor-not-allowed without focus styles.
|
|
270
|
+
#
|
|
271
|
+
# @return [Array<String>] CSS classes for disabled state
|
|
272
|
+
def disabled_classes
|
|
273
|
+
[
|
|
274
|
+
"border-gray-300",
|
|
275
|
+
"bg-gray-100",
|
|
276
|
+
"text-gray-500",
|
|
277
|
+
"cursor-not-allowed",
|
|
278
|
+
"opacity-60"
|
|
279
|
+
]
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# Overrides readonly_classes for the trigger.
|
|
283
|
+
#
|
|
284
|
+
# @return [Array<String>] CSS classes for readonly state
|
|
285
|
+
def readonly_classes
|
|
286
|
+
[
|
|
287
|
+
"border-gray-300",
|
|
288
|
+
"bg-gray-50",
|
|
289
|
+
"text-gray-700",
|
|
290
|
+
"cursor-default"
|
|
291
|
+
]
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# Returns the Stimulus controller wrapper data attributes.
|
|
295
|
+
#
|
|
296
|
+
# @return [Hash] data attributes hash
|
|
297
|
+
def controller_wrapper_attributes
|
|
298
|
+
attrs = {
|
|
299
|
+
controller: "better-ui--forms--select",
|
|
300
|
+
"better-ui--forms--select-clearable-value": @clearable,
|
|
301
|
+
"better-ui--forms--select-placeholder-value": placeholder_text,
|
|
302
|
+
"better-ui--forms--select-disabled-value": @disabled,
|
|
303
|
+
"better-ui--forms--select-readonly-value": @readonly
|
|
304
|
+
}
|
|
305
|
+
attrs
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# Returns the complete set of HTML attributes for the trigger button.
|
|
309
|
+
#
|
|
310
|
+
# @return [Hash] trigger button attributes
|
|
311
|
+
def trigger_attributes
|
|
312
|
+
attrs = {
|
|
313
|
+
type: "button",
|
|
314
|
+
role: "combobox",
|
|
315
|
+
"aria-expanded": "false",
|
|
316
|
+
"aria-haspopup": "listbox",
|
|
317
|
+
"aria-controls": listbox_id,
|
|
318
|
+
class: trigger_element_classes,
|
|
319
|
+
disabled: @disabled || nil,
|
|
320
|
+
"aria-readonly": @readonly ? "true" : nil,
|
|
321
|
+
"data-better-ui--forms--select-target": "trigger",
|
|
322
|
+
"data-action": "click->better-ui--forms--select#toggle keydown->better-ui--forms--select#handleTriggerKeydown"
|
|
323
|
+
}.compact
|
|
324
|
+
attrs
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
# Returns the HTML attributes for the hidden input.
|
|
328
|
+
#
|
|
329
|
+
# @return [Hash] hidden input attributes
|
|
330
|
+
def hidden_input_attributes
|
|
331
|
+
attrs = {
|
|
332
|
+
type: "hidden",
|
|
333
|
+
name: @name,
|
|
334
|
+
value: @value,
|
|
335
|
+
"data-better-ui--forms--select-target": "hiddenInput"
|
|
336
|
+
}
|
|
337
|
+
attrs[:id] = @options[:id] if @options[:id]
|
|
338
|
+
attrs[:required] = true if @required
|
|
339
|
+
@options.each do |key, val|
|
|
340
|
+
next if key == :id
|
|
341
|
+
attrs[key] = val
|
|
342
|
+
end
|
|
343
|
+
attrs.compact
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
end
|
|
@@ -71,6 +71,13 @@ module BetterUi
|
|
|
71
71
|
# @see BaseComponent
|
|
72
72
|
# @see BetterUi::UiFormBuilder#ui_text_input
|
|
73
73
|
class TextInputComponent < BaseComponent
|
|
74
|
+
# Available input types for text-based inputs.
|
|
75
|
+
# Password has its own component ({PasswordInputComponent}) with a Stimulus controller.
|
|
76
|
+
# Number has its own component ({NumberInputComponent}).
|
|
77
|
+
#
|
|
78
|
+
# @return [Array<Symbol>] the list of valid type options
|
|
79
|
+
TYPES = %i[text email tel date time].freeze
|
|
80
|
+
|
|
74
81
|
# @!method with_prefix_icon
|
|
75
82
|
# Slot for rendering an icon or content before (left of) the input text.
|
|
76
83
|
# The icon is positioned absolutely and input padding is adjusted automatically.
|
|
@@ -95,6 +102,7 @@ module BetterUi
|
|
|
95
102
|
# @see BaseComponent#initialize
|
|
96
103
|
def initialize(
|
|
97
104
|
name:,
|
|
105
|
+
type: :text,
|
|
98
106
|
value: nil,
|
|
99
107
|
label: nil,
|
|
100
108
|
hint: nil,
|
|
@@ -111,6 +119,7 @@ module BetterUi
|
|
|
111
119
|
error_classes: nil,
|
|
112
120
|
**options
|
|
113
121
|
)
|
|
122
|
+
@type = validate_type(type)
|
|
114
123
|
super(
|
|
115
124
|
name: name,
|
|
116
125
|
value: value,
|
|
@@ -133,12 +142,25 @@ module BetterUi
|
|
|
133
142
|
|
|
134
143
|
private
|
|
135
144
|
|
|
145
|
+
# Validates that the provided type is one of the allowed TYPES.
|
|
146
|
+
#
|
|
147
|
+
# @param type [Symbol] the type to validate
|
|
148
|
+
# @return [Symbol] the validated type
|
|
149
|
+
# @raise [ArgumentError] if type is not in TYPES
|
|
150
|
+
# @api private
|
|
151
|
+
def validate_type(type)
|
|
152
|
+
unless TYPES.include?(type)
|
|
153
|
+
raise ArgumentError, "Invalid type: #{type}. Must be one of #{TYPES.join(', ')}"
|
|
154
|
+
end
|
|
155
|
+
type
|
|
156
|
+
end
|
|
157
|
+
|
|
136
158
|
# Returns the HTML input type attribute.
|
|
137
159
|
#
|
|
138
|
-
# @return [String] the input type
|
|
160
|
+
# @return [String] the input type
|
|
139
161
|
# @api private
|
|
140
162
|
def input_type
|
|
141
|
-
|
|
163
|
+
@type.to_s
|
|
142
164
|
end
|
|
143
165
|
|
|
144
166
|
# Checks if a prefix icon has been provided via the slot.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<div class="<%= wrapper_classes %>">
|
|
2
|
+
<div class="flex items-center justify-between">
|
|
3
|
+
<<%= heading_tag %> class="<%= component_classes %>" <%= tag.attributes(html_attributes) %>><%= content %></<%= heading_tag %>>
|
|
4
|
+
<% if actions? %>
|
|
5
|
+
<div class="flex items-center gap-2"><%= actions %></div>
|
|
6
|
+
<% end %>
|
|
7
|
+
</div>
|
|
8
|
+
<% if show_subtitle? %>
|
|
9
|
+
<p class="<%= subtitle_classes %>"><%= subtitle? ? subtitle : @subtitle_text %></p>
|
|
10
|
+
<% end %>
|
|
11
|
+
</div>
|