šŸ› ļø Modal: Resolving differences between subclasses

Hey ,

I'm thrilled to help you learn JavaScript. Unfortunately, you've landed on a page where you cannot access with your current purchase.

Please upgrade (use this link) access this content.

I'm super eager to help you learn more!

šŸ› ļø Modal: Resolving differences between subclasses

We know a few things so far:

  1. When we close a User-triggered modal, we need to set the buttonā€™s aria-expanded attribute.
  2. Timed modals donā€™t have a buttonElement. We cannot set aria-expanded inside TimedModal. This means everything regarding buttonElement should be moved to UserTriggeredModal.
  3. We used modal.close to close the modal. We did this three times with event listeners inside BaseModal.
  4. When we use modal.close to close the modal in User-triggered modals, we need to set the buttonā€™s aria-expanded attribute back to false.

Iā€™ll bring you through a process to discover the solution I recommend.

Step 1: Creating event listeners in each derivative modal

When we close User-triggered modals, we need to run an extra buttonElement.setAttribute line. But we donā€™t have to do this when we close Timed modals. This means thereā€™s a difference in requirements when closing modals.

The easiest way to resolve this difference is to create event listeners inside derivative modals.

function BaseModal (settings) {
  // ...
  // Remove these
  closeButtonElement.addEventListener('click', _ => {
    modal.close()
  })

  overlayElement.addEventListener('click', event => {
    if (!event.target.closest('.modal')) {
      modal.close()
    }
  })

  document.addEventListener('keydown', event => {
    if (modal.isOpen && event.key === 'Escape') {
      modal.close()
    }
  })
}

We copy-paste the event listeners into each derivative modal:

function UserTriggeredModal (settings) {
  // ...
  // Paste code here
}

function TimedModal (settings) {
  // ...
  // Paste code here
}

For these event listeners to work, we need to declare variables like closeButtonElement and modalOverlay on each derivative modal.

function UserTriggeredModal (settings) {
  const modal = BaseModal(settings)
  const { modalElement, buttonElement } = settings
  const overlayElement = modalElement.parentElement
  const closeButtonElement = modalElement.querySelector('.jsModalClose')

  // Add event listeners
}

function TimedModal (settings) {
  const modal = BaseModal(settings)
  const { modalElement } = settings
  const overlayElement = modalElement.parentElement
  const closeButtonElement = modalElement.querySelector('.jsModalClose')

  // Add event listeners
}

We can set buttonElement's aria-expanded to false in UserTriggeredModal now.

function UserTriggeredModal (settings) {
  // ...

  closeButtonElement.addEventListener('click', _ => {
    modal.close()
    buttonElement.setAttribute('aria-expanded', false)
  })

  overlayElement.addEventListener('click', event => {
    if (!event.target.closest('.modal')) {
      modal.close()
		  buttonElement.setAttribute('aria-expanded', false)
    }
  })

  document.addEventListener('keydown', event => {
    if (modal.isOpen && event.key === 'Escape') {
      modal.close()
		  buttonElement.setAttribute('aria-expanded', false)
    }
  })
}

This works, but it a lot of repetition, so itā€™s not the best solution.

Step 2: Overwriting the close method

If we can overwrite close in UserTriggeredModal, we donā€™t need to call buttonElement.setAttribute three times (once in each event listener).

We can simply call the close method, like this:

function UserTriggeredModal (settings) {
  // ...

  closeButtonElement.addEventListener('click', _ => {
    modal.close()
  })

  overlayElement.addEventListener('click', event => {
    if (!event.target.closest('.modal')) {
      modal.close()
    }
  })

  document.addEventListener('keydown', event => {
    if (modal.isOpen && event.key === 'Escape') {
      modal.close()
    }
  })
}

Overwriting the method consist of three steps:

  1. Creating a method with the same name in the derivative class
  2. Call the parentā€™s method
  3. Add any code you wish to add

This process is easy if you used a Class syntax.

// Example for Class syntax
class BaseModal {
  // ...
  close () { /* ...*/ }
}

class UserTriggeredModal extends BaseModal {
  close () {
    super.close()
    // Add code here
  }
}

The process is tougher with Factory Functions. We need to create an instance of BaseModal first. Weā€™ll name this base instead of modal since we want modal to be the eventual object.

function UserTriggeredModal (settings) {
  const base = BaseModal(settings)
}

Then, we need to extend base with another object. Normally we can do this with Object.assign.

function UserTriggeredModal (settings) {
  const base = BaseModal(settings)
  const modal = Object.assign({}, base, {/* ... */})
}

