@vanillawc/wc-menu-wrapper
Version:
A web component that wraps HTML elements and forms a drop-down menu out of them.
273 lines (241 loc) • 8.73 kB
JavaScript
export class Custommenu extends HTMLElement {
constructor () {
super()
this.isInitialized = false
this.position = 'bottom'
this.direction = 'down'
this.mode = 'click' // changing this after init has no effect
this.closeSubmenusOnClosing = false // event based closing, onblur based closing
this.closeSubmenusOnHeadingClick = false // heading click based closing
this.menuIsOpen = false
this._submenuArray = []
this.initStateOpen = false
this.closingDelay = 500
this.item = 'item'
this.heading = 'heading'
}
static get observedAttributes () {
return ['position',
'direction',
'mode',
'init-state-open',
'closing-delay',
'close-submenus-on-closing',
'close-submenus-on-heading-click',
'heading-class',
'item',
'heading']
}
disconnectedCallback () {
}
checkAttributeValue (newValue, validValue_1, validValue_2) {
if (newValue !== validValue_1 && newValue !== validValue_2) {
throw `Invalid attribute value: ${newValue}`
}
}
attributeChangedCallback (name, oldValue, newValue) {
if (name === 'position') {
this.checkAttributeValue(newValue, 'bottom', 'right')
this.position = newValue
} else if (name === 'direction') {
this.checkAttributeValue(newValue, 'down', 'right')
this.direction = newValue
} else if (name === 'mode') {
this.checkAttributeValue(newValue, 'click', 'hover')
this.mode = newValue
} else if (name === 'init-state-open') {
this.initStateOpen = true
} else if (name === 'closing-delay') {
this.closingDelay = Number(newValue)
} else if (name === 'close-submenus-on-closing') {
this.closeSubmenusOnClosing = true
} else if (name === 'close-submenus-on-heading-click') {
this.closeSubmenusOnHeadingClick = true
} else if (name === 'heading-class') {
this.headingClass = newValue
} else if (name === 'item') {
this.item = newValue
} else if (name === 'heading') {
this.heading = newValue
}
}
connectedCallback () {
setTimeout(() => { this._init() }, 0) // https://stackoverflow.com/questions/58676021/accessing-custom-elements-child-element-without-using-slots-shadow-dom
}
_addEntry (entry, index) {
entry.style.display = 'flex'
entry.tabIndex = 0
entry.onblur = this._onBlurHandler
entry.onblur = entry.onblur.bind(this)
let subentries = entry.querySelectorAll('*').entries()
let subentry = subentries.next()
while (subentry.done === false) {
subentry.value[1].onblur = this._onBlurHandler
subentry.value[1].onblur = subentry.value[1].onblur.bind(this)
subentry = subentries.next()
}
let menu = entry.querySelector('wc-menu-wrapper')
if (menu) {
this._submenuArray.push(menu)
}
if (this._number_is_valid(index)) {
this.itemsContainer.insertBefore(entry, this.itemsContainer.children[index])
} else {
this.itemsContainer.appendChild(entry)
}
}
_init (checkRecursion) {
// is this correct place to check?
if (this.isInitialized) {
return
}
let entries = this.querySelectorAll('.' + this.item).entries()
let entry = entries.next()
if (entry.done === true) {
let menuContent = this.querySelector('wc-menu-wrapper > *')
if (menuContent === null) { return }
if (checkRecursion) { throw 'No valid menu items found!' }
const template = document.createElement('template')
template.innerHTML = menuContent.menuContent
this.appendChild(template.content)
this._init(true)
return
}
this.isInitialized = true
this.itemsContainer = this.appendChild(document.createElement('div'))
this.itemsContainer.style.display = 'none'
this.itemsContainer.style.position = 'absolute'
while (entry.done === false) {
if (entry.value[1].parentElement.isSameNode(this)) {
this._addEntry(entry.value[1])
}
entry = entries.next()
}
this.headingElem = this.querySelector('.' + this.heading)
this.headingElem.tabIndex = 0
// Q: should this be flex? otherwise element width "overflows"
// A: if it's flex, position right breaks
this.headingElem.style.display = 'inline-flex'
if (this.mode === 'click') {
this.headingElem.onclick = this._headingOnClickHandler
this.headingElem.onclick = this.headingElem.onclick.bind(this)
this.headingElem.onblur = this._onBlurHandler
this.headingElem.onblur = this.headingElem.onblur.bind(this)
} else if (this.mode === 'hover') {
this.headingElem.onmouseenter = this._mouseEnterHandler
this.headingElem.onmouseenter = this.headingElem.onmouseenter.bind(this)
this.headingElem.onmouseleave = this._mouseLeaveHandler
this.headingElem.onmouseleave = this.headingElem.onmouseleave.bind(this)
this.itemsContainer.onmouseenter = this._mouseEnterHandler
this.itemsContainer.onmouseenter = this.itemsContainer.onmouseenter.bind(this)
this.itemsContainer.onmouseleave = this._mouseLeaveHandler
this.itemsContainer.onmouseleave = this.itemsContainer.onmouseleave.bind(this)
}
this.headingElem.onkeydown = this._keyDownEventHandler
this.headingElem.onkeydown = this.headingElem.onkeydown.bind(this)
this.appendChild(this.headingElem)
this.appendChild(this.itemsContainer)
this.style.display = 'block'
this.addEventListener('rootMenuClose', this._rootMenuCloseHandler, true)
this.addEventListener('menuClose', this._menuCloseHandler, false)
if (this.initStateOpen) {
this._openMenu()
}
}
_keyDownEventHandler (event) {
if (event.key === 'Enter') {
this._headingOnClickHandler()
}
}
_menuCloseHandler (e) {
e.stopPropagation()
this._closeMenu(this.closeSubmenusOnClosing)
this.headingElem.focus()
}
_rootMenuCloseHandler (e) {
let closeSubmenus = true
if (!this.closeSubmenusOnClosing) {
e.stopPropagation()
closeSubmenus = false
}
this._closeMenu(closeSubmenus)
}
addItem (element, index) {
if (this.isInitialized) {
this._addEntry(element, index)
return true
}
return false
}
deleteItem (param) {
if (this.isInitialized) {
let itemToBeRemoved = null
if (param === undefined) {
itemToBeRemoved = this.itemsContainer.childNodes.length > 0 ? this.itemsContainer.childNodes[this.itemsContainer.childNodes.length - 1] : null
} else if (typeof param === 'string') {
itemToBeRemoved = this.querySelector('#' + param)
} else if (typeof param === 'number') {
itemToBeRemoved = this.itemsContainer.childNodes[param]
}
if (itemToBeRemoved !== null) {
this.itemsContainer.removeChild(itemToBeRemoved)
}
return true
}
return false
}
_onBlurHandler () {
// focus will be on body unless following (pseudo)delay is introduced:
setTimeout(() => { checkActiveElement(this) }, 0)
function checkActiveElement (ctx) {
let contains = false
if (ctx._rootMenu) { contains = ctx._rootMenu.contains(document.activeElement) }
if (!ctx.contains(document.activeElement) && !contains) {
ctx.dispatchEvent(new CustomEvent('rootMenuClose'))
}
}
}
_closeMenu (closeSubmenus) {
if (this.menuIsOpen) {
this.itemsContainer.style.display = 'none'
this.menuIsOpen = false
if (closeSubmenus) {
this._submenuArray.forEach(x => { x._closeMenu(true) })
}
if (this.headingClass) { this.headingElem.classList.remove(this.headingClass) }
}
}
_openMenu () {
if (!this.menuIsOpen) {
this.itemsContainer.style.display = this.position === 'bottom' ? 'flex' : 'inline-flex'
this.menuIsOpen = true
this._submenuArray.forEach(x => { x._passRootMenu(this._rootMenu === undefined ? this : this._rootMenu) })
if (this.headingClass) { this.headingElem.classList.add(this.headingClass) }
this.itemsContainer.style.flexDirection = this.direction === 'right' ? 'row' : 'column'
}
}
_passRootMenu (parent) {
this._rootMenu = parent
}
_headingOnClickHandler () {
if (!this.menuIsOpen) {
this._openMenu()
} else {
this._closeMenu(this.closeSubmenusOnHeadingClick)
}
}
_mouseEnterHandler () {
clearTimeout(this._timerId)
this._openMenu()
}
_mouseLeaveHandler () {
this._timerId = setTimeout(() => { this._closeMenu() }, this.closingDelay)
}
_number_is_valid (n) {
if (isNaN(n) || typeof n !== 'number' || n < 0) {
return false
}
return true
}
}
customElements.define('wc-menu-wrapper', Custommenu)