wilday_ui 0.2.3 → 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -90,3 +90,281 @@
90
90
  display: inline-flex;
91
91
  align-items: center;
92
92
  }
93
+
94
+ /* Loading ButtonSpinner */
95
+ .w-button-spinner {
96
+ width: 1rem;
97
+ height: 1rem;
98
+ border: 2px solid transparent;
99
+ border-top-color: #ffffff;
100
+ border-radius: 50%;
101
+ animation: w-button-spin 0.6s linear infinite;
102
+ }
103
+
104
+ @keyframes w-button-spin {
105
+ from {
106
+ transform: rotate(0deg);
107
+ }
108
+ to {
109
+ transform: rotate(360deg);
110
+ }
111
+ }
112
+
113
+ /* Loading Button State */
114
+ .w-button.w-button-loading {
115
+ pointer-events: none;
116
+ opacity: 0.8;
117
+ }
118
+
119
+ .w-button.w-button-loading .w-button-spinner {
120
+ display: inline-block;
121
+ }
122
+
123
+ .w-button.w-button-loading > *:not(.w-button-spinner) {
124
+ display: none;
125
+ }
126
+
127
+ /* Dropdown Wrapper */
128
+ .w-button-wrapper {
129
+ position: relative !important;
130
+ display: inline-block !important;
131
+ padding: 0;
132
+ margin: 0;
133
+ overflow: visible !important;
134
+ }
135
+
136
+ /* Dropdown Button */
137
+ .w-button-dropdown {
138
+ display: inline-flex;
139
+ align-items: center;
140
+ justify-content: center;
141
+ gap: 0.5rem;
142
+ }
143
+
144
+ /* Dropdown Arrow */
145
+ .w-button-dropdown-arrow {
146
+ display: inline-flex;
147
+ align-items: center;
148
+ margin-left: 0.5rem;
149
+ transition: transform 0.2s ease;
150
+ }
151
+
152
+ .w-button-dropdown-arrow:not(i) {
153
+ width: 0.5em;
154
+ height: 0.5em;
155
+ border: 0.15em solid currentColor;
156
+ border-top: 0;
157
+ border-left: 0;
158
+ transform: translateY(-25%) rotate(45deg);
159
+ }
160
+
161
+ /* Arrow Active State */
162
+ .w-button.active .w-button-dropdown-arrow:not(i) {
163
+ transform: translateY(25%) rotate(225deg);
164
+ }
165
+
166
+ .w-button.active .w-button-dropdown-arrow i {
167
+ transform: rotate(180deg);
168
+ }
169
+
170
+ /* Reset Arrow State */
171
+ .w-button-dropdown-arrow i:not(.active) {
172
+ transform: rotate(0);
173
+ }
174
+
175
+ /* Dropdown Menu */
176
+ .w-button-dropdown-menu {
177
+ position: absolute;
178
+ display: none;
179
+ width: max-content;
180
+ min-width: 100%;
181
+ background-color: white;
182
+ border: 1px solid rgba(0, 0, 0, 0.1);
183
+ border-radius: 0.5rem;
184
+ box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1),
185
+ 0 2px 4px -1px rgba(0, 0, 0, 0.06);
186
+ z-index: 1000;
187
+ overflow: visible;
188
+ margin: 0 !important;
189
+ padding: 0 !important;
190
+ }
191
+
192
+ .w-button-dropdown-menu.show {
193
+ display: block;
194
+ animation: fadeIn 0.2s ease-in-out;
195
+ }
196
+
197
+ /* Dropdown Items */
198
+ .w-button-dropdown-item {
199
+ display: block;
200
+ width: 100%;
201
+ padding: 0.5rem 1rem;
202
+ font-size: inherit;
203
+ line-height: inherit;
204
+ text-align: left;
205
+ white-space: nowrap;
206
+ background-color: transparent;
207
+ color: #374151;
208
+ text-decoration: none;
209
+ transition: all 0.15s ease-in-out;
210
+ box-sizing: border-box;
211
+ cursor: pointer;
212
+ }
213
+
214
+ .w-button-dropdown-item:hover,
215
+ .w-button-dropdown-item:focus {
216
+ background-color: #f3f4f6;
217
+ color: #111827;
218
+ outline: none;
219
+ }
220
+
221
+ /* Dropdown Divider */
222
+ .w-button-dropdown-divider {
223
+ height: 0;
224
+ margin: 0.5rem 0;
225
+ overflow: hidden;
226
+ border-top: 1px solid #e9ecef;
227
+ }
228
+
229
+ /* Animation */
230
+ @keyframes fadeIn {
231
+ from {
232
+ opacity: 0;
233
+ transform: translate(var(--translate-x, 0), var(--translate-y, 0));
234
+ }
235
+ to {
236
+ opacity: 1;
237
+ transform: translate(0, 0);
238
+ }
239
+ }
240
+
241
+ /* Position Variations */
242
+ .w-button-dropdown-menu[data-position="top"] {
243
+ bottom: 100%;
244
+ left: 0;
245
+ margin-bottom: 0.125rem;
246
+ --translate-y: -10px;
247
+ }
248
+
249
+ .w-button-dropdown-menu[data-position="bottom"] {
250
+ top: 100%;
251
+ left: 0;
252
+ margin-top: 0.125rem;
253
+ --translate-y: 10px;
254
+ }
255
+
256
+ .w-button-dropdown-menu[data-position="right"] {
257
+ left: 100%;
258
+ margin-left: 0.125rem;
259
+ top: 0;
260
+ --translate-x: 10px;
261
+ }
262
+
263
+ .w-button-dropdown-menu[data-position="left"] {
264
+ right: 100%;
265
+ margin-right: 0.125rem;
266
+ left: auto;
267
+ top: 0;
268
+ --translate-x: -10px;
269
+ }
270
+
271
+ .w-button-dropdown-menu[data-position="top"][data-align="start"],
272
+ .w-button-dropdown-menu[data-position="bottom"][data-align="start"] {
273
+ left: 0 !important;
274
+ right: auto !important;
275
+ }
276
+
277
+ .w-button-dropdown-menu[data-position="top"][data-align="end"],
278
+ .w-button-dropdown-menu[data-position="bottom"][data-align="end"] {
279
+ left: auto !important;
280
+ right: 0 !important;
281
+ }
282
+
283
+ .w-button-dropdown-menu[data-position="top"][data-align="center"],
284
+ .w-button-dropdown-menu[data-position="bottom"][data-align="center"] {
285
+ left: 50% !important;
286
+ transform: translateX(-50%) !important;
287
+ }
288
+
289
+ .w-button-dropdown-menu[data-position="top"][data-align="center"].show,
290
+ .w-button-dropdown-menu[data-position="bottom"][data-align="center"].show {
291
+ transform: translateX(-50%) translateY(0);
292
+ }
293
+
294
+ /* Submenu */
295
+ .w-button-dropdown-parent {
296
+ position: relative;
297
+ cursor: pointer;
298
+ }
299
+
300
+ .w-button-dropdown-parent .w-button-dropdown-menu {
301
+ position: absolute;
302
+ top: 0;
303
+ left: 100%;
304
+ z-index: 1100;
305
+ opacity: 0;
306
+ visibility: hidden;
307
+ transform: translateX(-10px);
308
+ transition: opacity 0.2s ease, visibility 0.2s ease, transform 0.2s ease;
309
+ min-width: 10rem;
310
+ background-color: white;
311
+ padding: 0.5rem 0;
312
+ border: 1px solid rgba(0, 0, 0, 0.1);
313
+ border-radius: 0.375rem;
314
+ box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1),
315
+ 0 2px 4px -1px rgba(0, 0, 0, 0.06);
316
+ }
317
+
318
+ .w-button-dropdown-parent .w-button-dropdown-menu.show {
319
+ opacity: 1;
320
+ visibility: visible;
321
+ transform: translateX(0);
322
+ }
323
+
324
+ .w-button-dropdown-parent .w-button-dropdown-menu[data-position="left"] {
325
+ left: auto;
326
+ right: 100%;
327
+ }
328
+
329
+ /* Nested Submenu Styling */
330
+ .w-button-dropdown-menu .w-button-dropdown-parent .w-button-dropdown-menu {
331
+ top: 0;
332
+ left: 100%;
333
+ z-index: 1200;
334
+ opacity: 0;
335
+ visibility: hidden;
336
+ transform: translateX(-10px);
337
+ transition: opacity 0.2s ease, visibility 0.2s ease, transform 0.2s ease;
338
+ }
339
+
340
+ .w-button-dropdown-menu .w-button-dropdown-parent .w-button-dropdown-menu.show {
341
+ opacity: 1;
342
+ visibility: visible;
343
+ transform: translateX(0);
344
+ pointer-events: auto;
345
+ }
346
+
347
+ /* Arrow State for Submenus */
348
+ .w-button-dropdown-parent .w-button-dropdown-arrow:not(i).active {
349
+ transform: translateY(25%) rotate(225deg);
350
+ }
351
+
352
+ .w-button-dropdown-arrow i.active {
353
+ transform: rotate(180deg);
354
+ }
355
+
356
+ /* Mobile responsiveness */
357
+ @media (max-width: 768px) {
358
+ .w-button-dropdown-menu[data-position="right"],
359
+ .w-button-dropdown-menu[data-position="left"] {
360
+ position: absolute;
361
+ left: 0;
362
+ right: 0;
363
+ top: 100%;
364
+ margin-top: 0.5rem;
365
+ margin-left: 0;
366
+ margin-right: 0;
367
+ --translate-x: 0;
368
+ --translate-y: 10px;
369
+ }
370
+ }
@@ -1,6 +1,25 @@
1
1
  module WildayUi
