wilday_ui 0.3.0 → 0.4.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.
@@ -0,0 +1,270 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+
3
+ export default class extends Controller {
4
+ static targets = ["button", "menu", "submenu"];
5
+ static values = {
6
+ trigger: { type: String, default: "click" },
7
+ position: { type: String, default: "bottom" },
8
+ align: { type: String, default: "start" },
9
+ };
10
+
11
+ connect() {
12
+ const position = this.element.dataset.dropdownPositionValue;
13
+ const align = this.element.dataset.dropdownAlignValue;
14
+
15
+ if (position) this.positionValue = position;
16
+ if (align) this.alignValue = align;
17
+
18
+ // Set up hover events if trigger is hover
19
+ if (this.triggerValue === "hover") {
20
+ this.element.addEventListener("mouseenter", () => {
21
+ console.log("Mouse enter - showing menu");
22
+ this.handleHover(true);
23
+ });
24
+
25
+ this.element.addEventListener("mouseleave", () => {
26
+ console.log("Mouse leave - hiding menu");
27
+ this.handleHover(false);
28
+ });
29
+ }
30
+
31
+ // Set up keyboard navigation
32
+ this.element.addEventListener("keydown", this.handleKeydown.bind(this));
33
+
34
+ // Close on click outside
35
+ document.addEventListener("click", this.handleClickOutside.bind(this));
36
+
37
+ // Set up parent dropdown items to handle submenus
38
+ this.setupSubmenus();
39
+ }
40
+
41
+ disconnect() {
42
+ document.removeEventListener("click", this.handleClickOutside.bind(this));
43
+ }
44
+
45
+ handleHover(show) {
46
+ if (show) {
47
+ this.menuTarget.classList.add("show");
48
+ this.buttonTarget.classList.add("active");
49
+ this.buttonTarget.setAttribute("aria-expanded", "true");
50
+ } else {
51
+ this.menuTarget.classList.remove("show");
52
+ this.buttonTarget.classList.remove("active");
53
+ this.buttonTarget.setAttribute("aria-expanded", "false");
54
+ }
55
+ }
56
+
57
+ toggle(event) {
58
+ event.preventDefault();
59
+ event.stopPropagation();
60
+
61
+ if (this.isOpen) {
62
+ this.menuTarget.classList.remove("show");
63
+ } else {
64
+ this.menuTarget.classList.add("show");
65
+ }
66
+ }
67
+
68
+ show() {
69
+ const menuElement = this.element.querySelector(".w-button-dropdown-menu");
70
+ const buttonElement = this.element.querySelector(
71
+ "[data-dropdown-target='button']"
72
+ );
73
+
74
+ this.updatePosition();
75
+ menuElement.classList.add("show");
76
+ buttonElement.classList.add("active");
77
+ buttonElement.setAttribute("aria-expanded", "true");
78
+
79
+ // Apply position and alignment
80
+ menuElement.dataset.position = this.positionValue || "bottom";
81
+ menuElement.dataset.align = this.alignValue || "start";
82
+
83
+ // Focus first menu item
84
+ const firstItem = menuElement.querySelector(".w-button-dropdown-item");
85
+ if (firstItem) firstItem.focus();
86
+ }
87
+
88
+ hide() {
89
+ const menuElement = this.element.querySelector(".w-button-dropdown-menu");
90
+ const buttonElement = this.element.querySelector(
91
+ "[data-dropdown-target='button']"
92
+ );
93
+
94
+ menuElement.classList.remove("show");
95
+ buttonElement.classList.remove("active");
96
+ buttonElement.setAttribute("aria-expanded", "false");
97
+ }
98
+
99
+ handleClickOutside(event) {
100
+ if (!this.element.contains(event.target)) {
101
+ console.log("Click outside detected. Closing all dropdowns.");
102
+ this.closeAllSubmenus();
103
+ this.hide(); // Close the main dropdown
104
+ }
105
+ }
106
+
107
+ handleKeydown(event) {
108
+ if (!this.isOpen && event.key === "Enter") {
109
+ this.show();
110
+ return;
111
+ }
112
+
113
+ if (this.isOpen) {
114
+ switch (event.key) {
115
+ case "Escape":
116
+ this.hide();
117
+ this.buttonTarget.focus();
118
+ break;
119
+ case "ArrowDown":
120
+ event.preventDefault();
121
+ this.focusNextItem();
122
+ break;
123
+ case "ArrowUp":
124
+ event.preventDefault();
125
+ this.focusPreviousItem();
126
+ break;
127
+ case "ArrowRight":
128
+ this.openSubmenu(event.target);
129
+ break;
130
+ case "ArrowLeft":
131
+ this.closeSubmenu(event.target);
132
+ break;
133
+ case "Tab":
134
+ this.hide();
135
+ break;
136
+ }
137
+ }
138
+ }
139
+
140
+ setupSubmenus() {
141
+ this.element
142
+ .querySelectorAll(".w-button-dropdown-parent")
143
+ .forEach((parent) => {
144
+ const submenu = parent.querySelector(".w-button-dropdown-menu");
145
+ const arrow = parent.querySelector(".w-button-dropdown-arrow");
146
+
147
+ if (!submenu) {
148
+ return;
149
+ }
150
+
151
+ // Determine the trigger (hover or click)
152
+ const trigger =
153
+ parent.closest(".w-button-wrapper")?.dataset.dropdownTriggerValue ||
154
+ "click";
155
+
156
+ if (trigger === "hover") {
157
+ parent.addEventListener("mouseenter", () => {
158
+ this.showSubmenu(submenu, arrow);
159
+ });
160
+ parent.addEventListener("mouseleave", () => {
161
+ this.hideSubmenu(submenu, arrow);
162
+ });
163
+ } else if (trigger === "click") {
164
+ parent.addEventListener("click", (event) => {
165
+ event.stopPropagation(); // Prevent closing parent dropdown
166
+ this.toggleSubmenu(submenu, arrow);
167
+ });
168
+
169
+ // Close the submenu when clicking outside
170
+ document.addEventListener("click", (event) => {
171
+ if (!parent.contains(event.target)) {
172
+ this.hideSubmenu(submenu, arrow);
173
+ }
174
+ });
175
+ }
176
+ });
177
+ }
178
+
179
+ toggleSubmenu(submenu, arrow) {
180
+ if (submenu.classList.contains("show")) {
181
+ this.hideSubmenu(submenu, arrow);
182
+ } else {
183
+ this.showSubmenu(submenu, arrow);
184
+ }
185
+ }
186
+
187
+ showSubmenu(submenu, arrow) {
188
+ submenu.classList.add("show");
189
+ submenu.setAttribute("aria-expanded", "true");
190
+
191
+ // Rotate arrow if present
192
+ if (arrow) {
193
+ arrow.classList.add("active");
194
+ }
195
+ }
196
+
197
+ hideSubmenu(submenu, arrow) {
198
+ submenu.classList.remove("show");
199
+ submenu.setAttribute("aria-expanded", "false");
200
+
201
+ // Reset arrow rotation if present
202
+ if (arrow) {
203
+ arrow.classList.remove("active");
204
+ }
205
+ }
206
+
207
+ isParentOpen(parent) {
208
+ const parentMenu = parent.closest(".w-button-dropdown-menu");
209
+ return parentMenu && parentMenu.classList.contains("show");
210
+ }
211
+
212
+ closeAllSubmenus() {
213
+ this.element
214
+ .querySelectorAll(".w-button-dropdown-menu.show")
215
+ .forEach((menu) => {
216
+ menu.classList.remove("show");
217
+ menu.setAttribute("aria-expanded", "false");
218
+ });
219
+ }
220
+
221
+ openSubmenu(parentItem) {
222
+ const submenu = parentItem.querySelector(".w-button-dropdown-menu");
223
+ if (submenu) {
224
+ this.showSubmenu(submenu);
225
+ submenu.querySelector(".w-button-dropdown-item").focus();
226
+ }
227
+ }
228
+
229
+ closeSubmenu(parentItem) {
230
+ const submenu = parentItem.closest(".w-button-dropdown-menu");
231
+ if (submenu) {
232
+ this.hideSubmenu(submenu);
233
+ parentItem.closest(".w-button-dropdown-parent").focus();
234
+ }
235
+ }
236
+
237
+ focusNextItem() {
238
+ const items = this.getMenuItems();
239
+ const currentIndex = items.indexOf(document.activeElement);
240
+ const nextIndex = currentIndex + 1 < items.length ? currentIndex + 1 : 0;
241
+ items[nextIndex].focus();
242
+ }
243
+
244
+ focusPreviousItem() {
245
+ const items = this.getMenuItems();
246
+ const currentIndex = items.indexOf(document.activeElement);
247
+ const previousIndex =
248
+ currentIndex > 0 ? currentIndex - 1 : items.length - 1;
249
+ items[previousIndex].focus();
250
+ }
251
+
252
+ getMenuItems() {
253
+ return Array.from(
254
+ this.menuTarget.querySelectorAll(".w-button-dropdown-item")
255
+ );
256
+ }
257
+
258
+ updatePosition() {
259
+ const menuElement = this.element.querySelector(".w-button-dropdown-menu");
260
+ const position = this.hasPositionValue ? this.positionValue : "bottom";
261
+ const align = this.hasAlignValue ? this.alignValue : "start";
262
+
263
+ menuElement.setAttribute("data-position", position);
264
+ menuElement.setAttribute("data-align", align);
265
+ }
266
+
267
+ get isOpen() {
268
+ return this.menuTarget.classList.contains("show");
269
+ }
270
+ }
@@ -1,5 +1,6 @@
1
1
  import { Application } from "@hotwired/stimulus";
