diff --git a/docs/app/views/docs/hover_card.rb b/docs/app/views/docs/hover_card.rb index 75c1cb7d..9eb43e20 100644 --- a/docs/app/views/docs/hover_card.rb +++ b/docs/app/views/docs/hover_card.rb @@ -52,6 +52,21 @@ def view_template RUBY end + render Docs::VisualCodeExample.new(title: "Escaping a clipping ancestor", description: "The card is positioned absolutely by default, so a positioned ancestor that hides its overflow — a scroll area, a table cell, a truncating container — clips it. The fixed strategy positions the card against the viewport instead, so it escapes.", context: self) do + <<~RUBY + div(class: "relative h-24 w-64 overflow-hidden rounded-md border p-4") do + HoverCard(option: {strategy: "fixed"}) do + HoverCardTrigger do + Button(variant: :link) { "@joeldrapper" } + end + HoverCardContent do + p(class: "text-sm") { "Not clipped by the container above." } + end + end + end + RUBY + end + render Components::ComponentSetup::Tabs.new(component_name: component) render Docs::ComponentsTable.new(component_files(component)) diff --git a/gem/lib/ruby_ui/dropdown_menu/dropdown_menu.rb b/gem/lib/ruby_ui/dropdown_menu/dropdown_menu.rb index 56d92ed6..2f7554fc 100644 --- a/gem/lib/ruby_ui/dropdown_menu/dropdown_menu.rb +++ b/gem/lib/ruby_ui/dropdown_menu/dropdown_menu.rb @@ -17,7 +17,7 @@ def default_attrs { class: [ "group/dropdown-menu", - (strategy == "absolute") ? "is-absolute" : "is-fixed" + (strategy == "fixed") ? "is-fixed" : "is-absolute" ], data: { controller: "ruby-ui--dropdown-menu", diff --git a/gem/lib/ruby_ui/hover_card/hover_card.rb b/gem/lib/ruby_ui/hover_card/hover_card.rb index f7c9caab..86f55ca9 100644 --- a/gem/lib/ruby_ui/hover_card/hover_card.rb +++ b/gem/lib/ruby_ui/hover_card/hover_card.rb @@ -17,11 +17,21 @@ def view_template(&) def default_attrs { + class: [ + "group/hover-card", + (strategy == "fixed") ? "is-fixed" : "is-absolute" + ], data: { controller: "ruby-ui--hover-card", ruby_ui__hover_card_options_value: @options.to_json } } end + + # An ancestor with `overflow: hidden` clips an absolutely positioned card; + # `strategy: "fixed"` positions it against the viewport instead. + def strategy + @_strategy ||= @options[:strategy] || "absolute" + end end end diff --git a/gem/lib/ruby_ui/hover_card/hover_card_content.rb b/gem/lib/ruby_ui/hover_card/hover_card_content.rb index 11576072..0e7b4da4 100644 --- a/gem/lib/ruby_ui/hover_card/hover_card_content.rb +++ b/gem/lib/ruby_ui/hover_card/hover_card_content.rb @@ -14,7 +14,7 @@ def default_attrs ruby_ui__hover_card_target: "content", state: :closed }, - class: "hidden absolute z-50 rounded-md border bg-background p-4 text-foreground shadow-md outline-none 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-[state=closed]:fill-mode-forwards 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" + class: "hidden group-[.is-absolute]/hover-card:absolute group-[.is-fixed]/hover-card:fixed z-50 rounded-md border bg-background p-4 text-foreground shadow-md outline-none 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-[state=closed]:fill-mode-forwards 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" } end end diff --git a/gem/lib/ruby_ui/hover_card/hover_card_controller.js b/gem/lib/ruby_ui/hover_card/hover_card_controller.js index 3aa49a83..32609943 100644 --- a/gem/lib/ruby_ui/hover_card/hover_card_controller.js +++ b/gem/lib/ruby_ui/hover_card/hover_card_controller.js @@ -164,6 +164,9 @@ export default class extends Controller { computePosition(this.triggerTarget, this.contentTarget, { placement: this.optionsValue.placement || "bottom", middleware: [offset(4), flip(), shift({ padding: 8 })], + // `fixed` positions against the viewport, so an ancestor with + // `overflow: hidden` no longer clips the card. + strategy: this.optionsValue.strategy || "absolute", }).then(({ x, y, placement }) => { Object.assign(this.contentTarget.style, { left: `${x}px`, diff --git a/gem/lib/ruby_ui/hover_card/hover_card_docs.rb b/gem/lib/ruby_ui/hover_card/hover_card_docs.rb index 1aa18ed5..10866b52 100644 --- a/gem/lib/ruby_ui/hover_card/hover_card_docs.rb +++ b/gem/lib/ruby_ui/hover_card/hover_card_docs.rb @@ -52,6 +52,21 @@ def view_template RUBY end + render Docs::VisualCodeExample.new(title: "Escaping a clipping ancestor", description: "The card is positioned absolutely by default, so a positioned ancestor that hides its overflow — a scroll area, a table cell, a truncating container — clips it. The fixed strategy positions the card against the viewport instead, so it escapes.", context: self) do + <<~RUBY + div(class: "relative h-24 w-64 overflow-hidden rounded-md border p-4") do + HoverCard(option: {strategy: "fixed"}) do + HoverCardTrigger do + Button(variant: :link) { "@joeldrapper" } + end + HoverCardContent do + p(class: "text-sm") { "Not clipped by the container above." } + end + end + end + RUBY + end + render Components::ComponentSetup::Tabs.new(component_name: component) render Docs::ComponentsTable.new(component_files(component)) diff --git a/gem/test/ruby_ui/dropdown_menu_test.rb b/gem/test/ruby_ui/dropdown_menu_test.rb index 69d8d027..170da48c 100644 --- a/gem/test/ruby_ui/dropdown_menu_test.rb +++ b/gem/test/ruby_ui/dropdown_menu_test.rb @@ -63,6 +63,24 @@ def test_render_with_strategy_fixed assert_match(/is-fixed/, output) end + # Floating UI treats anything but "fixed" as absolute, so the class has to + # fall back the same way — otherwise a typo renders `fixed` while the + # positioning engine computes `absolute`. + def test_render_with_unsupported_strategy_falls_back_to_absolute + output = phlex do + RubyUI.DropdownMenu(options: {strategy: "absolut"}) do + RubyUI.DropdownMenuTrigger(class: "w-full") do + RubyUI.Button(variant: :outline) { "Open" } + end + RubyUI.DropdownMenuContent do + RubyUI.DropdownMenuItem(href: "#") { "Profile" } + end + end + end + + assert_match(%r{class="group/dropdown-menu is-absolute"}, output) + end + # `hidden` lands a frame after the animation ends; without a forwards fill mode that frame flashes. def test_content_holds_the_last_frame_of_the_exit_animation output = phlex do diff --git a/gem/test/ruby_ui/hover_card_test.rb b/gem/test/ruby_ui/hover_card_test.rb index 5f45cfe6..6c3708e6 100644 --- a/gem/test/ruby_ui/hover_card_test.rb +++ b/gem/test/ruby_ui/hover_card_test.rb @@ -24,7 +24,7 @@ def test_render_with_all_items end # Floating UI positions a real element in the DOM (tippy used to clone a - #