From 0a85b4990d0c40744af153cebbc301c2f7911ac7 Mon Sep 17 00:00:00 2001 From: Leonardo Ventura Date: Wed, 9 Sep 2026 09:43:32 -0300 Subject: [PATCH 1/2] [Bug Fix] HoverCard: let the card escape a clipping ancestor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HoverCardContent is `position: absolute`, so an ancestor with `overflow: hidden` — a scroll area, a table cell, a truncating container — clips the card instead of letting it overflow. Give HoverCard the same escape hatch DropdownMenu already has: `option: {strategy: "fixed"}` switches the root to `is-fixed`, the content picks up `fixed` through the group variant, and the controller passes the strategy on to computePosition. The default stays `absolute`, so existing usage is untouched. Co-Authored-By: Claude Opus 5 --- docs/app/views/docs/hover_card.rb | 15 ++++++++++ gem/lib/ruby_ui/hover_card/hover_card.rb | 10 +++++++ .../ruby_ui/hover_card/hover_card_content.rb | 2 +- .../hover_card/hover_card_controller.js | 3 ++ gem/lib/ruby_ui/hover_card/hover_card_docs.rb | 15 ++++++++++ gem/test/ruby_ui/hover_card_test.rb | 29 +++++++++++++++++-- mcp/data/registry.json | 13 ++++++--- 7 files changed, 80 insertions(+), 7 deletions(-) diff --git a/docs/app/views/docs/hover_card.rb b/docs/app/views/docs/hover_card.rb index 75c1cb7de..9eb43e209 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/hover_card/hover_card.rb b/gem/lib/ruby_ui/hover_card/hover_card.rb index f7c9caabc..aa2281db1 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 == "absolute") ? "is-absolute" : "is-fixed" + ], 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 11576072b..0e7b4da44 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 3aa49a83a..326099438 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 1aa18ed55..10866b52f 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/hover_card_test.rb b/gem/test/ruby_ui/hover_card_test.rb index 5f45cfe6f..2ff22e314 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 - #