2
2
  module Components
3
3
  module ButtonHelper
4
+ BUTTON_FEATURES = {
5
+ dropdown: {
6
+ wrapper_required: true,
7
+ stimulus_controller: "dropdown",
8
+ default_stimulus_action: "click->dropdown#toggle"
9
+ },
10
+ loading: {
11
+ wrapper_required: false,
12
+ stimulus_controller: "button",
13
+ default_stimulus_action: "click->button#toggleLoading"
14
+ }
15
+ # Add more features here as needed
16
+ # tooltip: {
17
+ # wrapper_required: true,
18
+ # stimulus_controller: "tooltip",
19
+ # default_stimulus_action: "mouseenter->tooltip#show mouseleave->tooltip#hide"
20
+ # }
21
+ }.freeze
22
+
4
23
  def w_button(
5
24
  content,
6
25
  variant: :primary,
@@ -8,29 +27,229 @@ module WildayUi
8
27
  radius: :rounded,
9
28
  icon: nil,
10
29
  icon_position: :left,
30
+ loading: false,
31
+ loading_text: nil,
11
32
  disabled: false,
12
33
  additional_classes: "",
34
+ use_default_controller: true,
35
+ href: nil,
36
+ method: :get,
37
+ target: nil,
38
+ dropdown: false,
39
+ dropdown_items: nil,
40
+ dropdown_icon: nil,
13
41
  **options
14
42
  )
15
43
  content_for(:head) { stylesheet_link_tag "wilday_ui/button", media: "all" }
16
- variant_class = {
44
+
45
+ options[:data] ||= {}
46
+ wrapper_data = {}
47
+ wrapper_options = nil
48
+
49
+ variant_class = get_variant_class(variant)
50
+ size_class = get_size_class(size)
51
+ radius_class = get_radius_class(radius)
52
+
53
+ # Setup features that require Stimulus controllers
54
+ active_features = determine_active_features(loading, dropdown, loading_text, use_default_controller)
55
+
56
+ Rails.logger.debug "Active Features: #{active_features.inspect}"
57
+ Rails.logger.debug "Options before setup: #{options.inspect}"
58
+
59
+ setup_features(active_features, options, use_default_controller, loading_text)
60
+
61
+ Rails.logger.debug "Options after setup: #{options.inspect}"
62
+
63
+ setup_link_options(options, href, target, method)
64
+
65
+ if dropdown
66
+ setup_dropdown_options(
67
+ options,
68
+ additional_classes,
69
+ dropdown,
70
+ dropdown_items,
71
+ wrapper_data
72
+ )
73
+ end
74
+
75
+ # Setup wrapper options if any feature requires it
76
+ wrapper_options = setup_wrapper_options(
77
+ active_features,
78
+ additional_classes,
79
+ wrapper_data
80
+ )
81
+
82
+ render_button(
83
+ content,
84
+ variant_class,
85
+ size_class,
86
+ radius_class,
87
+ icon,
88
+ icon_position,
89
+ loading,
90
+ loading_text,
91
+ additional_classes,
92
+ disabled,
93
+ options,
94
+ href,
95
+ dropdown,
96
+ dropdown_items,
97
+ dropdown_icon,
98
+ wrapper_options
99
+ )
100
+ end
101
+
102
+ private
103
+
104
+ def get_variant_class(variant)
105
+ {
17
106
  primary: "w-button-primary",
18
107
  secondary: "w-button-secondary",
19
108
  outline: "w-button-outline"
20
109
  }[variant] || "w-button-primary"
110
+ end
21
111
 
22
- size_class = {
112
+ def get_size_class(size)
113
+ {
23
114
  small: "w-button-small",
24
115
  medium: "w-button-medium",
25
116
  large: "w-button-large"
26
117
  }[size] || "w-button-medium"
118
+ end
27
119
 
28
- radius_class = {
120
+ def get_radius_class(radius)
121
+ {
29
122
  rounded: "w-button-rounded",
30
123
  pill: "w-button-pill",
31
124
  square: "w-button-square"
32
125
  }[radius] || "w-button-rounded"
126
+ end
127
+
128
+ def determine_active_features(loading, dropdown, loading_text = nil, use_default_controller = true)
129
+ features = {}
130
+ features[:loading] = true if (loading || loading_text.present?) && use_default_controller
131
+ features[:dropdown] = true if dropdown && use_default_controller
132
+ features
133
+ end
134
+
135
+ def setup_features(active_features, options, use_default_controller, loading_text)
136
+ return unless use_default_controller && active_features.any?
137
+
138
+ active_features.each do |feature, _value|
139
+ feature_config = BUTTON_FEATURES[feature]
140
+ next unless feature_config
141
+
142
+ # Skip adding controller for dropdown feature since it's handled by wrapper
143
+ if feature_config[:wrapper_required]
144
+ # For dropdown, only set the action, not the controller
145
+ options[:data][:action] = feature_config[:default_stimulus_action]
146
+ else
147
+ setup_feature_controller(options, feature_config, loading_text)
148
+ end
149
+ end
150
+ end
151
+
152
+ def setup_feature_controller(options, feature_config, loading_text)
153
+ options[:data] ||= {}
154
+
155
+ existing_controller = options.dig(:data, :controller)
156
+ existing_action = options.dig(:data, :action)
157
+
158
+ options[:data][:controller] = [
159
+ existing_controller,
160
+ feature_config[:stimulus_controller]
161
+ ].compact.join(" ")
162
+
163
+ options[:data][:action] = [
164
+ existing_action,
165
+ feature_config[:default_stimulus_action]
166
+ ].compact.join(" ")
167
+
168
+ # Add feature-specific data attributes
169
+ if feature_config[:stimulus_controller] == "button" && loading_text.present?
170
+ options[:data][:button_loading_text] = loading_text
171
+ end
172
+ end
173
+
174
+ def setup_link_options(options, href, target, method)
175
+ return unless href.present?
176
+
177
+ options[:href] = href
178
+ options[:target] = target if target
179
+ options[:data][:method] = method if method != :get
180
+ options[:rel] = "noopener noreferrer" if target == "_blank"
181
+ end
182
+
183
+ def setup_dropdown_options(options, additional_classes, dropdown, dropdown_items, wrapper_data)
184
+ additional_classes = "#{additional_classes} w-button-dropdown"
185
+
186
+ options[:data][:dropdown_target] = "button"
187
+
188
+ wrapper_data.merge!(
189
+ controller: "dropdown",
190
+ dropdown_id: "dropdown-#{SecureRandom.hex(4)}"
191
+ )
192
+
193
+ if dropdown.is_a?(Hash)
194
+ wrapper_data.merge!(
195
+ dropdown_position_value: dropdown[:position]&.to_s || "bottom",
196
+ dropdown_align_value: dropdown[:align]&.to_s || "start",
197
+ dropdown_trigger_value: dropdown[:trigger]&.to_s || "click"
198
+ )
199
+ else
200
+ wrapper_data.merge!(
201
+ dropdown_position_value: "bottom",
202
+ dropdown_align_value: "start",
203
+ dropdown_trigger_value: "click"
204
+ )
205
+ end
206
+
207
+ normalize_dropdown_items(dropdown_items)
208
+ end
209
+
210
+ def setup_wrapper_options(active_features, additional_classes, wrapper_data)
211
+ return nil unless needs_wrapper?(active_features)
212
+
213
+ {
214
+ class: [ "w-button-wrapper", additional_classes ].compact.join(" "),
215
+ data: wrapper_data,
216
+ role: active_features[:dropdown] ? "menu" : nil
217
+ }.compact
218
+ end
219
+
220
+ def needs_wrapper?(active_features)
221
+ active_features.any? { |feature, _| BUTTON_FEATURES[feature][:wrapper_required] }
222
+ end
223
+
224
+ def normalize_dropdown_items(items, parent_id = nil)
225
+ return [] unless items
226
+
227
+ items.map.with_index do |item, index|
228
+ item_id = generate_item_id(parent_id, index)
229
+
230
+ normalized_item = {
231
+ id: item_id,
232
+ text: item[:text],
233
+ href: item[:href],
234
+ divider: item[:divider]
235
+ }
236
+
237
+ if item[:children].present?
238
+ normalized_item[:children] = normalize_dropdown_items(item[:children], item_id)
239
+ end
240
+
241
+ normalized_item.compact
242
+ end
243
+ end
244
+
245
+ def generate_item_id(parent_id, index)
246
+ base = parent_id ? "#{parent_id}-" : "dropdown-item-"
247
+ "#{base}#{index}"
248
+ end
33
249
 
250
+ def render_button(content, variant_class, size_class, radius_class, icon, icon_position,
251
+ loading, loading_text, additional_classes, disabled, options, href,
252
+ dropdown, dropdown_items, dropdown_icon, wrapper_options)
34
253
  render partial: "wilday_ui/button",
35
254
  locals: {
36
255
  content: content,
@@ -39,9 +258,16 @@ module WildayUi
39
258
  radius_class: radius_class,
40
259
  icon: icon,
41
260
  icon_position: icon_position,
261
+ loading: loading,
262
+ loading_text: loading_text,
42
263
  additional_classes: additional_classes,
43
264
  disabled: disabled,
44
- attributes: options.map { |key, value| "#{key}='#{value}'" }.join(" ")
265
+ html_options: options,
266
+ href: href,
267
+ dropdown: dropdown,
268
+ dropdown_items: dropdown_items,
269
+ dropdown_icon: dropdown_icon,
270
+ wrapper_options: wrapper_options
45
271
  }
46
272
  end
47
273
  end
@@ -1,8 +1,10 @@
1
1
  document.addEventListener("DOMContentLoaded", () => {
2
+ console.log("Button component loaded heah");
2
3
  document.querySelectorAll(".w-button").forEach((button) => {
3
4
  button.addEventListener("click", (event) => {
4
5
  if (button.disabled) {
5
6
  event.preventDefault();
7
+ return;
6
8
  }
7
9
  });
8
10
  });
@@ -0,0 +1,56 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+
3
+ export default class ButtonController extends Controller {
4
+ static values = {
5
+ loadingDuration: { type: Number, default: 2000 },
6
+ };
7
+
8
+ initialize() {}
9
+
10
+ connect() {}
11
+
12
+ toggleLoading(event) {
13
+ const button = this.element;
14
+ const isLink = button.tagName.toLowerCase() === "a";
15
+ const hasLoadingText = button.dataset.buttonLoadingText;
16
+
17
+ // Always prevent default if we have loading text
18
+ if (hasLoadingText) {
19
+ event.preventDefault();
20
+
21
+ if (button.disabled || button.classList.contains("w-button-loading")) {
22
+ return;
23
+ }
24
+
25
+ this.startLoading(button);
26
+
27
+ setTimeout(() => {
28
+ this.stopLoading(button);
29
+
30
+ // Only navigate if it's a link with href
31
+ if (isLink && button.href) {
32
+ window.location.href = button.href;
33
+ }
34
+ }, this.loadingDurationValue);
35
+ }
36
+ }
37
+
38
+ startLoading(button) {
39
+ this.originalContent = button.innerHTML;
40
+ const loadingText = button.dataset.buttonLoadingText;
41
+
42
+ button.classList.add("w-button-loading");
43
+ button.setAttribute("aria-busy", "true");
44
+ button.style.pointerEvents = "none";
45
+ button.disabled = true;
46
+ button.innerHTML = `<span class="w-button-spinner"></span> ${loadingText}`;
47
+ }
48
+
49
+ stopLoading(button) {
50
+ button.classList.remove("w-button-loading");
51
+ button.removeAttribute("aria-busy");
52
+ button.style.pointerEvents = "";
53
+ button.disabled = false;
54
+ button.innerHTML = this.originalContent;
55
+ }
56
+ }