Last active
October 8, 2025 03:31
-
-
Save cap340/47e24fe4374e4addf52b6dae60fc81ce to your computer and use it in GitHub Desktop.
Symfony UX Live Component Popover
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <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> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?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; | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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