Skip to content

Commit 81eedb3

Browse files
lsouoliveiraLucas Sousa
andauthored
Allow to pass the strategy option to dropdown menu (#243)
* Allow to pass strategy option to dropdown menu * Update the dropdown controller to accept the strategy * Add auto update to keep dropdown positioned correctly * Update the dropdown controller to accept the strategy * Move autoUpdate setup to a method --------- Co-authored-by: Lucas Sousa <[email protected]>
1 parent d0490e6 commit 81eedb3

File tree

4 files changed

+107
-15
lines changed

4 files changed

+107
-15
lines changed

lib/ruby_ui/dropdown_menu/dropdown_menu.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,20 @@ def view_template(&)
1515

1616
def default_attrs
1717
{
18+
class: [
19+
"group/dropdown-menu",
20+
(strategy == "absolute") ? "is-absolute" : "is-fixed"
21+
],
1822
data: {
1923
controller: "ruby-ui--dropdown-menu",
2024
action: "click@window->ruby-ui--dropdown-menu#onClickOutside",
2125
ruby_ui__dropdown_menu_options_value: @options.to_json
2226
}
2327
}
2428
end
29+
30+
def strategy
31+
@_strategy ||= @options[:strategy] || "absolute"
32+
end
2533
end
2634
end

lib/ruby_ui/dropdown_menu/dropdown_menu_content.rb

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
module RubyUI
44
class DropdownMenuContent < Base
55
def view_template(&block)
6-
div(data: {ruby_ui__dropdown_menu_target: "content"}, class: "hidden", style: "width: max-content; position: absolute; top: 0; left: 0;") do
6+
div(**wrapper_attrs) do
77
div(**attrs, &block)
88
end
99
end
@@ -18,5 +18,20 @@ def default_attrs
1818
class: "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-background p-1 text-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 w-56"
1919
}
2020
end
21+
22+
def wrapper_attrs
23+
{
24+
class: [
25+
"z-50 hidden group-[.is-absolute]/dropdown-menu:absolute",
26+
"group-[.is-fixed]/dropdown-menu:fixed"
27+
],
28+
data: {ruby_ui__dropdown_menu_target: "content"},
29+
style: {
30+
width: "max-content",
31+
top: "0",
32+
left: "0"
33+
}
34+
}
35+
end
2136
end
2237
end

lib/ruby_ui/dropdown_menu/dropdown_menu_controller.js

Lines changed: 43 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
import { Controller } from "@hotwired/stimulus";
2-
import { computePosition, flip, shift, offset } from "@floating-ui/dom";
2+
import {
3+
computePosition,
4+
flip,
5+
shift,
6+
offset,
7+
autoUpdate,
8+
} from "@floating-ui/dom";
39

410
export default class extends Controller {
511
static targets = ["trigger", "content", "menuItem"];
@@ -12,17 +18,34 @@ export default class extends Controller {
1218
type: Object,
1319
default: {},
1420
},
15-
}
21+
};
1622

1723
connect() {
1824
this.boundHandleKeydown = this.#handleKeydown.bind(this); // Bind the function so we can remove it later
1925
this.selectedIndex = -1;
26+
27+
this.#setupAutoUpdate();
28+
}
29+
30+
disconnect() {
31+
if (this.autoUpdateCleanup) {
32+
this.autoUpdateCleanup();
33+
}
34+
}
35+
36+
#setupAutoUpdate() {
37+
this.autoUpdateCleanup = autoUpdate(
38+
this.triggerTarget,
39+
this.contentTarget,
40+
this.#computeTooltip.bind(this),
41+
);
2042
}
2143

