Skip to content

Instantly share code, notes, and snippets.

@cap340
Last active October 8, 2025 03:31
Show Gist options
  • Save cap340/47e24fe4374e4addf52b6dae60fc81ce to your computer and use it in GitHub Desktop.
Save cap340/47e24fe4374e4addf52b6dae60fc81ce to your computer and use it in GitHub Desktop.
Symfony UX Live Component Popover
<div
{{ attributes.defaults(stimulus_controller('popover')) }}
data-live-name="{{ this.key }}"
>
<button
data-popover-target="trigger"
type="button"
aria-haspopup="dialog"
aria-expanded="{{ this.open ? 'true' : 'false' }}"
aria-controls="{{ this.key }}"
data-action="click->popover#toggle"
>
{% block trigger %}
{% endblock %}
</button>
<template data-popover-target="template">
<div
id="{{ this.key }}"
data-side="{{ this.side }}"
data-align="{{ this.align }}"
role="dialog"
tabindex="-1"
class="z-50 data-[state=open]:animate-in data-[state=closed]:animate-out origin-[var(--popover-origin)] transition duration-150"
data-popover-target="content"
data-state="{{ this.open ? 'open' : 'closed' }}"
>
{% block content %}
{% endblock %}
</div>
</template>
</div>
<?php
declare(strict_types=1);
namespace App\Twig\Components;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveAction;
use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\ComponentToolsTrait;
use Symfony\UX\LiveComponent\DefaultActionTrait;
#[AsLiveComponent]
class Popover
{
use ComponentToolsTrait;
use DefaultActionTrait;
#[LiveProp(writable: true)]
public bool $open = false;
#[LiveProp]
public string $key;
#[LiveProp]
public string $align = 'center';
#[LiveProp]
public string $side = 'bottom';
#[LiveProp]
public string $contentClass = '';
public function preMount(array $data): array
{
$resolver = new OptionsResolver();
$resolver->setDefined('key');
$resolver->setNormalizer('key', function (mixed $value): string {
if (!\is_string($value) || !str_starts_with($value, 'popover') || !str_contains($value, 'popover')) {
$value = (string) "popover-$value";
}
return $value;
});
$resolver->setDefaults([
'align' => 'center',
'side' => 'bottom',
]);
$resolver->setAllowedValues('align', ['start', 'center', 'end']);
$resolver->setAllowedValues('side', ['top', 'right', 'bottom', 'left']);
return $resolver->resolve($data) + $data;
}
#[LiveAction]
public function toggle(): void
{
$this->open = !$this->open;
}
}
import { Controller } from "@hotwired/stimulus";
import {
getComponent,
LiveController,
type Component,
// @ts-ignore
} from "@symfony/ux-live-component";
type PopoverSide = "bottom" | "right" | "bottom" | "left";
type PopoverAlign = "start" | "center" | "end";
export default class extends Controller<HTMLElement> implements LiveController {
static targets = ["trigger", "content", "template"];
declare triggerTarget: HTMLButtonElement;
declare templateTarget: HTMLTemplateElement;
declare hasContentTarget: boolean;
contentTarget: HTMLElement | undefined = undefined;
component!: Component;
connect() {
this.initComponent();
this.handleOutsideClick = this.handleOutsideClick.bind(this);
this.handleEscape = this.handleEscape.bind(this);
this.handleResize = this.handleResize.bind(this);
}
async initComponent() {
this.component = await getComponent(this.element);
}
toggle() {
const isOpen = this.component.getData("open");
isOpen ? this.hide() : this.show();
this.component.action("toggle");
}
show() {
if (!this.hasContentTarget) {
if (this.templateTarget) {
const node =
this.templateTarget.content.firstElementChild?.cloneNode(
true,
);
if (node && node instanceof HTMLDivElement) {
document.body.appendChild(node);
this.contentTarget = node;
}
}
}
this.positionPopover();
if (this.contentTarget) {
this.contentTarget.dataset.state = "open";
}
document.addEventListener("click", this.handleOutsideClick);
document.addEventListener("keydown", this.handleEscape);
window.addEventListener("resize", this.handleResize);
}
hide() {
if (!this.contentTarget) {
return;
}
if (this.contentTarget) {
this.contentTarget.dataset.state = "closed";
setTimeout(() => this.contentTarget?.remove(), 150);
}
document.removeEventListener("click", this.handleOutsideClick);
document.removeEventListener("keydown", this.handleEscape);
window.removeEventListener("resize", this.handleResize);
}
handleOutsideClick = (e: Event) => {
if (
e.target instanceof Node &&
this.hasContentTarget &&
this.contentTarget &&
!this.contentTarget.contains(e.target) &&
!this.triggerTarget.contains(e.target)
) {
this.hide();
this.component.action("toggle");
}
};
handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") {
this.hide();
this.component.action("toggle");
}
};
handleResize = () => {
if (this.component.getData("open")) {
this.positionPopover();
}
};
positionPopover() {
const rect = this.triggerTarget.getBoundingClientRect();
const side = this.component.getData("side") || "bottom";
const align = this.component.getData("align") || "center";
const margin = 8; // margin between anchor and popover
const content = this.contentTarget;
if (typeof content !== "undefined") {
const vw = window.innerWidth;
const vh = window.innerHeight;
const contentWidth = content.offsetWidth || 250;
const contentHeight = content.offsetHeight || 200;
let top = 0;
let left = 0;
let effectiveSide = side;
// --- Calcul brut
switch (side) {
case "top":
top = rect.top - contentHeight - margin;
break;
case "bottom":
top = rect.bottom + margin;
break;
case "left":
top = rect.top + rect.height / 2 - contentHeight / 2;
left = rect.left - contentWidth - margin;
break;
case "right":
top = rect.top + rect.height / 2 - contentHeight / 2;
left = rect.right + margin;
break;
}
// horizontal / vertical alignement
if (side === "top" || side === "bottom") {
switch (align) {
case "start":
left = rect.left;
break;
case "center":
left = rect.left + rect.width / 2 - contentWidth / 2;
break;
case "end":
left = rect.right - contentWidth;
break;
}
} else {
// left/right → align vertical
switch (align) {
case "start":
top = rect.top;
break;
case "center":
top = rect.top + rect.height / 2 - contentHeight / 2;
break;
case "end":
top = rect.bottom - contentHeight;
break;
}
}
// --- Collision detection (auto flip)
const fitsBottom = rect.bottom + contentHeight + margin < vh;
const fitsTop = rect.top - contentHeight - margin > 0;
const fitsLeft = rect.left - contentWidth - margin > 0;
const fitsRight = rect.right + contentWidth + margin < vw;
if (side === "bottom" && !fitsBottom && fitsTop) {
effectiveSide = "top";
top = rect.top - contentHeight - margin;
} else if (side === "top" && !fitsTop && fitsBottom) {
effectiveSide = "bottom";
top = rect.bottom + margin;
} else if (side === "left" && !fitsLeft && fitsRight) {
effectiveSide = "right";
left = rect.right + margin;
} else if (side === "right" && !fitsRight && fitsLeft) {
effectiveSide = "left";
left = rect.left - contentWidth - margin;
}
// --- viewport Constraint
top = Math.max(margin, Math.min(vh - contentHeight - margin, top));
left = Math.max(margin, Math.min(vw - contentWidth - margin, left));
// --- Apply styles
content.style.position = "fixed";
content.style.top = `${top}px`;
content.style.left = `${left}px`;
content.style.zIndex = "999";
content.dataset.side = effectiveSide;
// --- CSS vars animations
content.style.setProperty(
"--popover-origin",
this.getOrigin(effectiveSide, align),
);
content.style.setProperty(
"--popover-available-height",
`${vh - rect.bottom}px`,
);
content.style.setProperty(
"--popover-available-width",
`${vw - rect.left}px`,
);
}
}
getOrigin(side: PopoverSide, align: PopoverAlign) {
const origins = {
top: {
start: "bottom left",
center: "bottom center",
end: "bottom right",
},
bottom: {
start: "top left",
center: "top center",
end: "top right",
},
left: {
start: "top right",
center: "center right",
end: "bottom right",
},
right: {
start: "top left",
center: "center left",
end: "bottom left",
},
};
return origins[side]?.[align] ?? "center";
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment