import { autoUpdate, computePosition, flip, offset, shift, } from "@floating-ui/dom"; import { Controller } from "@hotwired/stimulus"; /** * A "menu" can contain arbitrary content including non-clickable items, links, buttons, and forms. */ export default class extends Controller { static targets = ["button", "content"]; static values = { show: Boolean, placement: { type: String, default: "bottom-end" }, offset: { type: Number, default: 6 }, }; connect() { this.show = this.showValue; this.boundUpdate = this.update.bind(this); this.addEventListeners(); this.startAutoUpdate(); } disconnect() { this.removeEventListeners(); this.stopAutoUpdate(); this.close(); } addEventListeners() { this.buttonTarget.addEventListener("click", this.toggle); this.element.addEventListener("keydown", this.handleKeydown); document.addEventListener("click", this.handleOutsideClick); document.addEventListener("turbo:load", this.handleTurboLoad); } removeEventListeners() { this.buttonTarget.removeEventListener("click", this.toggle); this.element.removeEventListener("keydown", this.handleKeydown); document.removeEventListener("click", this.handleOutsideClick); document.removeEventListener("turbo:load", this.handleTurboLoad); } handleTurboLoad = () => { if (!this.show) this.close(); }; handleOutsideClick = (event) => { if (this.show && !this.element.contains(event.target)) this.close(); }; handleKeydown = (event) => { if (event.key === "Escape") { this.close(); this.buttonTarget.focus(); } }; toggle = () => { this.show = !this.show; this.contentTarget.classList.toggle("hidden", !this.show); if (this.show) { this.update(); this.focusFirstElement(); } }; close() { this.show = false; this.contentTarget.classList.add("hidden"); } focusFirstElement() { const focusableElements = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'; const firstFocusableElement = this.contentTarget.querySelectorAll(focusableElements)[0]; if (firstFocusableElement) { firstFocusableElement.focus(); } } startAutoUpdate() { if (!this._cleanup) { this._cleanup = autoUpdate( this.buttonTarget, this.contentTarget, this.boundUpdate, ); } } stopAutoUpdate() { if (this._cleanup) { this._cleanup(); this._cleanup = null; } } update() { computePosition(this.buttonTarget, this.contentTarget, { placement: this.placementValue, middleware: [offset(this.offsetValue), flip(), shift({ padding: 5 })], }).then(({ x, y }) => { Object.assign(this.contentTarget.style, { position: "fixed", left: `${x}px`, top: `${y}px`, }); }); } }