Menu

Popover

Popover

This appears once you need it most

render Components::Popover.new do
  it.trigger(class: "cursor-help") { render Components::Button.new(color: :ghost, size: :sm) { "Hover me" } }
  it.content do
    div(class: "flex flex-col gap-1") do
      p(class: "font-medium text-gray-950") { "Popover" }
      p(class: "text-sm text-gray-950/50") { "This appears once you need it most" }
    end
  end
end

Installation

Add the component to your project

CLI

Run the following command in your terminal

bundle exec essence add popover
Manually

Add the following code and libraries into your project

components/popover.rb
# frozen_string_literal: true

class Components::Popover < Components::Essence
  INTERACTIONS = {
    click: "click->essence--popover#toggle",
    hover: "mouseenter->essence--popover#open mouseleave->essence--popover#close focus->essence--popover#open blur->essence--popover#close"
  }.freeze

  attr_reader :open, :interaction, :size

  def initialize(open: false, interaction: :hover, size: :md, **attributes)
    @open = open
    @interaction = interaction
    @size = size
    super(**attributes)
  end

  def view_template(&) = div(**attributes, &)
  def trigger(**, &) = div(**m(**), &)
  def content(**, &) = div(**m(**), &)

  private

  def component_attributes
    {
      _: {
        data: {
          controller: "essence--popover",
          "essence--popover-open-value": open.to_s,
          "popover-state": open ? "open" : "closed"
        }
      },
      trigger: {
        data: {
          "essence--popover-target": "trigger",
          action: current_interaction
        }
      },
      content: {
        data: {
          "essence--popover-target": "content",
          action: current_interaction
        }
      }
    }.freeze
  end

  def component_classes
    {
      _: {
        _: "group"
      },
      trigger: {
        _: "inline-flex"
      },
      content: {
        _: "absolute top-0 left-0 group-data-[popover-state='closed']:hidden group-data-[popover-state='open']:inline-flex rounded-sm border border-gray-950/5 shadow-xs p-2.5 bg-white",

        size: {
          none: "",
          sm: "w-48",
          md: "w-64",
          lg: "w-96"
        }
      }
    }.freeze
  end

  def current_interaction = @current_interaction ||= INTERACTIONS[interaction]
end
essence/popover_controller.js
import { Controller } from "@hotwired/stimulus";
import {
  computePosition,
  offset,
  flip,
  shift,
  autoUpdate,
} from "@floating-ui/dom";

export default class extends Controller {
  static targets = ["trigger", "content"];
  static values = {
    open: { type: Boolean, default: false },
    placement: { type: String, default: "top" },
    offset: { type: Number, default: 8 },
    shift: { type: Number, default: 8 },
    delay: { type: Number, default: 64 },
  };

  connect() {
    this.delayTimeout = null;

    this.cleanup = autoUpdate(this.triggerTarget, this.contentTarget, () => {
      computePosition(this.triggerTarget, this.contentTarget, {
        placement: this.placementValue,
        middleware: [
          offset(this.offsetValue),
          flip(),
          shift({ padding: this.shiftValue }),
        ],
      }).then(({ x, y }) => {
        this.contentTarget.style.transform = `translate(${this.#roundByDPR(x)}px,${this.#roundByDPR(y)}px)`;
      });
    });
  }

  disconnect() {
    this.cleanup();
  }

  toggle = (event) => {
    event.stopPropagation();
    this.openValue = !this.openValue;
  };

  open = (event) => {
    event.stopPropagation();
    clearTimeout(this.delayTimeout);
    this.openValue = true;
  };

  close = (event) => {
    event.stopPropagation();
    this.delayTimeout = setTimeout(() => {
      this.openValue = false;
    }, this.delayValue);
  };

  openValueChanged(state, _) {
    this.element.dataset.popoverState = state ? "open" : "closed";
    this.triggerTarget.setAttribute("aria-expanded", state);
    this.contentTarget.setAttribute("aria-hidden", !state);
  }

  #roundByDPR = (value) => {
    const dpr = window.devicePixelRatio || 1;
    return Math.round(value * dpr) / dpr;
  };
}