Dropdown menu

I am focusing on the mobile portion of a menu, basically mimicking a bootstrap menu I already have in place.

Brains are a bit frazzled at the moment, so not sure if I am missing a trick or two. I have done a few of these over the years, but don’t remember having to take into account transitions and reflows.

Any feedback or decent resources would be appreciated.

const getElem = document.querySelector.bind(document)

const propChange = (elem, prop, value) => {
  // override transition
  elem.classList.add('removeTransition')
  // assign property
  window.requestAnimationFrame(() => (elem.style[prop] = value))
  // force reflow
  window.getComputedStyle(elem).opacity
  // reassign transition
  elem.classList.remove('removeTransition')
}

const expanded = event => {
  const elem = event.currentTarget

  elem.removeEventListener('transitionend', expanded, false)
  // Set to 'auto'. Container needs to be able to expand with nested menu
  elem.style.height = 'auto'
  elem.dataset.collapsed = 'false'
}

const expand = elem => {
  elem.style.height = `${elem.scrollHeight}px`
  elem.addEventListener('transitionend', expanded, false)
}

const collapsed = event => {
  const elem = event.currentTarget

  elem.removeEventListener('transitionend', collapsed, false)
  elem.dataset.collapsed = 'true'
}

const collapse = elem => {
  // need expanded height first
  elem.style.height = `${elem.scrollHeight}px`
  propChange(elem, 'height', '0px')
  elem.addEventListener('transitionend', collapsed, false)
}

document.addEventListener('DOMContentLoaded', event => {
  const menuButton = getElem('#menubutton')
  const dropdownButton = getElem('#dropdown button')
  const menu = getElem('#menu')

  menuButton.addEventListener('click', event => (
    menu.dataset.collapsed === 'true' ? expand(menu) : collapse(menu)
  ))

  dropdownButton.addEventListener('click', event => {
    const dropdown = event.currentTarget.parentElement
    dropdown.classList.toggle('open')
  })
})

Codepen here

Thanks

Well reading through the somewhat abridged DOM section in Javascript the Definitive Guide (7th Edition), and came across an addEventListener property I had either forgotten about or just not given much thought about in the past 'once’

Example

element.addEventListener('click', handler, {useCapture: false, once: true})

If you only want an event to be triggered once, setting this property to true removes the need to add a removeEventListener to that element.

A quick test codepen

<!DOCTYPE html>
<html lang='en'>
<head>
  <meta charset='UTF-8'>
  <meta name='viewport' content='width=device-width, initial-scale=1.0'>
  <title>Document</title>
  <style>
    .box {
      background-color: red;
      margin: 50px 100px;
      width: 200px;
      height: 200px;
      transition: transform .5s ease-in-out;
    }
  </style>
</head>
<body>
  <button class='clickme'>Rotate</button>
  <div class='box'></div>
<script>
const getElem = document.querySelector.bind(document)
const box = getElem('.box')

document.addEventListener('DOMContentLoaded', function(event) {
  const button = getElem('.clickme')
  let deg = 0

  button.addEventListener('click', function(event) {
    box.style.transform = `rotate(${deg += 45}deg)`
  })

  box.addEventListener('transitionend', function rotated (event) {
    console.log(`Rotated ${deg} degrees`)
  }, {useCapture: false, once: true}) // note setting once to 'true' will carry on logging for each click
})
</script>
</body>
</html>

In the chrome console you can test for this with getEventListeners(box)

Initial result

getEventListeners(box)
{transitionend: Array(1)}
  transitionend: Array(1)
  0: {useCapture: false, passive: false, once: true, type: "transitionend", listener: ƒ}
  length: 1
  __proto__: Array(0)
  __proto__: Object

after a click

getEventListeners(box)
{} <-- empty object

This means in the code from the first post I can basically ditch the expanded and collapsed methods for just this

const expand = elem => {
  elem.style.height = `${elem.scrollHeight}px`

  elem.addEventListener('transitionend', event => {
    elem.style.height = 'auto'
    elem.dataset.collapsed = 'false'
  }, { once: true })
}

const collapse = elem => {
  elem.style.height = `${elem.scrollHeight}px`
  propChange(elem, 'height', '0px')

  elem.addEventListener('transitionend', event => (
    elem.dataset.collapsed = 'true'
  ), { once: true })
}