2244
#computeTooltip() {
2345
computePosition(this.triggerTarget, this.contentTarget, {
2446
placement: this.optionsValue.placement || "top",
2547
middleware: [flip(), shift(), offset(8)],
48+
strategy: this.optionsValue.strategy || "absolute",
2649
}).then(({ x, y }) => {
2750
Object.assign(this.contentTarget.style, {
2851
left: `${x}px`,
@@ -40,14 +63,16 @@ export default class extends Controller {
4063
}
4164

4265
toggle() {
43-
this.contentTarget.classList.contains("hidden") ? this.#open() : this.close();
66+
this.contentTarget.classList.contains("hidden")
67+
? this.#open()
68+
: this.close();
4469
}
4570

4671
#open() {
4772
this.openValue = true;
4873
this.#deselectAll();
4974
this.#addEventListeners();
50-
this.#computeTooltip()
75+
this.#computeTooltip();
5176
this.contentTarget.classList.remove("hidden");
5277
}
5378

@@ -59,15 +84,17 @@ export default class extends Controller {
5984

6085
#handleKeydown(e) {
6186
// return if no menu items (one line fix for)
62-
if (this.menuItemTargets.length === 0) { return; }
87+
if (this.menuItemTargets.length === 0) {
88+
return;
89+
}
6390

64-
if (e.key === 'ArrowDown') {
91+
if (e.key === "ArrowDown") {
6592
e.preventDefault();
6693
this.#updateSelectedItem(1);
67-
} else if (e.key === 'ArrowUp') {
94+
} else if (e.key === "ArrowUp") {
6895
e.preventDefault();
6996
this.#updateSelectedItem(-1);
70-
} else if (e.key === 'Enter' && this.selectedIndex !== -1) {
97+
} else if (e.key === "Enter" && this.selectedIndex !== -1) {
7198
e.preventDefault();
7299
this.menuItemTargets[this.selectedIndex].click();
73100
}
@@ -76,7 +103,7 @@ export default class extends Controller {
76103
#updateSelectedItem(direction) {
77104
// Check if any of the menuItemTargets have aria-selected="true" and set the selectedIndex to that index
78105
this.menuItemTargets.forEach((item, index) => {
79-
if (item.getAttribute('aria-selected') === 'true') {
106+
if (item.getAttribute("aria-selected") === "true") {
80107
this.selectedIndex = index;
81108
}
82109
});
@@ -99,22 +126,24 @@ export default class extends Controller {
99126
#toggleAriaSelected(element, isSelected) {
100127
// Add or remove attribute
101128
if (isSelected) {
102-
element.setAttribute('aria-selected', 'true');
129+
element.setAttribute("aria-selected", "true");
103130
} else {
104-
element.removeAttribute('aria-selected');
131+
element.removeAttribute("aria-selected");
105132
}
106133
}
107134

108135
#deselectAll() {
109-
this.menuItemTargets.forEach(item => this.#toggleAriaSelected(item, false));
136+
this.menuItemTargets.forEach((item) =>
137+
this.#toggleAriaSelected(item, false),
138+
);
110139
this.selectedIndex = -1;
111140
}
112141

113142
#addEventListeners() {
114-
document.addEventListener('keydown', this.boundHandleKeydown);
143+
document.addEventListener("keydown", this.boundHandleKeydown);
115144
}
116145

117146
#removeEventListeners() {
118-
document.removeEventListener('keydown', this.boundHandleKeydown);
147+
document.removeEventListener("keydown", this.boundHandleKeydown);
119148
}
120149
}

test/ruby_ui/dropdown_menu_test.rb

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,44 @@ def test_render_with_all_items
2222

2323
assert_match(/Open/, output)
2424
end
25+
26+
def test_render_with_strategy_absolute
27+
output = phlex do
28+
RubyUI.DropdownMenu(options: {strategy: "absolute"}) do
29+
RubyUI.DropdownMenuTrigger(class: "w-full") do
30+
RubyUI.Button(variant: :outline) { "Open" }
31+
end
32+
RubyUI.DropdownMenuContent do
33+
RubyUI.DropdownMenuLabel { "My Account" }
34+
RubyUI.DropdownMenuSeparator
35+
RubyUI.DropdownMenuItem(href: "#") { "Profile" }
36+
RubyUI.DropdownMenuItem(href: "#") { "Billing" }
37+
RubyUI.DropdownMenuItem(href: "#") { "Team" }
38+
RubyUI.DropdownMenuItem(href: "#") { "Subscription" }
39+
end
40+
end
41+
end
42+
43+
assert_match(/is-absolute/, output)
44+
end
45+
46+
def test_render_with_strategy_fixed
47+
output = phlex do
48+
RubyUI.DropdownMenu(options: {strategy: "fixed"}) do
49+
RubyUI.DropdownMenuTrigger(class: "w-full") do
50+
RubyUI.Button(variant: :outline) { "Open" }
51+
end
52+
RubyUI.DropdownMenuContent do
53+
RubyUI.DropdownMenuLabel { "My Account" }
54+
RubyUI.DropdownMenuSeparator
55+
RubyUI.DropdownMenuItem(href: "#") { "Profile" }
56+
RubyUI.DropdownMenuItem(href: "#") { "Billing" }
57+
RubyUI.DropdownMenuItem(href: "#") { "Team" }
58+
RubyUI.DropdownMenuItem(href: "#") { "Subscription" }
59+
end
60+
end
61+
end
62+
63+
assert_match(/is-fixed/, output)
64+
end
2565
end

0 commit comments

Comments
 (0)