Popover
Floating panel anchored to a trigger. Opens on click; click-outside and Escape dismiss.
Installation
bin/rails g wabi:add popover
bin/importmap pin @zag-js/popover
bin/importmap pin @zag-js/vanillaPin @zag-js/popover and @zag-js/vanilla at version 1.41+ using the +esm jsdelivr URLs — bin/importmap pin only downloads the main entry and leaves submodule imports unresolved.
Example
This is a floating panel anchored to the trigger.
render Components::UI::Popover.new do
render Components::UI::PopoverTrigger.new(
class: "inline-flex items-center justify-center rounded-md text-sm font-medium " \
"border border-input bg-background hover:bg-accent hover:text-accent-foreground " \
"h-10 px-4 py-2"
) { "More info" }
render Components::UI::PopoverContent.new do
p(class: "text-sm mb-3") { "This is a floating panel anchored to the trigger." }
render Components::UI::PopoverClose.new { "Close" }
end
end
Source
app/components/ui/popover.rb
# frozen_string_literal: true
require "date"
module Components
module UI
class Popover < Wabi::Base
def initialize(id: nil, open: false, modal: false, portal: true, **attrs)
@id = id
@open = open
@modal = modal
@portal = portal
@attrs = attrs
end
def view_template(&block)
div(
id: @id,
class: "inline-block",
data: {
controller: "wabi--popover",
"wabi--popover-open-value": @open.to_s,
"wabi--popover-modal-value": @modal.to_s,
"wabi--popover-portal-value": @portal.to_s,
}
) do
yield if block_given?
end
end
end
end
end
app/components/ui/popover_trigger.rb
# frozen_string_literal: true
require "date"
module Components
module UI
class PopoverTrigger < Wabi::Base
def initialize(**attrs)
@attrs = attrs
end
def view_template(&block)
user_class = @attrs.delete(:class)
# Spread @attrs so callers can pass aria-label: for icon-only triggers.
# Explicit data: wiring is placed last so Zag's target wire always wins.
button(
type: "button",
**@attrs,
data: { "wabi--popover-target": "trigger" },
class: merge_class(user_class)
) do
yield if block_given?
end
end
end
end
end
app/components/ui/popover_content.rb
# frozen_string_literal: true
require "date"
module Components
module UI
class PopoverContent < Wabi::Base
variants do
base "z-50 w-72 rounded-md border border-input bg-popover p-4 text-popover-foreground shadow-md outline-none " \
"transition-opacity duration-200 ease-out motion-reduce:transition-none " \
"data-[state=open]:opacity-100 data-[state=closed]:opacity-0 " \
"data-[state=closed]:pointer-events-none"
end
def initialize(**attrs)
@attrs = attrs
end
def view_template(&block)
user_class = @attrs.delete(:class)
div(
data: { "wabi--popover-target": "positioner" },
class: "z-50 pointer-events-none"
) do
# Spread @attrs FIRST so caller aria-label/aria-labelledby reach the
# role=dialog content (Zag's getContentProps doesn't set a name); the
# explicit target/data-state/inert below still win the wiring.
div(
**@attrs,
data: { "wabi--popover-target": "content" },
"data-state": "closed",
inert: true,
class: merge_class(tokens, user_class)
) do
yield if block_given?
end
end
end
end
end
end
app/components/ui/popover_close.rb
# frozen_string_literal: true
require "date"
module Components
module UI
# Outline button tagged as the Zag closeTrigger -- click auto-closes the
# popover via the same `wabi--popover` controller (no manual data-action
# wiring needed). Matches the DialogCancel / DrawerClose pattern.
class PopoverClose < Wabi::Base
def initialize(**attrs)
@attrs = attrs
end
def view_template(&block)
render Components::UI::Button.new(
appearance: :outline,
data: { "wabi--popover-target": "closeTrigger" },
**@attrs
) do
yield if block_given?
end
end
end
end
end
Accessibility
- role="dialog" (Zag's popover default); aria-labelledby and aria-describedby wired through the title/description slots when present.
- Positioning handled by Zag's positioner — keeps the panel in view at viewport edges.
- Click-outside and Escape close the popover.
- Focus moves into the panel on open and returns to the trigger on close.