2
2
  import ButtonController from "./button_controller";
3
+ import DropdownController from "./dropdown_controller";
3
4
 
4
5
  // Initialize Stimulus
5
6
  const application = Application.start();
@@ -7,10 +8,10 @@ window.Stimulus = application;
7
8
 
8
9
  // Register the button controller
9
10
  application.register("button", ButtonController);
10
-
11
+ application.register("dropdown", DropdownController);
11
12
  // Debug check to ensure Stimulus is loaded
12
- if (window.Stimulus) {
13
- console.log("✅ Stimulus is loaded and initialized.");
14
- } else {
15
- console.error("❌ Stimulus failed to load.");
16
- }
13
+ // if (window.Stimulus) {
14
+ // console.log("✅ Stimulus is loaded and initialized.");
15
+ // } else {
16
+ // console.error("❌ Stimulus failed to load.");
17
+ // }
@@ -1,4 +1,4 @@
1
1
  import "./controllers";
2
2
  import "./components/button";
3
3
 
4
- console.log("JavaScript loaded");
4
+ // console.log("JavaScript loaded");
@@ -4,7 +4,9 @@ html_options = {
4
4
  class: ["w-button", variant_class, size_class, radius_class, additional_classes, ("w-button-loading" if loading)].compact.join(" "),
5
5
  disabled: disabled || loading,
6
6
  "aria-busy": loading,
7
- "aria-disabled": disabled || loading
7
+ "aria-disabled": disabled || loading,
8
+ "aria-expanded": dropdown ? "false" : nil,
9
+ "aria-haspopup": dropdown ? "true" : nil
8
10
  }
