ComponentsThemes
Palette
Default
Stone
Rose
Blue
Green
Violet
Yellow
Orange
6

← Components

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/vanilla

Pin @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

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.