Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions docs/app/views/docs/hover_card.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
2 changes: 1 addition & 1 deletion gem/lib/ruby_ui/dropdown_menu/dropdown_menu.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 10 additions & 0 deletions gem/lib/ruby_ui/hover_card/hover_card.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion gem/lib/ruby_ui/hover_card/hover_card_content.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions gem/lib/ruby_ui/hover_card/hover_card_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
15 changes: 15 additions & 0 deletions gem/lib/ruby_ui/hover_card/hover_card_docs.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
18 changes: 18 additions & 0 deletions gem/test/ruby_ui/dropdown_menu_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 36 additions & 2 deletions gem/test/ruby_ui/hover_card_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,18 +24,52 @@ def test_render_with_all_items
end

# Floating UI positions a real element in the DOM (tippy used to clone a
# <template>). Content must render as a hidden, absolutely-positioned div.
# <template>). Content must render as a hidden, positioned div.
def test_content_renders_hidden_positioned_div_not_template
output = phlex do
RubyUI.HoverCardContent { "card body" }
end

refute_match(/<template/, output)
assert_match(/hidden/, output)
assert_match(/absolute/, output)
assert_match(/card body/, output)
end

# The strategy lives on the root, so the content takes its `position` from
# the group variant the root switches on.
def test_content_takes_its_position_from_the_root_strategy
output = phlex do
RubyUI.HoverCardContent { "card body" }
end

assert_match(%r{group-\[\.is-absolute\]/hover-card:absolute}, output)
assert_match(%r{group-\[\.is-fixed\]/hover-card:fixed}, output)
end

def test_root_is_absolute_by_default
output = phlex { RubyUI.HoverCard { "card" } }

assert_match(%r{class="group/hover-card is-absolute"}, output)
end

# An ancestor with `overflow: hidden` clips an absolutely positioned card;
# `strategy: "fixed"` is what lets the card escape it.
def test_root_opts_into_the_fixed_strategy
output = phlex { RubyUI.HoverCard(option: {strategy: "fixed"}) { "card" } }

assert_match(%r{class="group/hover-card is-fixed"}, output)
assert_match(/&quot;strategy&quot;:&quot;fixed&quot;/, 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_root_falls_back_to_absolute_for_an_unsupported_strategy
output = phlex { RubyUI.HoverCard(option: {strategy: "absolut"}) { "card" } }

assert_match(%r{class="group/hover-card 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
Expand Down
Loading