assets_js_controllers_popover-controller.js

import { Controller } from "@hotwired/stimulus";

/**
 * Popover Controller
 * 
 * A reusable Stimulus controller for Bootstrap popovers with support for:
 * - HTML content with close buttons
 * - Custom allowList for sanitizer (allows button elements)
 * - Auto-initialization on connect
 * - Proper cleanup on disconnect
 * 
 * Usage:
 * <button type="button" 
 *     data-controller="popover"
 *     data-bs-toggle="popover"
 *     data-bs-trigger="click"
 *     data-bs-html="true"
 *     data-bs-content="<div>Content with <button class='btn-close popover-close-btn'></button></div>">
 *     Open Popover
 * </button>
 */
class PopoverController extends Controller {
    static values = {
        placement: { type: String, default: "auto" },
        trigger: { type: String, default: "click" },
        html: { type: Boolean, default: true },
        customClass: { type: String, default: "" }
    };

    connect() {
        this.initializePopover();
        this.setupCloseButtonHandler();
    }

    disconnect() {
        this.removeCloseButtonHandler();
        this.destroyPopover();
    }

    initializePopover() {
        // Custom allowList to permit button elements in popover content
        const allowList = Object.assign({}, bootstrap.Popover.Default.allowList);
        allowList.button = ['type', 'class', 'aria-label'];

        // Get options from data attributes or use defaults
        const options = {
            allowList: allowList,
            placement: this.placementValue,
            trigger: this.triggerValue,
            html: this.htmlValue,
        };

        if (this.customClassValue) {
            options.customClass = this.customClassValue;
        }

        // Initialize Bootstrap popover
        this.popover = new bootstrap.Popover(this.element, options);
    }

    destroyPopover() {
        if (this.popover) {
            this.popover.dispose();
            this.popover = null;
        }
    }

    setupCloseButtonHandler() {
        // Use bound method for proper removal later
        this.handleCloseClick = this.handleCloseClick.bind(this);
        document.addEventListener('click', this.handleCloseClick);
    }

    removeCloseButtonHandler() {
        document.removeEventListener('click', this.handleCloseClick);
    }

    handleCloseClick(event) {
        const closeBtn = event.target.closest('.popover .btn-close, .popover .popover-close-btn');
        if (!closeBtn) return;

        const popoverElement = closeBtn.closest('.popover');
        if (!popoverElement) return;

        // Check if this popover belongs to this controller's element
        const popoverId = popoverElement.id;
        if (this.element.getAttribute('aria-describedby') !== popoverId) return;

        // Hide the popover
        if (this.popover) {
            this.popover.hide();
        }

        event.preventDefault();
        event.stopPropagation();
    }

    // Action to programmatically show the popover
    show() {
        if (this.popover) {
            this.popover.show();
        }
    }

    // Action to programmatically hide the popover
    hide() {
        if (this.popover) {
            this.popover.hide();
        }
    }

    // Action to toggle the popover
    toggle() {
        if (this.popover) {
            this.popover.toggle();
        }
    }
}

// Register in global Controllers object
if (!window.Controllers) {
    window.Controllers = {};
}
window.Controllers["popover"] = PopoverController;

export default PopoverController;