One last tip, I was getting a bit bored of writing document.addEventListener('DOMContent… all the time

Snippets in VSCode are great, and I came up with the following to add to file -> preferences -> user snippets -> javascript

{
  "Document Content Loaded": {
    "prefix": ["document", "doc-cont"],
    "body": ["document.addEventListener('DOMContentLoaded', function(event) {", "\t$0", "}"],
    "description": "DOMContentLoaded."
  }
}

I’m sure this is all common knowledge to some, but just thought I would share

1 Like

Managed to shave a few lines off the script.

const getElem = document.querySelector.bind(document)
// collapse transitionend callback
const collapsed = (elem, event) => {
  elem.dataset.collapsed = 'true'
}
// expand transitionend callback
const expanded = (elem, event) => {
  elem.style.height = 'auto'
  elem.dataset.collapsed = ''
}

const expand = elem => {
  elem.addEventListener('transitionend', expanded.bind(null, elem), { once: true })
  elem.style.height = `${elem.scrollHeight}px`
}

const collapse = elem => {
  elem.addEventListener('transitionend', collapsed.bind(null, elem), { once: true })
  elem.style.height = `${elem.scrollHeight}px`
  elem.style.height = `${elem.scrollHeight * 0}px` // accessing elem.scrollHeight forces reflow
}

document.addEventListener('DOMContentLoaded', event => {
  const menu = getElem('#menu')

  getElem('#toggleMenu').addEventListener('click', () => {
    menu.dataset.collapsed ? expand(menu) : collapse(menu)
  })

  getElem('#dropdown').addEventListener('click', event => {
    event.currentTarget.classList.toggle('open')
  })
})

To get a reflow I snuck scrollHeight into my assignment/expression, which kept js standard happy as well

elem.style.height = `${elem.scrollHeight}px`
elem.style.height = `${elem.scrollHeight * 0}px` // force re-flow

transitionend and ‘once’ option caveat

I don’t know if this is of interest, but this issue cropped up on a menu I have been working on and proved to be a bit of a headache to get to the bottom of. As is often the case, I tried every solution under the sun, before discovering the blatantly obvious.

The issue comes down to adding an eventListener to a particular element — with ‘once’ set — which also has child elements that may also fire that particular event.

The child elements essentially remove the eventListener in the process, before it gets to do it’s intended job on the parent element.

I’ve put together an illustration, which might make this clearer (this kind of helps me cement this information into my brain)

Here is an example menu which demonstrates the issue.

The solution for the moment is to remove the ‘once’ property and go back to having removeEventListener do it’s job inside the callback instead.

Cheers

1 Like

Could do with some help here is possible. Trying to get to the bottom of a bug, where by the menu buttons momentarily stop working. A click on a menu button does nothing, but leave it a second and try again and it works.

My guess is that possibly an expand is firing when it should be collapsing and vice ‘n’ versa.

Codepen is here

JS here

const getElem = (needle, root = document) => root.querySelector(needle)

// collapse transitionend callback
const collapsed = (elem, handler, event) => {
  if (event.propertyName !== 'height') return

  elem.removeEventListener('transitionend', handler)
  elem.style = ''
  elem.dataset.collapsed = 'true'
}
// expand transitionend callback
const expanded = (elem, handler, event) => {
  if (event.propertyName !== 'height') return

  elem.removeEventListener('transitionend', handler)
  elem.style.height = 'auto'
  elem.dataset.collapsed = ''
}

const expand = elem => {
  const handler = event => (expanded(elem, handler, event))

  elem.addEventListener('transitionend', handler)
  setTimeout(() => (elem.style.height = `${elem.scrollHeight}px`), 50)
}

const collapse = elem => {
  const handler = event => (collapsed(elem, handler, event))

  elem.addEventListener('transitionend', handler)
  elem.style.height = `${elem.scrollHeight}px`
  setTimeout(() => (elem.style.height = '0px'), 50)
}

document.addEventListener('DOMContentLoaded', event => {
  // regex to match 'Menu' or 'DropDown' in 'toggleMenu' or 'toggleDropDown'
  const isButtonRx = /\btoggle(?:(Menu)|(DropDown))\b/

  const menuHandler = event => {
    const elem = event.target

    // if neither element has a classname of 'toggleMenu' or 'toggleDropDown' return
    if (!isButtonRx.test(elem.className)) return

    // in place to disable the anchor links when mobile menu
    if (elem.classList.contains('disabled')) event.preventDefault()

    const [/* fullmatch */, menu/*, dropdown */] = elem.className.match(isButtonRx)
    const menuElement = getElem((menu) ? '.menu' : '.dropdownmenu', elem.parentElement)

    if (elem.classList.toggle('open')) { expand(menuElement) } else { collapse(menuElement) }
  }

  getElem('#mainnav').addEventListener('click', menuHandler, false)
})

Thanks in advance

edit: An aside, I will be changing isButtonRx to \btoggle(?:Menu|DropDown)\b. and

const [menu] = elem.className.match(isButtonRx)
const menuElement = getElem((menu === 'toggleMenu') ? '.menu' : '.dropdownmenu', elem.parentElement)

Makes more sense

Edit2: Well that’s embarrassing. What I mistook for a random bug was me occasionally clicking on the span tag and caret. My if (!isButtonRx.test(elem.className)) return makes sure to ignore that.

Had hoped I might have got some advice along the way, but given I am the only one posting here, it’s turned into more of a personal blog than anything else.

Anyway…

Opted to drop the event delegation handler. In theory a good idea, but separating the handler into handlers for the main toggle menu and the drop downs made life a bit simpler

The dropdowns now work in more of an accordion fashion

Conscious that I have made a of a meal of it, but have also learnt a bit along the way.

The idea behind this after toying with upgrading my site from Bootstrap 3 to Bootstrap 5 is to hand roll it and take advantage of grids and flexboxes. At the very least a good learning exercise.

codepen here

JS here

// dom helper functions to be moved to a domHelper module
const getElem = (needle, root = document) => root.querySelector(needle)
const getElems = (needle, root = document) => Array.from(root.querySelectorAll(needle))
const nextSibling = elem => elem.nextElementSibling
const hasClass = (elem, className) => elem.classList.contains(className)
const removeClass = (elem, className) => elem.classList.remove(className)
const toggle = (elem, className) => elem.classList.toggle(className)
const addEvent = (elem, type, handler) => elem.addEventListener(type, handler)
const removeEvent = (elem, type, handler) => elem.removeEventListener(type, handler)

const doc = window.document

// collapse transitionend callback
const collapsed = (event, elem, handler, callback) => {
  if (event.propertyName !== 'height') return

  removeEvent(elem, 'transitionend', handler)
  elem.style = ''
  if (typeof callback === 'function') callback(elem)
}

// expand transitionend callback
const expanded = (event, elem, handler) => {
  if (event.propertyName !== 'height') return

  removeEvent(elem, 'transitionend', handler)
  elem.style.height = 'auto'
}

const expand = elem => {
  const handler = event => (expanded(event, elem, handler))

  addEvent(elem, 'transitionend', handler)
  setTimeout(() => (elem.style.height = `${elem.scrollHeight}px`), 0)
}

const collapse = (elem, callback) => {
  const handler = event => (collapsed(event, elem, handler, callback))

  addEvent(elem, 'transitionend', handler)
  elem.style.height = `${elem.scrollHeight}px`
  setTimeout(() => (elem.style.height = `${elem.scrollHeight * 0}px`), 0)
}

addEvent(doc, 'DOMContentLoaded', event => {
  const mainMenuHandler = event => {
    const toggleButton = event.currentTarget
    const menuElement = nextSibling(toggleButton)

    if (toggle(toggleButton, 'open')) { expand(menuElement) } else { collapse(menuElement) }
  }

  const getDropDownMenuHandler = parentMenu => event => {
    const toggleButton = event.currentTarget
    const menuElement = nextSibling(toggleButton)
    const dropdownOpened = getElem('.toggle-dropdown.open', parentMenu)

    // disable togglebutton going to href link address if on mobile menu
    if (hasClass(toggleButton, 'disabled')) event.preventDefault()

    if (toggle(toggleButton, 'open')) {
      if (dropdownOpened !== null) {
        removeClass(dropdownOpened, 'open')
        // collapse opened dropdown and expand selected dropdown on callback
        collapse(nextSibling(dropdownOpened), () => (expand(menuElement)))
      } else {
        expand(menuElement)
      }
    } else {
      collapse(menuElement)
    }
  }

  const mainMenu = getElem('#main-nav')

  addEvent(getElem('.toggle-menu', mainMenu), 'click', mainMenuHandler)
  getElems('.toggle-dropdown', mainMenu).forEach(elem => addEvent(elem, 'click', getDropDownMenuHandler(mainMenu)))
})

1 Like