/* * HSDropdown * @version: 4.2.0 * @author: Preline Labs Ltd. * @license: Licensed under MIT and Preline UI Fair Use License (https://preline.co/docs/license.html) * Copyright 2024 Preline Labs Ltd. */ import { afterTransition, dispatch, getClassProperty, getClassPropertyAlt, isIOS, isIpadOS, stringToBoolean, } from '../../utils'; import { autoUpdate, computePosition, flip, offset, type Placement, type Strategy, VirtualElement, } from '@floating-ui/dom'; import { IDropdown, IHTMLElementFloatingUI } from '../dropdown/interfaces'; import HSBasePlugin from '../base-plugin'; import HSAccessibilityObserver from '../accessibility-manager'; import { ICollectionItem } from '../../interfaces'; import { IAccessibilityComponent } from '../accessibility-manager/interfaces'; import { POSITIONS } from '../../constants'; class HSDropdown extends HSBasePlugin<{}, IHTMLElementFloatingUI> implements IDropdown { private static globalListenersInitialized = false; private accessibilityComponent: IAccessibilityComponent; private readonly toggle: HTMLElement | null; private readonly closers: HTMLElement[] | null; public menu: HTMLElement | null; private eventMode: string; private closeMode: string; private hasAutofocus: boolean; private autofocusOnKeyboardOnly: boolean; private animationInProcess: boolean; private longPressTimer: number | null = null; private openedViaKeyboard: boolean = false; private onElementMouseEnterListener: () => void | null; private onElementMouseLeaveListener: () => void | null; private onToggleClickListener: (evt: Event) => void | null; private onToggleContextMenuListener: (evt: Event) => void | null; private onTouchStartListener: ((evt: TouchEvent) => void) | null = null; private onTouchEndListener: ((evt: TouchEvent) => void) | null = null; private onCloserClickListener: | { el: HTMLButtonElement; fn: () => void; }[] | null; constructor(el: IHTMLElementFloatingUI, options?: {}, events?: {}) { super(el, options, events); this.toggle = this.el.querySelector(':scope > .hs-dropdown-toggle') || this.el.querySelector( ':scope > .hs-dropdown-toggle-wrapper > .hs-dropdown-toggle', ) || (this.el.children[0] as HTMLElement); this.closers = Array.from(this.el.querySelectorAll(':scope .hs-dropdown-close')) || null; this.menu = this.el.querySelector(':scope > .hs-dropdown-menu'); this.eventMode = getClassProperty(this.el, '--trigger', 'click'); this.closeMode = getClassProperty(this.el, '--auto-close', 'true'); this.hasAutofocus = stringToBoolean( getClassProperty(this.el, '--has-autofocus', 'true') || 'true', ); this.autofocusOnKeyboardOnly = stringToBoolean( getClassProperty(this.el, '--autofocus-on-keyboard-only', 'true') || 'true', ); this.animationInProcess = false; this.onCloserClickListener = []; if (this.toggle && this.menu) this.init(); } private elementMouseEnter() { this.onMouseEnterHandler(); } private elementMouseLeave() { this.onMouseLeaveHandler(); } private toggleClick(evt: Event) { this.onClickHandler(evt); } private toggleContextMenu(evt: MouseEvent) { evt.preventDefault(); this.onContextMenuHandler(evt); } private handleTouchStart(evt: TouchEvent): void { this.longPressTimer = window.setTimeout(() => { evt.preventDefault(); const touch = evt.touches[0]; const contextMenuEvent = new MouseEvent('contextmenu', { bubbles: true, cancelable: true, view: window, clientX: touch.clientX, clientY: touch.clientY, }); if (this.toggle) this.toggle.dispatchEvent(contextMenuEvent); }, 400); } private handleTouchEnd(evt: TouchEvent): void { if (this.longPressTimer) { clearTimeout(this.longPressTimer); this.longPressTimer = null; } } private closerClick() { this.close(); } private init() { HSDropdown.ensureGlobalHandlers(); this.createCollection(window.$hsDropdownCollection, this); if ((this.toggle as HTMLButtonElement).disabled) return false; if (this.toggle) this.buildToggle(); if (this.menu) this.buildMenu(); if (this.closers) this.buildClosers(); if (!isIOS() && !isIpadOS()) { this.onElementMouseEnterListener = () => this.elementMouseEnter(); this.onElementMouseLeaveListener = () => this.elementMouseLeave(); this.el.addEventListener('mouseenter', this.onElementMouseEnterListener); this.el.addEventListener('mouseleave', this.onElementMouseLeaveListener); } if (typeof window !== 'undefined') { if (!window.HSAccessibilityObserver) { window.HSAccessibilityObserver = new HSAccessibilityObserver(); } this.setupAccessibility(); } } resizeHandler() { this.eventMode = getClassProperty(this.el, '--trigger', 'click'); this.closeMode = getClassProperty(this.el, '--auto-close', 'true'); this.hasAutofocus = stringToBoolean( getClassProperty(this.el, '--has-autofocus', 'true') || 'true', ); this.autofocusOnKeyboardOnly = stringToBoolean( getClassProperty(this.el, '--autofocus-on-keyboard-only', 'true') || 'true', ); } private isOpen(): boolean { return ( this.el.classList.contains('open') && !this.menu.classList.contains('hidden') ); } private buildToggle() { if (this?.toggle?.ariaExpanded) { if (this.el.classList.contains('open')) this.toggle.ariaExpanded = 'true'; else this.toggle.ariaExpanded = 'false'; } if (this.eventMode === 'contextmenu') { this.onToggleContextMenuListener = (evt: MouseEvent) => this.toggleContextMenu(evt); this.onTouchStartListener = this.handleTouchStart.bind(this); this.onTouchEndListener = this.handleTouchEnd.bind(this); this.toggle.addEventListener( 'contextmenu', this.onToggleContextMenuListener, ); this.toggle.addEventListener('touchstart', this.onTouchStartListener, { passive: false, }); this.toggle.addEventListener('touchend', this.onTouchEndListener); this.toggle.addEventListener('touchmove', this.onTouchEndListener); } else { this.onToggleClickListener = (evt) => this.toggleClick(evt); this.toggle.addEventListener('click', this.onToggleClickListener); } } private buildMenu() { this.menu.role = this.menu.getAttribute('role') || 'menu'; this.menu.tabIndex = -1; const checkboxes = this.menu.querySelectorAll('[role="menuitemcheckbox"]'); const radiobuttons = this.menu.querySelectorAll('[role="menuitemradio"]'); checkboxes.forEach((el: HTMLElement) => el.addEventListener('click', () => this.selectCheckbox(el)), ); radiobuttons.forEach((el: HTMLElement) => el.addEventListener('click', () => this.selectRadio(el)), ); this.menu.addEventListener('click', (evt) => { const target = evt.target as HTMLElement; if ( target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT' || target.tagName === 'BUTTON' || target.tagName === 'A' || target.closest('button') || target.closest('a') || target.closest('input') || target.closest('textarea') || target.closest('select') ) { return; } this.menu.focus(); }); } private buildClosers() { this.closers.forEach((el: HTMLButtonElement) => { this.onCloserClickListener.push({ el, fn: () => this.closerClick(), }); el.addEventListener( 'click', this.onCloserClickListener.find((closer) => closer.el === el).fn, ); }); } private getScrollbarSize() { let div = document.createElement('div'); div.style.overflow = 'scroll'; div.style.width = '100px'; div.style.height = '100px'; document.body.appendChild(div); let scrollbarSize = div.offsetWidth - div.clientWidth; document.body.removeChild(div); return scrollbarSize; } private onContextMenuHandler(evt: MouseEvent) { const virtualElement: VirtualElement = { getBoundingClientRect: () => new DOMRect(), }; virtualElement.getBoundingClientRect = () => new DOMRect(evt.clientX, evt.clientY, 0, 0); HSDropdown.closeCurrentlyOpened(); if ( this.el.classList.contains('open') && !this.menu.classList.contains('hidden') ) { this.close(); document.body.style.overflow = ''; document.body.style.paddingRight = ''; } else { document.body.style.overflow = 'hidden'; document.body.style.paddingRight = `${this.getScrollbarSize()}px`; this.open(virtualElement); } } private onClickHandler(evt: Event) { const isMouseHoverTrigger = this.eventMode === 'hover' && window.matchMedia('(hover: hover)').matches && (evt as PointerEvent).pointerType === 'mouse'; if (isMouseHoverTrigger) { const el = evt.currentTarget as HTMLElement; const isAnchor = el.tagName === 'A'; const isNavLink = isAnchor && el.hasAttribute('href') && el.getAttribute('href') !== '#'; if (!isNavLink) { evt.preventDefault(); evt.stopPropagation(); evt.stopImmediatePropagation?.(); } return false; } if ( this.el.classList.contains('open') && !this.menu.classList.contains('hidden') ) { this.close(); } else { this.open(); } } private onMouseEnterHandler() { if (this.eventMode !== 'hover') return false; if ( !this.el._floatingUI || (this.el._floatingUI && !this.el.classList.contains('open')) ) this.forceClearState(); if ( !this.el.classList.contains('open') && this.menu.classList.contains('hidden') ) { this.open(); } } private onMouseLeaveHandler() { if (this.eventMode !== 'hover') return false; if ( this.el.classList.contains('open') && !this.menu.classList.contains('hidden') ) { this.close(); } } private destroyFloatingUI() { const scope = ( window.getComputedStyle(this.el).getPropertyValue('--scope') || '' ).trim(); this.menu.classList.remove('block'); this.menu.classList.add('hidden'); this.menu.style.inset = null; this.menu.style.position = null; if (this.el && this.el._floatingUI) { this.el._floatingUI.destroy(); this.el._floatingUI = null; } if (scope === 'window') this.el.appendChild(this.menu); this.animationInProcess = false; } private focusElement() { const input: HTMLInputElement = this.menu.querySelector('[autofocus]'); if (input) { input.focus(); return true; } const menuItems = this.menu.querySelectorAll( 'a:not([hidden]), button:not([hidden]), [role="menuitem"]:not([hidden])', ); if (menuItems.length > 0) { const firstItem = menuItems[0] as HTMLElement; firstItem.focus(); return true; } return false; } private setupFloatingUI(target?: VirtualElement | HTMLElement) { const _target = target || this.el; const computedStyle = window.getComputedStyle(this.el); const placementCss = ( computedStyle.getPropertyValue('--placement') || '' ).trim(); const flipCss = (computedStyle.getPropertyValue('--flip') || 'true').trim(); const strategyCss = ( computedStyle.getPropertyValue('--strategy') || 'fixed' ).trim(); const offsetCss = ( computedStyle.getPropertyValue('--offset') || '10' ).trim(); const gpuAccelerationCss = ( computedStyle.getPropertyValue('--gpu-acceleration') || 'true' ).trim(); const adaptive = ( window.getComputedStyle(this.el).getPropertyValue('--adaptive') || 'adaptive' ).replace(' ', ''); const strategy = strategyCss as Strategy; const offsetValue = parseInt(offsetCss, 10); const placement: Placement = POSITIONS[placementCss] || 'bottom-start'; const middleware = [ ...(flipCss === 'true' ? [flip()] : []), offset(offsetValue), ]; const options = { placement, strategy, middleware, }; if (strategy === 'fixed') Object.assign(this.menu.style, { position: strategy }); const checkSpaceAndAdjust = (x: number) => { const menuRect = this.menu.getBoundingClientRect(); const viewportWidth = window.innerWidth; const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth; const availableWidth = viewportWidth - scrollbarWidth; if (x + menuRect.width > availableWidth) { x = availableWidth - menuRect.width; } if (x < 0) x = 0; return x; }; const update = () => { computePosition(_target, this.menu, options).then( ({ x, y, placement: computedPlacement }) => { const adjustedX = checkSpaceAndAdjust(x); if (strategy === 'absolute' && adaptive === 'none') { Object.assign(this.menu.style, { position: strategy, margin: '0', }); } else if (strategy === 'absolute') { Object.assign(this.menu.style, { position: strategy, transform: `translate3d(${x}px, ${y}px, 0px)`, margin: '0', }); } else { if (gpuAccelerationCss === 'true') { Object.assign(this.menu.style, { position: strategy, left: '', top: '', inset: '0px auto auto 0px', margin: '0', transform: `translate3d(${ adaptive === 'adaptive' ? adjustedX : 0 }px, ${y}px, 0)`, }); } else { Object.assign(this.menu.style, { position: strategy, left: `${x}px`, top: `${y}px`, transform: '', }); } } this.menu.setAttribute('data-placement', computedPlacement); }, ); }; update(); const cleanup = autoUpdate(_target, this.menu, update); return { update, destroy: cleanup, }; } private selectCheckbox(target: HTMLElement) { target.ariaChecked = target.ariaChecked === 'true' ? 'false' : 'true'; } private selectRadio(target: HTMLElement) { if (target.ariaChecked === 'true') return false; const group = target.closest('.group'); const items = group.querySelectorAll('[role="menuitemradio"]'); const otherItems = Array.from(items).filter((el) => el !== target); otherItems.forEach((el) => { el.ariaChecked = 'false'; }); target.ariaChecked = 'true'; } // Public methods // TODO:: rename "Popper" to "FLoatingUI" public calculatePopperPosition(target?: VirtualElement | HTMLElement) { const floatingUIInstance = this.setupFloatingUI(target); const floatingUIPosition = this.menu.getAttribute('data-placement'); floatingUIInstance.update(); floatingUIInstance.destroy(); return floatingUIPosition; } public open( target?: VirtualElement | HTMLElement, openedViaKeyboard: boolean = false, ) { if (this.el.classList.contains('open') || this.animationInProcess) { return false; } this.openedViaKeyboard = openedViaKeyboard; this.animationInProcess = true; this.menu.style.cssText = ''; const _target = target || this.el; const computedStyle = window.getComputedStyle(this.el); const scope = (computedStyle.getPropertyValue('--scope') || '').trim(); const strategyCss = ( computedStyle.getPropertyValue('--strategy') || 'fixed' ).trim(); const strategy = strategyCss as Strategy; if (scope === 'window') document.body.appendChild(this.menu); if (strategy !== ('static' as Strategy)) { this.el._floatingUI = this.setupFloatingUI(_target); } this.menu.style.margin = null; this.menu.classList.remove('hidden'); this.menu.classList.add('block'); setTimeout(() => { if (this?.toggle?.ariaExpanded) this.toggle.ariaExpanded = 'true'; this.el.classList.add('open'); if (window.HSAccessibilityObserver && this.accessibilityComponent) { window.HSAccessibilityObserver.updateComponentState( this.accessibilityComponent, true, ); } if (scope === 'window') this.menu.classList.add('open'); this.animationInProcess = false; if ( this.hasAutofocus && (!this.autofocusOnKeyboardOnly || this.openedViaKeyboard) ) this.focusElement(); this.fireEvent('open', this.el); dispatch('open.hs.dropdown', this.el, this.el); }); } public close(isAnimated = true) { if (this.animationInProcess || !this.el.classList.contains('open')) { return false; } const scope = ( window.getComputedStyle(this.el).getPropertyValue('--scope') || '' ).trim(); const clearAfterClose = () => { this.menu.style.margin = null; if (this?.toggle?.ariaExpanded) this.toggle.ariaExpanded = 'false'; this.el.classList.remove('open'); this.openedViaKeyboard = false; this.fireEvent('close', this.el); dispatch('close.hs.dropdown', this.el, this.el); }; this.animationInProcess = true; if (scope === 'window') this.menu.classList.remove('open'); if (window.HSAccessibilityObserver && this.accessibilityComponent) { window.HSAccessibilityObserver.updateComponentState( this.accessibilityComponent, false, ); } if (isAnimated) { const el: HTMLElement = this.el.querySelector('[data-hs-dropdown-transition]') || this.menu; let hasCompleted = false; const completeClose = () => { if (hasCompleted) return; hasCompleted = true; this.destroyFloatingUI(); }; afterTransition(el, completeClose); const computedStyle = window.getComputedStyle(el); const transitionDuration = computedStyle.getPropertyValue( 'transition-duration', ); const duration = parseFloat(transitionDuration) * 1000 || 150; setTimeout(completeClose, duration + 50); } else { this.destroyFloatingUI(); } clearAfterClose(); } public forceClearState() { this.destroyFloatingUI(); this.menu.style.margin = null; this.el.classList.remove('open'); this.menu.classList.add('hidden'); this.openedViaKeyboard = false; } public destroy() { // Remove listeners if (!isIOS() && !isIpadOS()) { this.el.removeEventListener( 'mouseenter', this.onElementMouseEnterListener, ); this.el.removeEventListener( 'mouseleave', () => this.onElementMouseLeaveListener, ); this.onElementMouseEnterListener = null; this.onElementMouseLeaveListener = null; } if (this.eventMode === 'contextmenu') { if (this.toggle) { this.toggle.removeEventListener( 'contextmenu', this.onToggleContextMenuListener, ); this.toggle.removeEventListener( 'touchstart', this.onTouchStartListener, ); this.toggle.removeEventListener('touchend', this.onTouchEndListener); this.toggle.removeEventListener('touchmove', this.onTouchEndListener); } this.onToggleContextMenuListener = null; this.onTouchStartListener = null; this.onTouchEndListener = null; } else { if (this.toggle) { this.toggle.removeEventListener('click', this.onToggleClickListener); } this.onToggleClickListener = null; } if (this.closers.length) { this.closers.forEach((el: HTMLButtonElement) => { el.removeEventListener( 'click', this.onCloserClickListener.find((closer) => closer.el === el).fn, ); }); this.onCloserClickListener = null; } // Remove classes this.el.classList.remove('open'); this.destroyFloatingUI(); window.$hsDropdownCollection = window.$hsDropdownCollection.filter( ({ element }) => element.el !== this.el, ); // Unregister accessibility // if (typeof window !== "undefined" && window.HSAccessibilityObserver) { // window.HSAccessibilityObserver.unregisterPlugin(this); // } } // Static methods private static findInCollection( target: HSDropdown | HTMLElement | string, ): ICollectionItem | null { return ( window.$hsDropdownCollection.find((el) => { if (target instanceof HSDropdown) return el.element.el === target.el; else if (typeof target === 'string') { return el.element.el === document.querySelector(target); } else return el.element.el === target; }) || null ); } static getInstance(target: HTMLElement | string, isInstance?: boolean) { const elInCollection = window.$hsDropdownCollection.find( (el) => el.element.el === (typeof target === 'string' ? document.querySelector(target) : target), ); return elInCollection ? isInstance ? elInCollection : elInCollection.element : null; } static autoInit() { HSDropdown.ensureGlobalHandlers(); if (window.$hsDropdownCollection) { window.$hsDropdownCollection = window.$hsDropdownCollection.filter( ({ element }) => document.contains(element.el), ); } document .querySelectorAll('.hs-dropdown:not(.--prevent-on-load-init)') .forEach((el: IHTMLElementFloatingUI) => { if ( !window.$hsDropdownCollection.find( (elC) => (elC?.element?.el as HTMLElement) === el, ) ) { new HSDropdown(el); } }); } private static ensureGlobalHandlers() { if (typeof window === 'undefined') return; if (!window.$hsDropdownCollection) window.$hsDropdownCollection = []; if (HSDropdown.globalListenersInitialized) return; HSDropdown.globalListenersInitialized = true; window.addEventListener('click', (evt) => { const evtTarget = evt.target; HSDropdown.closeCurrentlyOpened(evtTarget as HTMLElement); }); let prevWidth = window.innerWidth; window.addEventListener('resize', () => { if (window.innerWidth !== prevWidth) { prevWidth = innerWidth; HSDropdown.closeCurrentlyOpened(null, false); } }); } static open( target: HSDropdown | HTMLElement | string, openedViaKeyboard: boolean = false, ) { const instance = HSDropdown.findInCollection(target); if (instance && instance.element.menu.classList.contains('hidden')) instance.element.open(undefined, openedViaKeyboard); } static close(target: HSDropdown | HTMLElement | string) { const instance = HSDropdown.findInCollection(target); if (instance && !instance.element.menu.classList.contains('hidden')) instance.element.close(); } static closeCurrentlyOpened( evtTarget: HTMLElement | null = null, isAnimated = true, ) { const parent = evtTarget && evtTarget.closest('.hs-dropdown') && evtTarget.closest('.hs-dropdown').parentElement.closest('.hs-dropdown') ? evtTarget .closest('.hs-dropdown') .parentElement.closest('.hs-dropdown') : null; let currentlyOpened = parent ? window.$hsDropdownCollection.filter( (el) => el.element.el.classList.contains('open') && el.element.menu .closest('.hs-dropdown') .parentElement.closest('.hs-dropdown') === parent, ) : window.$hsDropdownCollection.filter((el) => el.element.el.classList.contains('open'), ); if (evtTarget) { const dropdownElement = evtTarget.closest('.hs-dropdown') as HTMLElement; if (dropdownElement) { if (getClassPropertyAlt(dropdownElement, '--auto-close') === 'inside') { currentlyOpened = currentlyOpened.filter( (el) => el.element.el !== dropdownElement, ); } } else { const dropdownMenu = evtTarget.closest('.hs-dropdown-menu'); if (dropdownMenu) { const originalDropdown = window.$hsDropdownCollection.find( (item) => item.element.menu === dropdownMenu, ); if ( originalDropdown && getClassPropertyAlt(originalDropdown.element.el, '--auto-close') === 'inside' ) { currentlyOpened = currentlyOpened.filter( (el) => el.element.el !== originalDropdown.element.el, ); } } } } if (currentlyOpened) { currentlyOpened.forEach((el) => { if ( el.element.closeMode === 'false' || el.element.closeMode === 'outside' ) { return false; } el.element.close(isAnimated); }); } if (currentlyOpened) { currentlyOpened.forEach((el) => { if (getClassPropertyAlt(el.element.el, '--trigger') !== 'contextmenu') { return false; } document.body.style.overflow = ''; document.body.style.paddingRight = ''; }); } } // Accessibility methods private setupAccessibility(): void { this.accessibilityComponent = window.HSAccessibilityObserver.registerComponent( this.el, { onEnter: () => { const active = document.activeElement as HTMLElement | null; if (!active) return; const menuRoot = active.closest('.hs-dropdown-menu'); if (menuRoot) { const submenuToggle = active.closest( '.hs-dropdown-toggle, [data-hs-dropdown-toggle]', ); if (submenuToggle) { submenuToggle.click(); return; } const item = active.closest( '[role="menuitem"], a, button, [data-hs-dropdown-item]', ); if (item?.children?.length > 0) { Array.from(item.children).forEach((child) => { if ( child.matches( "input[type='checkbox']:not([hidden]), input[type='radio']:not([hidden])", ) ) (child as HTMLElement).click(); }); item.focus(); return; } else if (item) { if (active.matches('input, textarea, select')) return; item.click(); } return; } if (!this.isOpened()) this.open(undefined, true); else this.close(); }, onSpace: () => { if (!this.isOpened()) this.open(undefined, true); }, onEsc: () => { if (this.isOpened()) { this.close(); if (this.toggle) this.toggle.focus(); } }, onArrow: (evt: KeyboardEvent) => { if (evt.metaKey) return; switch (evt.key) { case 'ArrowDown': if (!this.isOpened()) this.open(undefined, true); else this.focusMenuItem('next'); break; case 'ArrowUp': if (this.isOpened()) this.focusMenuItem('prev'); break; case 'ArrowRight': this.onArrowX(evt, 'right'); break; case 'ArrowLeft': this.onArrowX(evt, 'left'); break; } }, onHome: () => { if (this.isOpened()) this.onStartEnd(true); }, onEnd: () => { if (this.isOpened()) this.onStartEnd(false); }, onTab: () => { setTimeout(() => { const active = document.activeElement as HTMLElement | null; const menuRoot = active.closest('.hs-dropdown-menu'); if (active && menuRoot) { const submenuToggle = active.closest( '.hs-dropdown-toggle, [data-hs-dropdown-toggle]', ); if (submenuToggle) { submenuToggle.click(); return; } active.focus(); return; } else if (this.isOpened()) this.close(); else return; }, 100); }, onFirstLetter: (key: string) => { const active = document.activeElement as HTMLElement | null; const isInput = active?.matches('input, textarea'); if (!isInput && this.isOpened()) this.onFirstLetter(key); }, }, this.isOpened(), 'Dropdown', '.hs-dropdown', this.menu, { onFirstLetter: false, }, ); } private onFirstLetter(key: string): void { if (!this.isOpened() || !this.menu) return; const menuItems = this.menu.querySelectorAll( 'a:not([hidden]), button:not([hidden]), [role="menuitem"]:not([hidden])', ); if (menuItems.length === 0) return; const currentIndex = Array.from(menuItems).indexOf( document.activeElement as HTMLElement, ); for (let i = 1; i <= menuItems.length; i++) { const index = (currentIndex + i) % menuItems.length; const text = (menuItems[index] as HTMLElement).textContent?.trim().toLowerCase() || ''; if (text.startsWith(key.toLowerCase())) { (menuItems[index] as HTMLElement).focus(); return; } } (menuItems[0] as HTMLElement).focus(); } private onArrowX(evt: KeyboardEvent, direction: 'left' | 'right'): void { if (!this.isOpened()) return; evt.preventDefault(); evt.stopImmediatePropagation(); const menuItems = this.menu.querySelectorAll( 'a:not([hidden]), button:not([hidden]), [role="menuitem"]:not([hidden])', ); if (!menuItems.length) return; const currentIndex = Array.from(menuItems).indexOf( document.activeElement as HTMLElement, ); let nextIndex = -1; if (direction === 'right') { nextIndex = (currentIndex + 1) % menuItems.length; } else { nextIndex = currentIndex > 0 ? currentIndex - 1 : menuItems.length - 1; } (menuItems[nextIndex] as HTMLElement).focus(); } private onStartEnd(toStart: boolean = true): void { if (!this.isOpened()) return; const menuItems = this.menu.querySelectorAll( 'a:not([hidden]), button:not([hidden]), [role="menuitem"]:not([hidden])', ); if (!menuItems.length) return; const index = toStart ? 0 : menuItems.length - 1; (menuItems[index] as HTMLElement).focus(); } private focusMenuItem(direction: 'next' | 'prev'): void { const menuItems = this.menu.querySelectorAll( 'a:not([hidden]), button:not([hidden]), [role="menuitem"]:not([hidden])', ); if (!menuItems.length) return; const currentIndex = Array.from(menuItems).indexOf( document.activeElement as HTMLElement, ); const nextIndex = direction === 'next' ? (currentIndex + 1) % menuItems.length : (currentIndex - 1 + menuItems.length) % menuItems.length; (menuItems[nextIndex] as HTMLElement).focus(); } // Backward compatibility static on( evt: string, target: HSDropdown | HTMLElement | string, cb: Function, ) { const instance = HSDropdown.findInCollection(target); if (instance) instance.element.events[evt] = cb; } public isOpened(): boolean { return this.isOpen(); } public containsElement(element: HTMLElement): boolean { return this.el.contains(element); } } export default HSDropdown;