UNPKG

@rxxuzi/gumi

Version:

Clean & minimal design system with delightful interactions

250 lines (203 loc) 8.58 kB
// components/dropdown.ts // Gumi.js v1.0.0 - Dropdown Component (Simplified) import { DropdownOptions, GumiElement } from '../types'; import { $, $$, on, off, trigger, addClass, removeClass, hasClass } from '../core/dom'; export class Dropdown { private trigger: HTMLElement; private menu: HTMLElement; private options: DropdownOptions; private isOpen: boolean = false; private clickHandler?: (e: Event) => void; private documentClickHandler?: (e: Event) => void; constructor(trigger: GumiElement, menuOrOptions?: GumiElement | DropdownOptions, options?: DropdownOptions) { const triggerEl = $(trigger); if (!triggerEl) throw new Error('Dropdown trigger not found'); this.trigger = triggerEl; // Handle overloaded constructor parameters if (menuOrOptions && typeof menuOrOptions === 'object' && !('nodeType' in menuOrOptions)) { // Second parameter is options this.options = { ...this.getDefaultOptions(), ...menuOrOptions }; this.menu = this.findMenu(); } else { // Second parameter is menu element const menuEl = $(menuOrOptions as GumiElement); if (!menuEl) throw new Error('Dropdown menu not found'); this.menu = menuEl; this.options = { ...this.getDefaultOptions(), ...options }; } this.init(); } private getDefaultOptions(): DropdownOptions { return { trigger: 'hover', // Default to hover for simplicity closeOnClick: true, keyboard: true }; } private findMenu(): HTMLElement { // Find dropdown menu by data attribute or next sibling const menuId = this.trigger.getAttribute('data-dropdown'); if (menuId) { const menu = $(menuId); if (menu) return menu; } // Look for sibling menu let menu = this.trigger.nextElementSibling as HTMLElement; if (menu && hasClass(menu, 'dropdown-menu')) { return menu; } // Look for menu in parent container const parent = this.trigger.closest('.dropdown'); if (parent) { menu = parent.querySelector('.dropdown-menu') as HTMLElement; if (menu) return menu; } throw new Error('Dropdown menu not found'); } private init(): void { // Add wrapper if needed const parent = this.trigger.parentElement; if (!parent || !hasClass(parent, 'dropdown')) { const wrapper = document.createElement('div'); wrapper.className = 'dropdown'; // Add click modifier if needed if (this.options.trigger === 'click') { wrapper.className += ' dropdown-click'; } this.trigger.parentNode?.insertBefore(wrapper, this.trigger); wrapper.appendChild(this.trigger); wrapper.appendChild(this.menu); } else if (this.options.trigger === 'click') { addClass(parent, 'dropdown-click'); } // For click-based dropdowns, bind events if (this.options.trigger === 'click') { this.bindClickEvents(); } // Keyboard navigation (minimal) if (this.options.keyboard) { this.bindKeyboardEvents(); } // Setup ARIA attributes this.trigger.setAttribute('role', 'button'); this.trigger.setAttribute('aria-haspopup', 'true'); this.trigger.setAttribute('aria-expanded', 'false'); this.menu.setAttribute('role', 'menu'); } private bindClickEvents(): void { // Toggle on click this.clickHandler = (e: Event) => { e.preventDefault(); e.stopPropagation(); this.toggle(); }; on(this.trigger, 'click', this.clickHandler); // Close on outside click this.documentClickHandler = (e: Event) => { const target = e.target as Element; const dropdown = this.trigger.closest('.dropdown'); if (dropdown && !dropdown.contains(target)) { this.hide(); } }; // Close on menu item click if option is set if (this.options.closeOnClick) { on(this.menu, 'click', (e: Event) => { const target = e.target as HTMLElement; if (target.closest('.dropdown-item')) { this.hide(); } }); } } private bindKeyboardEvents(): void { on(this.trigger, 'keydown', (e: Event) => { const event = e as KeyboardEvent; if (event.key === 'Enter' || event.key === ' ') { e.preventDefault(); this.toggle(); } else if (event.key === 'Escape' && this.isOpen) { e.preventDefault(); this.hide(); this.trigger.focus(); } }); } show(): void { if (this.isOpen || this.options.trigger !== 'click') return; const dropdown = this.trigger.closest('.dropdown') as HTMLElement; if (!dropdown) return; this.isOpen = true; addClass(dropdown, 'active'); this.trigger.setAttribute('aria-expanded', 'true'); // Add document listener if (this.documentClickHandler) { setTimeout(() => { on(document, 'click', this.documentClickHandler!); }, 0); } trigger(this.trigger, 'gumi:dropdown:show', { dropdown: this }); } hide(): void { if (!this.isOpen || this.options.trigger !== 'click') return; const dropdown = this.trigger.closest('.dropdown') as HTMLElement; if (!dropdown) return; this.isOpen = false; removeClass(dropdown, 'active'); this.trigger.setAttribute('aria-expanded', 'false'); // Remove document listener if (this.documentClickHandler) { off(document, 'click', this.documentClickHandler); } trigger(this.trigger, 'gumi:dropdown:hide', { dropdown: this }); } toggle(): void { if (this.isOpen) { this.hide(); } else { this.show(); } } destroy(): void { // Close dropdown first if (this.isOpen) { this.hide(); } // Remove event listeners if (this.clickHandler) { off(this.trigger, 'click', this.clickHandler); } if (this.documentClickHandler) { off(document, 'click', this.documentClickHandler); } // Clean up ARIA attributes this.trigger.removeAttribute('role'); this.trigger.removeAttribute('aria-haspopup'); this.trigger.removeAttribute('aria-expanded'); this.menu.removeAttribute('role'); } /** * Static method to initialize all dropdowns */ static initAll(selector: string = '[data-dropdown]'): Dropdown[] { const triggers = $$(selector); return Array.from(triggers).map(trigger => new Dropdown(trigger)); } /** * Static method to initialize from data attributes */ static initFromAttributes(selector: string = '[data-dropdown]'): Dropdown[] { const elements = $$(selector); return Array.from(elements).map(element => { const options: DropdownOptions = {}; // Parse data attributes const triggerType = element.getAttribute('data-trigger'); if (triggerType) options.trigger = triggerType as 'click' | 'hover'; const closeOnClick = element.getAttribute('data-close-on-click'); if (closeOnClick) options.closeOnClick = closeOnClick !== 'false'; const keyboard = element.getAttribute('data-keyboard'); if (keyboard) options.keyboard = keyboard !== 'false'; return new Dropdown(element, options); }); } }