Menu
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
Add the component to your project
Run the following command in your terminal
bundle exec essence add popover
Add the following code and libraries into your project
# 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
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;
};
}