import anim from "animejs"; import { Utils } from "./utils"; import { Component, BaseOptions, InitElements, MElement, Openable } from "./component"; export interface DropdownOptions extends BaseOptions { /** * Defines the edge the menu is aligned to. * @default 'left' */ alignment: 'left' | 'right'; /** * If true, automatically focus dropdown el for keyboard. * @default true */ autoFocus: boolean; /** * If true, constrainWidth to the size of the dropdown activator. * @default true */ constrainWidth: boolean; /** * Provide an element that will be the bounding container of the dropdown. * @default null */ container: Element; /** * If false, the dropdown will show below the trigger. * @default true */ coverTrigger: boolean; /** * If true, close dropdown on item click. * @default true */ closeOnClick: boolean; /** * If true, the dropdown will open on hover. * @default false */ hover: boolean; /** * The duration of the transition enter in milliseconds. * @default 150 */ inDuration: number; /** * The duration of the transition out in milliseconds. * @default 250 */ outDuration: number; /** * Function called when dropdown starts entering. * @default null */ onOpenStart: (el: HTMLElement) => void; /** * Function called when dropdown finishes entering. * @default null */ onOpenEnd: (el: HTMLElement) => void; /** * Function called when dropdown starts exiting. * @default null */ onCloseStart: (el: HTMLElement) => void; /** * Function called when dropdown finishes exiting. * @default null */ onCloseEnd: (el: HTMLElement) => void; /** * Function called when item is clicked. * @default null */ onItemClick: (el: HTMLLIElement) => void; }; const _defaults: DropdownOptions = { alignment: 'left', autoFocus: true, constrainWidth: true, container: null, coverTrigger: true, closeOnClick: true, hover: false, inDuration: 150, outDuration: 250, onOpenStart: null, onOpenEnd: null, onCloseStart: null, onCloseEnd: null, onItemClick: null }; export class Dropdown extends Component implements Openable { static _dropdowns: Dropdown[] = []; /** ID of the dropdown element. */ id: string; /** The DOM element of the dropdown. */ dropdownEl: HTMLElement; /** If the dropdown is open. */ isOpen: boolean; /** If the dropdown content is scrollable. */ isScrollable: boolean; isTouchMoving: boolean; /** The index of the item focused. */ focusedIndex: number; filterQuery: any[]; filterTimeout: NodeJS.Timeout; constructor(el: HTMLElement, options: Partial) { super(el, options, Dropdown); (this.el as any).M_Dropdown = this; Dropdown._dropdowns.push(this); this.id = Utils.getIdFromTrigger(el); this.dropdownEl = document.getElementById(this.id); this.options = { ...Dropdown.defaults, ...options }; this.isOpen = false; this.isScrollable = false; this.isTouchMoving = false; this.focusedIndex = -1; this.filterQuery = []; // Move dropdown-content after dropdown-trigger this._moveDropdown(); this._makeDropdownFocusable(); this._setupEventHandlers(); } static get defaults(): DropdownOptions { return _defaults; } /** * Initializes instance of Dropdown. * @param el HTML element. * @param options Component options. */ static init(el: HTMLElement, options?: Partial): Dropdown; /** * Initializes instances of Dropdown. * @param els HTML elements. * @param options Component options. */ static init(els: InitElements, options?: Partial): Dropdown[]; /** * Initializes instances of Dropdown. * @param els HTML elements. * @param options Component options. */ static init(els: HTMLElement | InitElements, options: Partial = {}): Dropdown | Dropdown[] { return super.init(els, options, Dropdown); } static getInstance(el: HTMLElement): Dropdown { return (el as any).M_Dropdown; } destroy() { this._resetDropdownStyles(); this._removeEventHandlers(); Dropdown._dropdowns.splice(Dropdown._dropdowns.indexOf(this), 1); (this.el as any).M_Dropdown = undefined; } _setupEventHandlers() { // Trigger keydown handler this.el.addEventListener('keydown', this._handleTriggerKeydown); // Item click handler this.dropdownEl?.addEventListener('click', this._handleDropdownClick); // Hover event handlers if (this.options.hover) { this.el.addEventListener('mouseenter', this._handleMouseEnter); this.el.addEventListener('mouseleave', this._handleMouseLeave); this.dropdownEl.addEventListener('mouseleave', this._handleMouseLeave); // Click event handlers } else { this.el.addEventListener('click', this._handleClick); } } _removeEventHandlers() { this.el.removeEventListener('keydown', this._handleTriggerKeydown); this.dropdownEl.removeEventListener('click', this._handleDropdownClick); if (this.options.hover) { this.el.removeEventListener('mouseenter', this._handleMouseEnter); this.el.removeEventListener('mouseleave', this._handleMouseLeave); this.dropdownEl.removeEventListener('mouseleave', this._handleMouseLeave); } else { this.el.removeEventListener('click', this._handleClick); } } _setupTemporaryEventHandlers() { // Use capture phase event handler to prevent click document.body.addEventListener('click', this._handleDocumentClick, true); document.body.addEventListener('touchmove', this._handleDocumentTouchmove); this.dropdownEl.addEventListener('keydown', this._handleDropdownKeydown); } _removeTemporaryEventHandlers() { // Use capture phase event handler to prevent click document.body.removeEventListener('click', this._handleDocumentClick, true); document.body.removeEventListener('touchmove', this._handleDocumentTouchmove); this.dropdownEl.removeEventListener('keydown', this._handleDropdownKeydown); } _handleClick = (e: MouseEvent) => { e.preventDefault(); this.open(); } _handleMouseEnter = () => { this.open(); } _handleMouseLeave = (e: MouseEvent) => { const toEl = e.relatedTarget as HTMLElement; const leaveToDropdownContent = !!toEl.closest('.dropdown-content'); let leaveToActiveDropdownTrigger = false; const closestTrigger = toEl.closest('.dropdown-trigger'); if ( closestTrigger && !!(closestTrigger).M_Dropdown && (closestTrigger).M_Dropdown.isOpen ) { leaveToActiveDropdownTrigger = true; } // Close hover dropdown if mouse did not leave to either active dropdown-trigger or dropdown-content if (!leaveToActiveDropdownTrigger && !leaveToDropdownContent) { this.close(); } } _handleDocumentClick = (e: MouseEvent) => { const target = e.target; if ( this.options.closeOnClick && target.closest('.dropdown-content') && !this.isTouchMoving ) { // isTouchMoving to check if scrolling on mobile. //setTimeout(() => { this.close(); //}, 0); } else if ( target.closest('.dropdown-trigger') || !target.closest('.dropdown-content') ) { //setTimeout(() => { this.close(); //}, 0); } this.isTouchMoving = false; } _handleTriggerKeydown = (e: KeyboardEvent) => { // ARROW DOWN OR ENTER WHEN SELECT IS CLOSED - open Dropdown const arrowDownOrEnter = Utils.keys.ARROW_DOWN.includes(e.key) || Utils.keys.ENTER.includes(e.key); if (arrowDownOrEnter && !this.isOpen) { e.preventDefault(); this.open(); } } _handleDocumentTouchmove = (e: TouchEvent) => { const target = e.target; if (target.closest('.dropdown-content')) { this.isTouchMoving = true; } } _handleDropdownClick = (e: MouseEvent) => { // onItemClick callback if (typeof this.options.onItemClick === 'function') { const itemEl = (e.target).closest('li'); this.options.onItemClick.call(this, itemEl); } } _handleDropdownKeydown = (e: KeyboardEvent) => { const arrowUpOrDown = Utils.keys.ARROW_DOWN.includes(e.key) || Utils.keys.ARROW_UP.includes(e.key); if (Utils.keys.TAB.includes(e.key)) { e.preventDefault(); this.close(); } // Navigate down dropdown list else if (arrowUpOrDown && this.isOpen) { e.preventDefault(); const direction = Utils.keys.ARROW_DOWN.includes(e.key) ? 1 : -1; let newFocusedIndex = this.focusedIndex; let hasFoundNewIndex = false; do { newFocusedIndex = newFocusedIndex + direction; if ( !!this.dropdownEl.children[newFocusedIndex] && (this.dropdownEl.children[newFocusedIndex]).tabIndex !== -1 ) { hasFoundNewIndex = true; break; } } while (newFocusedIndex < this.dropdownEl.children.length && newFocusedIndex >= 0); if (hasFoundNewIndex) { // Remove active class from old element if (this.focusedIndex >= 0) this.dropdownEl.children[this.focusedIndex].classList.remove('active'); this.focusedIndex = newFocusedIndex; this._focusFocusedItem(); } } // ENTER selects choice on focused item else if (Utils.keys.ENTER.includes(e.key) && this.isOpen) { // Search for and