UNPKG

@magic-spells/dropdown-select

Version:
367 lines (307 loc) 9.95 kB
/** * dropdown-select component that handles the functionality of a custom dropdown * @class DropdownSelect * @extends HTMLElement */ export class DropdownSelect extends HTMLElement { // private fields for event handlers #handleDocumentClick; #handleKeyDown; // private fields for elements #trigger; #input; #optionsContainer; #options; #label; #currentFocusIndex = -1; // Observed attributes static get observedAttributes() { return ['position']; } constructor() { super(); // set default attributes this.setAttribute('aria-hidden', 'true'); // bind event handlers this.#handleDocumentClick = this.handleOutsideClick.bind(this); this.#handleKeyDown = this.handleKeyboardNavigation.bind(this); } /** * when element is connected to the dom */ connectedCallback() { // query all dom elements needed for the component this.#trigger = this.querySelector('dropdown-select-trigger'); this.#input = this.querySelector('input'); this.#optionsContainer = this.querySelector('dropdown-options'); this.#options = this.querySelectorAll('dropdown-option'); this.#label = this.#trigger?.querySelector('.dropdown-label'); // Make sure the component itself isn't focusable this.setAttribute('tabindex', '-1'); // initialize component this.setupAriaAttributes(); this.bindUI(); // set initial state this.hide(); } /** * clean up event listeners when element is removed */ disconnectedCallback() { this.unbindUI(); } /** * sets up aria attributes for accessibility */ setupAriaAttributes() { const listbox = this.#optionsContainer; const trigger = this.#trigger; // setup trigger button trigger.setAttribute('aria-haspopup', 'listbox'); trigger.setAttribute('aria-expanded', 'false'); trigger.setAttribute('role', 'combobox'); if (!trigger.id) { trigger.id = `dropdown-select-trigger-${Date.now()}`; } // setup listbox listbox.setAttribute('role', 'listbox'); listbox.setAttribute('aria-labelledby', trigger.id); // setup options this.#options.forEach((option, index) => { option.setAttribute('role', 'option'); option.setAttribute('aria-selected', 'false'); option.setAttribute('tabindex', '-1'); option.id = `${trigger.id}-option-${index}`; }); } /** * binds the necessary ui events to the component */ bindUI() { // No need to bind element events - child components manage their own events // This method is kept for potential future global event binding } /** * unbinds event listeners */ unbindUI() { // No element events to remove - child components manage their own events // This method is kept for potential future global event cleanup // remove document events if they exist document.removeEventListener('click', this.#handleDocumentClick); document.removeEventListener('keydown', this.#handleKeyDown); } /** * handles click events outside of the dropdown to close it * @param {Event} e - the click event */ handleOutsideClick(e) { // if click is outside of the dropdown, close it if (!this.contains(e.target)) { this.hide(); } } /** * handles keyboard navigation in the dropdown * @param {KeyboardEvent} e - the keyboard event */ handleKeyboardNavigation(e) { const options = Array.from(this.#options); switch (e.key) { case 'Escape': e.preventDefault(); this.hide(); break; case 'ArrowDown': e.preventDefault(); // if focus is on trigger, move to first option if (document.activeElement === this.#trigger) { this.#currentFocusIndex = -1; } // move to next option or loop to first if (this.#currentFocusIndex < options.length - 1) { this.focusOption(this.#currentFocusIndex + 1); } break; case 'ArrowUp': e.preventDefault(); // move to previous option or loop to last if (this.#currentFocusIndex > 0) { this.focusOption(this.#currentFocusIndex - 1); } else if (this.#currentFocusIndex === 0) { // if on first option, move focus back to trigger this.#trigger.focus(); this.#currentFocusIndex = -1; } break; case 'Home': e.preventDefault(); this.focusOption(0); break; case 'End': e.preventDefault(); this.focusOption(options.length - 1); break; case 'Enter': case ' ': e.preventDefault(); // if dropdown is closed and trigger is focused, open it if ( this.getAttribute('aria-hidden') === 'true' && document.activeElement === this.#trigger ) { this.show(); return; } // if focus is on an option, select it if (this.#currentFocusIndex >= 0) { this.selectOption({ target: options[this.#currentFocusIndex] }); } else if (document.activeElement === this.#trigger) { // if focus is on trigger, toggle the dropdown this.toggleDropdown(); } break; default: // handle typeahead - find option starting with pressed key const key = e.key.toLowerCase(); // only proceed if it's a single character if (key.length === 1) { // find the first option that starts with the pressed key const matchingOption = options.find((option) => option.textContent.trim().toLowerCase().startsWith(key) ); if (matchingOption) { const index = options.indexOf(matchingOption); this.focusOption(index); } } break; } } /** * focuses a specific option by index * @param {number} index - the index of the option to focus */ focusOption(index) { const options = Array.from(this.#options); // reset tabindex on all options options.forEach((opt) => { opt.setAttribute('tabindex', '-1'); }); // set tabindex on target option and focus it if (options[index]) { options[index].setAttribute('tabindex', '0'); options[index].focus(); this.#currentFocusIndex = index; } } /** * toggles the dropdown open/closed */ toggleDropdown() { if (this.getAttribute('aria-hidden') === 'true') { this.show(); } else { this.hide(); } } /** * selects an option from the dropdown * @param {Event} e - the click event */ selectOption(e) { const option = e.target.closest('dropdown-option'); if (!option) return; // update aria-selected on all options this.#options.forEach((opt) => { opt.setAttribute('aria-selected', 'false'); }); // mark selected option option.setAttribute('aria-selected', 'true'); // update the input value if (this.#input) { this.#input.value = option.dataset.value || option.textContent.trim(); } // update the visible label if (this.#label) { this.#label.textContent = option.textContent.trim(); } // dispatch change event this.dispatchEvent( new CustomEvent('change', { detail: { value: option.dataset.value || option.textContent.trim(), text: option.textContent.trim(), }, bubbles: true, }) ); // close the dropdown this.hide(); } /** * Determines if the dropdown should open upward based on available space * @private */ #determineDirection() { // If position is explicitly set, honor that const userPosition = this.getAttribute('position'); if (userPosition === 'up' || userPosition === 'down') { return userPosition; } // Calculate available space const rect = this.getBoundingClientRect(); const spaceBelow = window.innerHeight - rect.bottom; const estimatedOptionsHeight = Math.min( this.#options.length * 40, // Rough estimate of option height parseInt(getComputedStyle(this).getPropertyValue('--options-max-height') || '15rem') * 16 // Convert rem to px ); // Determine if there's not enough space below, but more space above if (spaceBelow < estimatedOptionsHeight && rect.top > estimatedOptionsHeight) { return 'up'; } // Default to down return 'down'; } /** * shows the dropdown options */ show() { // Determine direction to open const direction = this.#determineDirection(); this.setAttribute('direction', direction); // set attributes for open state this.setAttribute('aria-hidden', 'false'); this.#trigger.setAttribute('aria-expanded', 'true'); // find selected option or default to first const selectedOption = Array.from(this.#options).find( (opt) => opt.getAttribute('aria-selected') === 'true' ); if (selectedOption) { const selectedIndex = Array.from(this.#options).indexOf(selectedOption); this.focusOption(selectedIndex); } else if (this.#options.length > 0) { this.focusOption(0); } // add global event listeners document.addEventListener('click', this.#handleDocumentClick); document.addEventListener('keydown', this.#handleKeyDown); } /** * hides the dropdown options */ hide() { // set attributes for closed state this.setAttribute('aria-hidden', 'true'); this.#trigger.setAttribute('aria-expanded', 'false'); // Remove direction attribute this.removeAttribute('direction'); // reset the current focus index this.#currentFocusIndex = -1; // remove global event listeners document.removeEventListener('click', this.#handleDocumentClick); document.removeEventListener('keydown', this.#handleKeyDown); // return focus to trigger this.#trigger.focus(); } }