flyonui
Version:
The easiest, free and open-source Tailwind CSS component library with semantic classes.
921 lines (727 loc) • 28.8 kB
text/typescript
/*
* HSDropdown
* @version: 3.2.2
* @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 './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 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 > .dropdown-toggle') ||
this.el.querySelector(':scope > .dropdown-toggle-wrapper > .dropdown-toggle') ||
(this.el.children[0] as HTMLElement)
this.closers = Array.from(this.el.querySelectorAll(':scope .dropdown-close')) || null
this.menu = this.el.querySelector(':scope > .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() {
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', () => {
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 currentTrigger = getClassProperty(this.el, '--trigger', 'click')
const isMouseHoverTrigger =
currentTrigger === '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() {
const currentTrigger = getClassProperty(this.el, '--trigger', 'click')
if (currentTrigger !== '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() {
const currentTrigger = getClassProperty(this.el, '--trigger', 'click')
if (currentTrigger !== '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]), .dropdown-item'
)
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') || '6').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
}
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 }: { x: number; y: number; placement: Placement }) => {
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
public calculateFloatingUIPosition(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.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.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-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<HSDropdown> | 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() {
if (!window.$hsDropdownCollection) {
window.$hsDropdownCollection = []
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)
}
})
}
if (window.$hsDropdownCollection) {
window.$hsDropdownCollection = window.$hsDropdownCollection.filter(({ element }) => document.contains(element.el))
}
document.querySelectorAll('.dropdown:not(.--prevent-on-load-init)').forEach((el: IHTMLElementFloatingUI) => {
if (!window.$hsDropdownCollection.find(elC => (elC?.element?.el as HTMLElement) === el)) {
new HSDropdown(el)
}
})
}
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('.dropdown') && evtTarget.closest('.dropdown').parentElement.closest('.dropdown')
? evtTarget.closest('.dropdown').parentElement.closest('.dropdown')
: null
let currentlyOpened = parent
? window.$hsDropdownCollection.filter(
el =>
el.element.el.classList.contains('open') &&
el.element.menu.closest('.dropdown').parentElement.closest('.dropdown') === parent
)
: window.$hsDropdownCollection.filter(el => el.element.el.classList.contains('open'))
if (evtTarget) {
const dropdownElement = evtTarget.closest('.dropdown') as HTMLElement
if (dropdownElement) {
if (getClassPropertyAlt(dropdownElement, '--auto-close') === 'inside') {
currentlyOpened = currentlyOpened.filter(el => el.element.el !== dropdownElement)
}
} else {
const dropdownMenu = evtTarget.closest('.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)
}
}
}
}
// This is added in FlyonUI
if (
evtTarget &&
evtTarget.closest('.dropdown') &&
getClassPropertyAlt(evtTarget.closest('.dropdown'), '--auto-close') === 'outside'
) {
currentlyOpened = currentlyOpened.filter(el => el.element.el == evtTarget.closest('.dropdown'))
currentlyOpened.forEach(el => el.element.close(isAnimated))
}
// This condition work when true.
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: () => {
if (!this.isOpened()) this.open(undefined, true)
},
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: () => {
if (this.isOpened()) this.close()
},
onFirstLetter: (key: string) => {
if (this.isOpened()) this.onFirstLetter(key)
}
},
this.isOpened(),
'Dropdown',
'.dropdown',
this.menu
)
}
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]), .dropdown-item'
)
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]), .dropdown-item'
)
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]), .dropdown-item'
)
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]), .dropdown-item'
)
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)
}
}
declare global {
interface Window {
HSDropdown: Function
$hsDropdownCollection: ICollectionItem<HSDropdown>[]
}
}
window.addEventListener('load', () => {
HSDropdown.autoInit()
// Uncomment for debug
// console.log('Dropdown collection:', window.$hsDropdownCollection);
})
window.addEventListener('resize', () => {
if (!window.$hsDropdownCollection) window.$hsDropdownCollection = []
window.$hsDropdownCollection.forEach(el => el.element.resizeHandler())
})
if (typeof window !== 'undefined') {
window.HSDropdown = HSDropdown
}
export default HSDropdown