9
11
 
10
12
  # Only add Stimulus data attributes if they're in the passed options
@@ -16,21 +18,21 @@ end
16
18
  html_options.merge!(local_assigns[:html_options] || {})
17
19
 
18
20
  tag_type = href.present? ? :a : :button
19
- %>
20
21
 
21
- <%= content_tag tag_type, html_options do %>
22
- <% if loading %>
23
- <span class="w-button-spinner"></span>
24
- <%= loading_text %>
25
- <% else %>
26
- <% if icon_position == :left && icon.present? %>
27
- <i class="w-button-icon-left <%= icon %>"></i>
22
+ # Conditionally render wrapper for dropdowns or advanced features
23
+ if local_assigns[:wrapper_options].present?
24
+ %>
25
+ <div <%= tag.attributes(wrapper_options) %>>
26
+ <%= content_tag tag_type, html_options do %>
27
+ <%= render partial: "wilday_ui/button/content", locals: local_assigns %>
28
28
  <% end %>
29
-
30
- <%= content %>
31
-
32
- <% if icon_position == :right && icon.present? %>
33
- <i class="w-button-icon-right <%= icon %>"></i>
29
+
30
+ <% if dropdown && dropdown_items %>
31
+ <%= render partial: "wilday_ui/button/dropdown_menu", locals: { items: dropdown_items, dropdown: dropdown } %>
34
32
  <% end %>
