import { LitElement, html, css } from 'lit'; import { property } from 'lit/decorators.js'; export class ClickAwayListener extends LitElement { @property({ type: Boolean }) disabled = false; @property({ type: String }) mouseEvent: 'onClick' | 'onMouseDown' | 'onMouseUp' | false = 'onClick'; @property({ type: String }) touchEvent: 'onTouchStart' | 'onTouchEnd' | false = 'onTouchEnd'; private _mounted = false; private _syntheticEventExists = false; static styles = css` :host { display: contents; } `; connectedCallback() { super.connectedCallback(); this._mounted = true; // Add event listeners after a microtask to avoid immediate triggering setTimeout(() => { this._addEventListeners(); }, 0); } disconnectedCallback() { super.disconnectedCallback(); this._mounted = false; this._removeEventListeners(); } updated(changedProperties: Map) { super.updated(changedProperties); if (changedProperties.has('disabled') || changedProperties.has('mouseEvent') || changedProperties.has('touchEvent')) { this._removeEventListeners(); if (!this.disabled) { this._addEventListeners(); } } } private _addEventListeners() { if (this.disabled || !this._mounted) return; // Add mouse event listener if (this.mouseEvent) { const eventName = this._getEventName(this.mouseEvent); document.addEventListener(eventName, this._handleClickAway, true); } // Add touch event listener if (this.touchEvent) { const eventName = this._getEventName(this.touchEvent); document.addEventListener(eventName, this._handleClickAway, true); } } private _removeEventListeners() { // Remove mouse event listener if (this.mouseEvent) { const eventName = this._getEventName(this.mouseEvent); document.removeEventListener(eventName, this._handleClickAway, true); } // Remove touch event listener if (this.touchEvent) { const eventName = this._getEventName(this.touchEvent); document.removeEventListener(eventName, this._handleClickAway, true); } } private _getEventName(eventType: string): string { switch (eventType) { case 'onClick': return 'click'; case 'onMouseDown': return 'mousedown'; case 'onMouseUp': return 'mouseup'; case 'onTouchStart': return 'touchstart'; case 'onTouchEnd': return 'touchend'; default: return 'click'; } } private _handleClickAway = (event: Event) => { // Ignore events that occurred before the component was mounted if (!this._mounted) return; // Check if this is a synthetic event (React-style event handling) if (this._syntheticEventExists) { this._syntheticEventExists = false; return; } // Get the target element const target = event.target as Node; if (!target || !document.contains(target)) return; // Check if the click is inside any of our slotted children const slot = this.shadowRoot?.querySelector('slot'); if (!slot) return; const assignedElements = slot.assignedElements({ flatten: true }); const assignedNodes = slot.assignedNodes({ flatten: true }); // Check all assigned elements and nodes for (const element of [...assignedElements, ...assignedNodes]) { if (element.nodeType === Node.ELEMENT_NODE) { const elementNode = element as Element; if (elementNode.contains(target) || elementNode === target) { return; // Click is inside, don't trigger clickaway } } } // If we get here, the click was outside all children this._dispatchClickAway(event); }; private _dispatchClickAway(originalEvent: Event) { const clickAwayEvent = new CustomEvent('clickaway', { bubbles: true, composed: true, detail: { originalEvent, mouseEvent: this.mouseEvent, touchEvent: this.touchEvent } }); this.dispatchEvent(clickAwayEvent); } private _handleSlotClick = (event: Event) => { // Mark that a synthetic event exists to prevent double triggering this._syntheticEventExists = true; }; render() { return html` `; } } // Utility function for programmatic usage export function createClickAwayListener( element: Element, callback: (event: Event) => void, options: { disabled?: boolean; mouseEvent?: 'onClick' | 'onMouseDown' | 'onMouseUp' | false; touchEvent?: 'onTouchStart' | 'onTouchEnd' | false; } = {} ): () => void { const { disabled = false, mouseEvent = 'onClick', touchEvent = 'onTouchEnd' } = options; let mounted = true; const getEventName = (eventType: string): string => { switch (eventType) { case 'onClick': return 'click'; case 'onMouseDown': return 'mousedown'; case 'onMouseUp': return 'mouseup'; case 'onTouchStart': return 'touchstart'; case 'onTouchEnd': return 'touchend'; default: return 'click'; } }; const handleClickAway = (event: Event) => { if (!mounted || disabled) return; const target = event.target as Node; if (!target || !document.contains(target)) return; // Check if click is inside the element if (element.contains(target) || element === target) { return; } // Click is outside, trigger callback callback(event); }; // Add event listeners const addListeners = () => { if (disabled || !mounted) return; if (mouseEvent) { const eventName = getEventName(mouseEvent); document.addEventListener(eventName, handleClickAway, true); } if (touchEvent) { const eventName = getEventName(touchEvent); document.addEventListener(eventName, handleClickAway, true); } }; const removeListeners = () => { if (mouseEvent) { const eventName = getEventName(mouseEvent); document.removeEventListener(eventName, handleClickAway, true); } if (touchEvent) { const eventName = getEventName(touchEvent); document.removeEventListener(eventName, handleClickAway, true); } }; // Start listening setTimeout(addListeners, 0); // Return cleanup function return () => { mounted = false; removeListeners(); }; }