const smMenu = document.createElement('template') smMenu.innerHTML = `
`; customElements.define('sm-menu', class extends HTMLElement { constructor() { super() this.attachShadow({ mode: 'open' }).append(smMenu.content.cloneNode(true)) this.isOpen = false; this.availableOptions this.containerDimensions this.animOptions = { duration: 200, easing: 'ease' } this.optionList = this.shadowRoot.querySelector('.options') this.menu = this.shadowRoot.querySelector('.menu') this.icon = this.shadowRoot.querySelector('.icon') this.expand = this.expand.bind(this) this.collapse = this.collapse.bind(this) this.toggle = this.toggle.bind(this) this.handleKeyDown = this.handleKeyDown.bind(this) this.handleClick = this.handleClick.bind(this) this.handleClickOutside = this.handleClickOutside.bind(this) } static get observedAttributes() { return ['value'] } get value() { return this.getAttribute('value') } set value(val) { this.setAttribute('value', val) } expand() { if (!this.isOpen) { this.optionList.classList.remove('hide') this.optionList.animate([ { transform: 'translateY(-1rem)', opacity: '0' }, { transform: 'none', opacity: '1' }, ], this.animOptions) .onfinish = () => { this.isOpen = true this.icon.classList.add('focused') document.addEventListener('mousedown', this.handleClickOutside) const firstElement = this.optionList.firstElementChild.assignedElements().find(el => el.tagName === 'MENU-OPTION') if (firstElement) { firstElement.focus() } } } } collapse() { if (this.isOpen) { this.optionList.animate([ { transform: 'none', opacity: '1' }, { transform: 'translateY(-1rem)', opacity: '0' }, ], this.animOptions) .onfinish = () => { this.isOpen = false this.icon.classList.remove('focused') this.optionList.classList.add('hide') document.removeEventListener('mousedown', this.handleClickOutside) } } } toggle() { if (!this.isOpen) { this.expand() } else { this.collapse() } } handleClick(e) { if (e.target === this) { this.toggle() } else { this.collapse() } } handleKeyDown(e) { // If key is pressed on menu button if (e.target === this) { if (e.key === 'ArrowDown') { e.preventDefault() this.availableOptions[0].focus() } else if (e.key === ' ') { e.preventDefault() this.toggle() } } else { // If key is pressed over menu options if (e.key === 'ArrowUp') { e.preventDefault() if (document.activeElement.previousElementSibling) { document.activeElement.previousElementSibling.focus() } else { this.availableOptions[this.availableOptions.length - 1].focus() } } else if (e.key === 'ArrowDown') { e.preventDefault() if (document.activeElement.nextElementSibling) { document.activeElement.nextElementSibling.focus() } else { this.availableOptions[0].focus() } } else if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() e.target.click() this.collapse() this.menu.focus() } } } handleClickOutside(e) { if (!this.contains(e.target) && e.button !== 2) { this.collapse() } } connectedCallback() { this.setAttribute('role', 'listbox') this.setAttribute('aria-label', 'dropdown menu') const slot = this.shadowRoot.querySelector('.options slot') slot.addEventListener('slotchange', e => { this.availableOptions = e.target.assignedElements() this.containerDimensions = this.optionList.getBoundingClientRect() }); this.addEventListener('click', this.handleClick) this.addEventListener('keydown', this.handleKeyDown) } disconnectedCallback() { this.removeEventListener('click', this.handleClick) this.removeEventListener('keydown', this.handleKeyDown) document.removeEventListener('mousedown', this.handleClickOutside) } }) // option const menuOption = document.createElement('template') menuOption.innerHTML = `
`; customElements.define('menu-option', class extends HTMLElement { constructor() { super() this.attachShadow({ mode: 'open' }).append(menuOption.content.cloneNode(true)) } connectedCallback() { this.setAttribute('role', 'option') this.setAttribute('tabindex', '0') this.addEventListener('keyup', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() this.click() } }) } })