@rxxuzi/gumi
Version:
Clean & minimal design system with delightful interactions
214 lines • 7.61 kB
JavaScript
// components/dropdown.ts
// Gumi.js v1.0.0 - Dropdown Component (Simplified)
import { $, $$, on, off, trigger, addClass, removeClass, hasClass } from '../core/dom';
export class Dropdown {
constructor(trigger, menuOrOptions, options) {
this.isOpen = false;
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);
if (!menuEl)
throw new Error('Dropdown menu not found');
this.menu = menuEl;
this.options = { ...this.getDefaultOptions(), ...options };
}
this.init();
}
getDefaultOptions() {
return {
trigger: 'hover', // Default to hover for simplicity
closeOnClick: true,
keyboard: true
};
}
findMenu() {
// 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;
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');
if (menu)
return menu;
}
throw new Error('Dropdown menu not found');
}
init() {
var _a;
// 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';
}
(_a = this.trigger.parentNode) === null || _a === void 0 ? void 0 : _a.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');
}
bindClickEvents() {
// Toggle on click
this.clickHandler = (e) => {
e.preventDefault();
e.stopPropagation();
this.toggle();
};
on(this.trigger, 'click', this.clickHandler);
// Close on outside click
this.documentClickHandler = (e) => {
const target = e.target;
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) => {
const target = e.target;
if (target.closest('.dropdown-item')) {
this.hide();
}
});
}
}
bindKeyboardEvents() {
on(this.trigger, 'keydown', (e) => {
const event = e;
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() {
if (this.isOpen || this.options.trigger !== 'click')
return;
const dropdown = this.trigger.closest('.dropdown');
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() {
if (!this.isOpen || this.options.trigger !== 'click')
return;
const dropdown = this.trigger.closest('.dropdown');
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() {
if (this.isOpen) {
this.hide();
}
else {
this.show();
}
}
destroy() {
// 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 = '[data-dropdown]') {
const triggers = $$(selector);
return Array.from(triggers).map(trigger => new Dropdown(trigger));
}
/**
* Static method to initialize from data attributes
*/
static initFromAttributes(selector = '[data-dropdown]') {
const elements = $$(selector);
return Array.from(elements).map(element => {
const options = {};
// Parse data attributes
const triggerType = element.getAttribute('data-trigger');
if (triggerType)
options.trigger = triggerType;
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);
});
}
}
//# sourceMappingURL=dropdown.js.map