33
+ </div>
34
+ <% else %>
35
+ <%= content_tag tag_type, html_options do %>
36
+ <%= render partial: "wilday_ui/button/content", locals: local_assigns %>
35
37
  <% end %>
36
38
  <% end %>
@@ -0,0 +1,22 @@
1
+ <% if loading %>
2
+ <span class="w-button-spinner"></span>
3
+ <%= loading_text %>
4
+ <% else %>
5
+ <% if icon_position == :left && icon.present? %>
6
+ <i class="w-button-icon-left <%= icon %>"></i>
7
+ <% end %>
8
+
9
+ <%= content %>
10
+
11
+ <% if dropdown %>
12
+ <% if dropdown_icon.present? %>
13
+ <i class="w-button-dropdown-arrow <%= dropdown_icon %>"></i>
14
+ <% else %>
15
+ <span class="w-button-dropdown-arrow"></span>
16
+ <% end %>
17
+ <% end %>
18
+
19
+ <% if icon_position == :right && icon.present? %>
20
+ <i class="w-button-icon-right <%= icon %>"></i>
21
+ <% end %>
22
+ <% end %>
@@ -0,0 +1,26 @@
1
+ <% if items.present? %>
2
+ <div class="w-button-dropdown-menu" data-dropdown-target="menu" role="menu">
3
+ <% items.each do |item| %>
4
+ <% if item[:divider] %>
5
+ <div class="w-button-dropdown-divider" role="separator"></div>
6
+ <% elsif item[:children].present? %>
7
+ <div class="w-button-dropdown-item w-button-dropdown-parent" role="menuitem">
8
+ <%= item[:text] %>
9
+ <span class="w-button-dropdown-arrow"></span>
10
+ <%= render partial: "wilday_ui/button/dropdown_menu",
11
+ locals: {
12
+ items: item[:children],
13
+ parent_id: item[:id]
14
+ } %>
15
+ </div>
16
+ <% else %>
17
+ <%= link_to item[:text],
18
+ item[:href],
19
+ class: "w-button-dropdown-item",
20
+ role: "menuitem",
21
+ tabindex: "-1"
22
+ %>
23
+ <% end %>
24
+ <% end %>
25
+ </div>
26
+ <% end %>
@@ -1,3 +1,3 @@
1
1
  module WildayUi
2
- VERSION = "0.3.0"
2
+ VERSION = "0.4.0"
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: wilday_ui
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - davidwinalda
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2024-12-12 00:00:00.000000000 Z
11
+ date: 2024-12-15 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -49,6 +49,7 @@ files:
49
49
  - app/helpers/wilday_ui/components/button_helper.rb
50
50
  - app/javascript/wilday_ui/components/button.js
51
51
  - app/javascript/wilday_ui/controllers/button_controller.js
52
+ - app/javascript/wilday_ui/controllers/dropdown_controller.js
52
53
  - app/javascript/wilday_ui/controllers/index.js
53
54
  - app/javascript/wilday_ui/index.js
54
55
  - app/jobs/wilday_ui/application_job.rb
@@ -56,6 +57,8 @@ files:
56
57
  - app/models/wilday_ui/application_record.rb
57
58
  - app/views/layouts/wilday_ui/application.html.erb
58
59
  - app/views/wilday_ui/_button.html.erb
60
+ - app/views/wilday_ui/button/_content.html.erb
61
+ - app/views/wilday_ui/button/_dropdown_menu.html.erb
59
62
  - config/routes.rb
60
63
  - lib/tasks/wilday_ui_tasks.rake
61
64
  - lib/wilday_ui.rb