diff --git a/lib/ruby_ui/dropdown_menu/dropdown_menu.rb b/lib/ruby_ui/dropdown_menu/dropdown_menu.rb index 0a7564ad..56d92ed6 100644 --- a/lib/ruby_ui/dropdown_menu/dropdown_menu.rb +++ b/lib/ruby_ui/dropdown_menu/dropdown_menu.rb @@ -15,6 +15,10 @@ def view_template(&) def default_attrs { + class: [ + "group/dropdown-menu", + (strategy == "absolute") ? "is-absolute" : "is-fixed" + ], data: { controller: "ruby-ui--dropdown-menu", action: "click@window->ruby-ui--dropdown-menu#onClickOutside", @@ -22,5 +26,9 @@ def default_attrs } } end + + def strategy + @_strategy ||= @options[:strategy] || "absolute" + end end end diff --git a/lib/ruby_ui/dropdown_menu/dropdown_menu_content.rb b/lib/ruby_ui/dropdown_menu/dropdown_menu_content.rb index 44b8e16d..bb2e6d08 100644 --- a/lib/ruby_ui/dropdown_menu/dropdown_menu_content.rb +++ b/lib/ruby_ui/dropdown_menu/dropdown_menu_content.rb @@ -3,7 +3,7 @@ module RubyUI class DropdownMenuContent < Base def view_template(&block) - div(data: {ruby_ui__dropdown_menu_target: "content"}, class: "hidden", style: "width: max-content; position: absolute; top: 0; left: 0;") do + div(**wrapper_attrs) do div(**attrs, &block) end end @@ -18,5 +18,20 @@ def default_attrs 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" } end + + def wrapper_attrs + { + class: [ + "z-50 hidden group-[.is-absolute]/dropdown-menu:absolute", + "group-[.is-fixed]/dropdown-menu:fixed" + ], + data: {ruby_ui__dropdown_menu_target: "content"}, + style: { + width: "max-content", + top: "0", + left: "0" + } + } + end end end diff --git a/lib/ruby_ui/dropdown_menu/dropdown_menu_controller.js b/lib/ruby_ui/dropdown_menu/dropdown_menu_controller.js index 266c0fed..d624eaf5 100644 --- a/lib/ruby_ui/dropdown_menu/dropdown_menu_controller.js +++ b/lib/ruby_ui/dropdown_menu/dropdown_menu_controller.js @@ -1,5 +1,11 @@ import { Controller } from "@hotwired/stimulus"; -import { computePosition, flip, shift, offset } from "@floating-ui/dom"; +import { + computePosition, + flip, + shift, + offset, + autoUpdate, +} from "@floating-ui/dom"; export default class extends Controller { static targets = ["trigger", "content", "menuItem"]; @@ -12,17 +18,34 @@ export default class extends Controller { type: Object, default: {}, }, - } + }; connect() { this.boundHandleKeydown = this.#handleKeydown.bind(this); // Bind the function so we can remove it later this.selectedIndex = -1; + + this.#setupAutoUpdate(); + } + + disconnect() { + if (this.autoUpdateCleanup) { + this.autoUpdateCleanup(); + } + } + + #setupAutoUpdate() { + this.autoUpdateCleanup = autoUpdate( + this.triggerTarget, + this.contentTarget, + this.#computeTooltip.bind(this), + ); } #computeTooltip() { computePosition(this.triggerTarget, this.contentTarget, { placement: this.optionsValue.placement || "top", middleware: [flip(), shift(), offset(8)], + strategy: this.optionsValue.strategy || "absolute", }).then(({ x, y }) => { Object.assign(this.contentTarget.style, { left: `${x}px`, @@ -40,14 +63,16 @@ export default class extends Controller { } toggle() { - this.contentTarget.classList.contains("hidden") ? this.#open() : this.close(); + this.contentTarget.classList.contains("hidden") + ? this.#open() + : this.close(); } #open() { this.openValue = true; this.#deselectAll(); this.#addEventListeners(); - this.#computeTooltip() + this.#computeTooltip(); this.contentTarget.classList.remove("hidden"); } @@ -59,15 +84,17 @@ export default class extends Controller { #handleKeydown(e) { // return if no menu items (one line fix for) - if (this.menuItemTargets.length === 0) { return; } + if (this.menuItemTargets.length === 0) { + return; + } - if (e.key === 'ArrowDown') { + if (e.key === "ArrowDown") { e.preventDefault(); this.#updateSelectedItem(1); - } else if (e.key === 'ArrowUp') { + } else if (e.key === "ArrowUp") { e.preventDefault(); this.#updateSelectedItem(-1); - } else if (e.key === 'Enter' && this.selectedIndex !== -1) { + } else if (e.key === "Enter" && this.selectedIndex !== -1) { e.preventDefault(); this.menuItemTargets[this.selectedIndex].click(); } @@ -76,7 +103,7 @@ export default class extends Controller { #updateSelectedItem(direction) { // Check if any of the menuItemTargets have aria-selected="true" and set the selectedIndex to that index this.menuItemTargets.forEach((item, index) => { - if (item.getAttribute('aria-selected') === 'true') { + if (item.getAttribute("aria-selected") === "true") { this.selectedIndex = index; } }); @@ -99,22 +126,24 @@ export default class extends Controller { #toggleAriaSelected(element, isSelected) { // Add or remove attribute if (isSelected) { - element.setAttribute('aria-selected', 'true'); + element.setAttribute("aria-selected", "true"); } else { - element.removeAttribute('aria-selected'); + element.removeAttribute("aria-selected"); } } #deselectAll() { - this.menuItemTargets.forEach(item => this.#toggleAriaSelected(item, false)); + this.menuItemTargets.forEach((item) => + this.#toggleAriaSelected(item, false), + ); this.selectedIndex = -1; } #addEventListeners() { - document.addEventListener('keydown', this.boundHandleKeydown); + document.addEventListener("keydown", this.boundHandleKeydown); } #removeEventListeners() { - document.removeEventListener('keydown', this.boundHandleKeydown); + document.removeEventListener("keydown", this.boundHandleKeydown); } } diff --git a/test/ruby_ui/dropdown_menu_test.rb b/test/ruby_ui/dropdown_menu_test.rb index fce350c1..261d44c6 100644 --- a/test/ruby_ui/dropdown_menu_test.rb +++ b/test/ruby_ui/dropdown_menu_test.rb @@ -22,4 +22,44 @@ def test_render_with_all_items assert_match(/Open/, output) end + + def test_render_with_strategy_absolute + output = phlex do + RubyUI.DropdownMenu(options: {strategy: "absolute"}) do + RubyUI.DropdownMenuTrigger(class: "w-full") do + RubyUI.Button(variant: :outline) { "Open" } + end + RubyUI.DropdownMenuContent do + RubyUI.DropdownMenuLabel { "My Account" } + RubyUI.DropdownMenuSeparator + RubyUI.DropdownMenuItem(href: "#") { "Profile" } + RubyUI.DropdownMenuItem(href: "#") { "Billing" } + RubyUI.DropdownMenuItem(href: "#") { "Team" } + RubyUI.DropdownMenuItem(href: "#") { "Subscription" } + end + end + end + + assert_match(/is-absolute/, output) + end + + def test_render_with_strategy_fixed + output = phlex do + RubyUI.DropdownMenu(options: {strategy: "fixed"}) do + RubyUI.DropdownMenuTrigger(class: "w-full") do + RubyUI.Button(variant: :outline) { "Open" } + end + RubyUI.DropdownMenuContent do + RubyUI.DropdownMenuLabel { "My Account" } + RubyUI.DropdownMenuSeparator + RubyUI.DropdownMenuItem(href: "#") { "Profile" } + RubyUI.DropdownMenuItem(href: "#") { "Billing" } + RubyUI.DropdownMenuItem(href: "#") { "Team" } + RubyUI.DropdownMenuItem(href: "#") { "Subscription" } + end + end + end + + assert_match(/is-fixed/, output) + end end