@rxxuzi/gumi
Version:
Clean & minimal design system with delightful interactions
250 lines (203 loc) • 8.58 kB
text/typescript
// 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);
});
}
}