@magic-spells/dropdown-select
Version:
Accessible custom dropdown web component.
554 lines (465 loc) • 19.1 kB
JavaScript
'use strict';
function styleInject(css, ref) {
if ( ref === void 0 ) ref = {};
var insertAt = ref.insertAt;
if (!css || typeof document === 'undefined') { return; }
var head = document.head || document.getElementsByTagName('head')[0];
var style = document.createElement('style');
style.type = 'text/css';
if (insertAt === 'top') {
if (head.firstChild) {
head.insertBefore(style, head.firstChild);
} else {
head.appendChild(style);
}
} else {
head.appendChild(style);
}
if (style.styleSheet) {
style.styleSheet.cssText = css;
} else {
style.appendChild(document.createTextNode(css));
}
}
var css_248z = ":root {\n --select-dropdown-width: 300px;\n --select-options-max-height: 15rem;\n --select-caret-size: 0.25rem;\n --select-font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, Ubuntu, Cantarell, Open Sans, Helvetica Neue, sans-serif;\n --select-font-size-base: 1rem;\n --select-line-height: 1.5;\n --select-color-text: #333;\n --select-color-background: #fff;\n --select-color-border: #ddd;\n --select-color-border-hover: #aaa;\n --select-color-border-dark: #666;\n --select-color-primary: #4299e1;\n --select-color-primary-rgb: 66, 153, 225;\n --select-color-hover: #f0f0f0;\n --select-color-focus: #e6f7ff;\n --select-color-selected: #e6f7ff;\n --select-spacing-xs: 0.25rem;\n --select-spacing-sm: 0.75rem;\n --select-spacing-md: 1rem;\n --select-border-radius: 0.25rem;\n --select-box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);\n --select-z-index-dropdown: 10;\n --select-transition-duration: 0.2s;\n}\n\n/* dropdown component styles */\ndropdown-select {\n position: relative;\n width: var(--select-dropdown-width);\n margin-bottom: var(--select-spacing-md);\n display: block;\n font-family: var(--select-font-family);\n font-size: var(--select-font-size-base);\n line-height: var(--select-line-height);\n color: var(--select-color-text);\n box-sizing: border-box;\n}\n\ndropdown-select * {\n box-sizing: border-box;\n}\n\n/* hide dropdown when aria-hidden=\"true\" */\ndropdown-select[aria-hidden=true] dropdown-options {\n display: none;\n}\n\n/* trigger button styles */\ndropdown-select-trigger {\n display: flex;\n justify-content: space-between;\n align-items: center;\n width: 100%;\n padding: var(--select-spacing-sm) var(--select-spacing-md);\n background-color: var(--select-color-background);\n border: 1px solid var(--select-color-border);\n border-radius: var(--select-border-radius);\n cursor: pointer;\n transition: border-color var(--select-transition-duration), box-shadow var(--select-transition-duration);\n}\ndropdown-select-trigger:hover {\n border-color: var(--select-color-border-hover);\n}\ndropdown-select-trigger:focus {\n outline: none;\n border-color: var(--select-color-primary);\n box-shadow: 0 0 0 3px rgba(var(--select-color-primary-rgb, 66, 153, 225), 0.25);\n}\n\n/* caret icon */\n.dropdown-caret {\n border-style: solid;\n border-width: var(--select-caret-size) var(--select-caret-size) 0;\n border-color: var(--select-color-border-dark) transparent transparent;\n margin-left: var(--select-spacing-sm);\n transition: transform var(--select-transition-duration);\n}\n\n/* Flipped caret when expanded (default direction) */\ndropdown-select:not([direction=up]) dropdown-select-trigger[aria-expanded=true] .dropdown-caret {\n transform: rotate(180deg);\n}\n\n/* Already flipped caret for upward dropdowns when closed */\ndropdown-select[direction=up] .dropdown-caret {\n transform: rotate(180deg);\n border-width: 0 var(--select-caret-size) var(--select-caret-size);\n border-color: transparent transparent var(--select-color-border-dark);\n}\n\n/* Flip back to normal when expanded for upward dropdowns */\ndropdown-select[direction=up] dropdown-select-trigger[aria-expanded=true] .dropdown-caret {\n transform: rotate(0);\n}\n\n/* options container */\ndropdown-options {\n position: absolute;\n left: 0;\n width: 100%;\n max-height: var(--select-options-max-height);\n overflow-y: auto;\n background-color: var(--select-color-background);\n border: 1px solid var(--select-color-border);\n border-radius: var(--select-border-radius);\n box-shadow: var(--select-box-shadow);\n z-index: var(--select-z-index-dropdown);\n}\n\n/* Default direction (down) */\ndropdown-select:not([direction=up]) dropdown-options {\n top: calc(100% + var(--select-spacing-xs));\n}\n\n/* Direction up */\ndropdown-select[direction=up] dropdown-options {\n bottom: calc(100% + var(--select-spacing-xs));\n}\n\n/* Add some elevation for upward direction to create nice shadow */\ndropdown-select[direction=up] dropdown-options {\n box-shadow: 0 -4px 6px rgba(0, 0, 0, 0.1);\n}\n\n/* styling for the list */\n.dropdown-list {\n list-style: none;\n padding: 0;\n margin: 0;\n}\n\n/* option items */\ndropdown-option {\n padding: var(--select-spacing-sm) var(--select-spacing-md);\n cursor: pointer;\n transition: background-color var(--select-transition-duration);\n display: block;\n}\ndropdown-option:hover {\n background-color: var(--select-color-hover);\n}\ndropdown-option:focus {\n outline: none;\n background-color: var(--select-color-focus);\n box-shadow: inset 0 0 0 2px var(--select-color-primary);\n}\ndropdown-option[aria-selected=true] {\n background-color: var(--select-color-selected);\n font-weight: 500;\n}\n\n/* hidden input */\n.dropdown-hidden-input {\n position: absolute;\n opacity: 0;\n height: 0;\n width: 0;\n}";
styleInject(css_248z);
/**
* dropdown-select component that handles the functionality of a custom dropdown
* @class DropdownSelect
* @extends HTMLElement
*/
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();
}
}
/**
* dropdown-select-trigger component
* @class DropdownSelectTrigger
* @extends HTMLElement
*/
class DropdownSelectTrigger extends HTMLElement {
#handleKeyDown;
#handleClick;
constructor() {
super();
// Make the trigger focusable
this.setAttribute('tabindex', '0');
this.#handleKeyDown = this.#onKeyDown.bind(this);
this.#handleClick = this.#onClick.bind(this);
}
connectedCallback() {
// Add caret if not present
if (!this.querySelector('.dropdown-caret')) {
const caret = document.createElement('span');
caret.className = 'dropdown-caret';
this.appendChild(caret);
}
// Add event listeners
this.addEventListener('keydown', this.#handleKeyDown);
this.addEventListener('click', this.#handleClick);
}
disconnectedCallback() {
this.removeEventListener('keydown', this.#handleKeyDown);
this.removeEventListener('click', this.#handleClick);
}
/**
* Handle keydown events on the trigger
* @param {KeyboardEvent} e - The keyboard event
* @private
*/
#onKeyDown(e) {
// Handle Enter and Space key presses
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation(); // Prevent event bubbling
this.#toggleDropdown();
}
}
/**
* Handle click events on the trigger
* @param {MouseEvent} e - The mouse event
* @private
*/
#onClick(e) {
this.#toggleDropdown();
}
/**
* Toggle the parent dropdown
* @private
*/
#toggleDropdown() {
const dropdown = this.closest('dropdown-select');
if (dropdown && typeof dropdown.toggleDropdown === 'function') {
dropdown.toggleDropdown();
}
}
}
/**
* dropdown-options component
* @class DropdownOptions
* @extends HTMLElement
*/
class DropdownOptions extends HTMLElement {
constructor() {
super();
}
}
/**
* dropdown-option component
* @class DropdownOption
* @extends HTMLElement
*/
class DropdownOption extends HTMLElement {
#handleClick;
constructor() {
super();
this.#handleClick = this.#onClick.bind(this);
}
connectedCallback() {
// Add click event listener
this.addEventListener('click', this.#handleClick);
}
disconnectedCallback() {
// Clean up event listener
this.removeEventListener('click', this.#handleClick);
}
/**
* Handle click events on the option
* @param {MouseEvent} e - The mouse event
* @private
*/
#onClick(e) {
e.preventDefault();
this.#notifySelection();
}
/**
* Notify the parent dropdown that this option was selected
* @private
*/
#notifySelection() {
const dropdown = this.closest('dropdown-select');
if (dropdown && typeof dropdown.selectOption === 'function') {
dropdown.selectOption({ target: this });
}
}
}
/**
* @file Main entry point for dropdown-select web component
* @author Cory Schulz
* @version 0.1.0
*/
// define custom elements if not already defined
if (!customElements.get('dropdown-select')) {
customElements.define('dropdown-select', DropdownSelect);
}
if (!customElements.get('dropdown-select-trigger')) {
customElements.define('dropdown-select-trigger', DropdownSelectTrigger);
}
if (!customElements.get('dropdown-options')) {
customElements.define('dropdown-options', DropdownOptions);
}
if (!customElements.get('dropdown-option')) {
customElements.define('dropdown-option', DropdownOption);
}
exports.DropdownOption = DropdownOption;
exports.DropdownOptions = DropdownOptions;
exports.DropdownSelect = DropdownSelect;
exports.DropdownSelectTrigger = DropdownSelectTrigger;