But we cannot use Object.assign here because we used getter functions. Object.assign doesnā€™t copy getter functions. It copies the results from a getter function.

We need to use a custom function called mix to merge base with a new object.

Hereā€™s the simple version of mix. Thereā€™s also a robust version if you wish to use it. I explained how mix works in this article.

function mix (...sources) {
  const result = {}
  for (const source of sources) {
    const props = Object.keys(source)
    for (const prop of props) {
      const descriptor = Object.getOwnPropertyDescriptor(source, prop)
      Object.defineProperty(result, prop, descriptor)
    }
  }
  return result
}

We can use mix like Object.assign. The difference is: we donā€™t need to pass an empty object as the first argument. mix creates this empty object automatically.

function UserTriggeredModal (settings) {
  const base = BaseModal()
  const modal = mix(base, {/* ... */})
}

We can now overwrite the close method by providing a method with the same name.

function UserTriggeredModal (settings) {
  const base = BaseModal()
  const modal = mix(base, {
    close () {
      // Overwrites the close method
    }
  })
}

Then we call the parentā€™s close method (which is base.close) and add any code we wish. In this case, weā€™ll add the buttonElement.setAttribute line.

function UserTriggeredModal (settings) {
  const { modalElement, buttonElement } = settings
  const base = BaseModal()
  const modal = mix(base, {
    close () {
      base.close()
      buttonElement.setAttribute('aria-expanded', false)
      buttonElement.focus()
    }
  })
}

We can now use modal.close in the three event listeners.

function UserTriggeredModal (settings) {
  // ...

  closeButtonElement.addEventListener('click', _ => {
    modal.close()
  })

  overlayElement.addEventListener('click', event => {
    if (!event.target.closest('.modal')) {
      modal.close()
    }
  })

  document.addEventListener('keydown', event => {
    if (modal.isOpen && event.key === 'Escape') {
      modal.close()
    }
  })
}

Since we overwrote close in UserTriggredModal, it makes sense to overwrite open too.

function UserTriggeredModal (settings) {
  // Declare variables

  const modal = mix(base, {
    open () {
      base.open()
      buttonElement.setAttribute('aria-expanded', true)
    },
    // Other methods
  })

  buttonElement.addEventListener('click', _ => {
    modal.open()
  })

  // Other event listeners
}

Step 3: Adding the event listeners inside Modal

Weā€™ve reduced the amount of code by overwriting the close method in UserTriggeredModal. But thereā€™s still a lot of duplication between UserTriggeredModal and TimedModal.

We can reduce code duplication by deferring the adding of common event listeners after the derivative modals are created. The easiest way is to add event listeners inside Modal instead of derivative modals.

To do this, we need to return the modal in each derivative modal.

function UserTriggeredModal (settings) {
  // ...
  return modal
}

function TimedModal (settings) {
  // ...
  return modal
}

We then add event listeners in Modal instead of the derivative modals. You can remove these three event listeners from UserTriggeredModal and TimedModal at this point.

export default function Modal (settings) {
  settings = Object.assign({}, defaults, settings)

  const { type } = settings
  let modal

  // Create Modal
  switch (type) {
    case 'normal': modal = UserTriggeredModal(settings); break
    case 'timed': modal = TimedModal(settings); break
  }

  // Adds event listeners
  const { modalElement } = settings
  const overlayElement = modalElement.parentElement
  const closeButtonElement = modalElement.querySelector('.jsModalClose')

  closeButtonElement.addEventListener('click', _ => {
    modal.close()
  })

  overlayElement.addEventListener('click', event => {
    if (!event.target.closest('.modal')) {
      modal.close()
    }
  })

  document.addEventListener('keydown', event => {
    if (modal.isOpen && event.key === 'Escape') {
      modal.close()
    }
  })
}

Thatā€™s it!

Bonus

Building other components can be frustrating when you have a Timed Modal on screen. This is because the Timed Modal pops up when you donā€™t want it to.

An easy way to ā€œfixā€ this is to create the Timed Modal when a button is clicked.

const timedModalButton = document.querySelector('#timed-modal-button')

timedModalButton.addEventListener('click', _ => {
  Modal({
    type: 'timed'
    delayBeforeOpening: 1000,
    modalElement: document.querySelector('#time-modal')
  })
})

This is just a neat trick to use for development šŸ˜‰.

Bonus 2

Try extending this Modal to include a Scroll-based modal and an Exit Intent modal. These shouldnā€™t be too hard for you at this point. (You might have to google around to figure out how to build an Exit Intent Modal though).

Good luck!