šŸ› ļø Typeahead: Keyboard

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!

šŸ› ļø Typeahead: Keyboard

We want to let users select predictions from the list of predictions with arrow keys. This is how Google does it.

How google's typeahead respond to up and down arrow keys

Highlighting a prediction

If you play around with Google, youā€™ll notice a few things.

First, if you press the Down on the input, Googleā€™s Typeahead highlights the first prediction. If you press Up on the input, Googleā€™s Typeahead highlights the last prediction.

At the same time, it replaces the inputā€™s value with the prediction.

Press Down and Up from Google's input

Notice the typing cursor remains on the input? This means focus remained on the input all the time.

To build this, we need to listen to a keydown event on the input element.

input.addEventListener('keydown', event => {
  // ...
})

We only want to select a prediction if the user presses the Up or Down arrow key. Here, weā€™ll use an early return so we donā€™t have to indent the rest of the code.

input.addEventListener('keydown', event => {
  const { key } = event
  if (key !== 'ArrowUp' && key !== 'ArrowDown') return
})

Next, we need to find the first and last predictions (so we can highlight them).

input.addEventListener('keydown', event => {
  // ...
  const predictions = [...ul.children]
  const firstPrediction = predictions[0]
  const lastPrediction = predictions[predictions.length - 1]
})

If there are no predictions, we can bail the function immediately. Thereā€™s no need to highlight anything.

input.addEventListener('keydown', event => {
  // ...
  const predictions = [...ul.children]
  if (predictions.length === 0) return

  const firstPrediction = predictions[0]
  const lastPrediction = predictions[predictions.length - 1]
})

If the user presses Down, we want to highlight the first prediction. To highlight the first prediction, we can add a is-highlighted class.

.typeahead li.is-highlighted {
  background-color: var(--magenta-050);
  border-color: var(--magenta-300);
}
input.addEventListener('keydown', event => {
  // ...
  if (key === 'ArrowDown') {
    firstPrediction.classList.add('is-highlighted')
  }
})

After highlighting the prediction, we want to replace the inputā€™s value with the prediction.

input.addEventListener('keydown', event => {
  // ...
  if (key === 'ArrowDown') {
    firstPrediction.classList.add('is-highlighted')
    input.value = firstPrediction.querySelector('span').textContent
  }
})
Highlights first prediction.

If the user presses Up from the input, we want to highlight the last prediction.

input.addEventListener('keydown', event => {
  // ...
  if (key === 'ArrowUp') {
    lastPrediction.classList.add('is-highlighted')
    input.value = lastPrediction.querySelector('span').textContent
  }
})
Highlights last prediction.

Notice the position of the cursor changed? If you press Up on an input element, the default behaviour is to go to the start of the input.

We can prevent this default behaviour with event.preventDefault.

input.addEventListener('keydown', event => {
  // ...
  if (key === 'ArrowUp') {
    event.preventDefault()
    firstPrediction.classList.add('is-highlighted')
    input.value = lastPrediction.querySelector('span').textContent
  }
})
Highlights last prediction. Cursor remains at the end of the input.

Highlighting other predictions

If you press Down when a prediction is highlighted, Google selects the next prediction. The reverse is true. If you press Up when a prediction is highlighted, Googleā€™s Typeahead selects the previous prediction.

How google's typeahead respond to up and down arrow keys

Here, we need to check if we highlighted a prediction. We can check by finding the element with the is-highlighted class.

input.addEventListener('keydown', event => {
  // ...
  const currentPrediction = ul.querySelector('is-highlighted')
  // ...
})

If no predictions are highlighted, we want to select the first or last prediction (what we did above).

input.addEventListener('keydown', event => {
  // ...
  if (!currentPrediction) {
    if (key === 'ArrowUp') {
      event.preventDefault()
      lastPrediction.classList.add('is-highlighted')
      input.value = lastPrediction.querySelector('span').textContent
    }

    if (key === 'ArrowDown') {
      firstPrediction.classList.add('is-highlighted')
      input.value = firstPrediction.querySelector('span').textContent
    }
  }
})

If the user press Down when a prediction is selected, we want to select the next prediction.

To select the next prediction, we:

  1. Remove .is-highlighted from the current prediction
  2. Find the next prediction (with nextElementSibling)

Once we find the next prediction, we do the following:

  1. Add .is-highlighted to the next prediction
  2. Replace the input with the next predictionā€™s text.
input.addEventListener('keydown', event => {
  if (!currentPrediction) {
    // ...
  } else {
    currentPrediction.classList.remove('is-highlighted')

    if (key === 'ArrowDown') {
      const nextPrediction = currentPrediction.nextElementSibling
      if (nextPrediction) {
        nextPrediction.classList.add('is-highlighted')
        input.value = nextPrediction.querySelector('span').textContent
      }
    }
  }
})

Weā€™ll do the same if the user press Up. Make sure you remember to preventDefault to place the cursor in the correct position.

input.addEventListener('keydown', event => {
  if (!currentPrediction) {
    // ...
  } else {
    // ...

    if (key === 'ArrowUp') {
      const previousPrediction = currentPrediction.previousElementSibling
      if (previousPrediction) {
        event.preventDefault()
        previousPrediction.classList.add('is-highlighted')
        input.value = previousPrediction.querySelector('span').textContent
      }
    }
  }
})
Highlighting predictions with Up and Down arrow keys.

Reverting input text

If you press Up from the first prediction, Googleā€™s Typeahead reverts the inputā€™s value back to what the user originally typed.

The same thing happens if you press Down from the last prediction.

Google's Typeahead.

From this, we can see that highlighting a prediction doesnā€™t necessarily mean ā€œselectingā€ it. Rather, it means the user is considering selecting the prediction (and they can always revert back to what they typed).

To build this, we have to create a variable to store the value entered by the user. Weā€™ll call this userEnteredValue. We have to put this value outside of the event listener.

let userEnteredValue
input.addEventListener('keydown', event => {
  // ...
})

We want to save what the user typed into userEnteredValue when they highlight the first or last prediction with the keyboard.

let userEnteredValue
input.addEventListener('keydown', event => {
  if (!currentPrediction) {
    userEnteredValue = input.value.trim()
    // ...
  }
})

If the user press Up from the first prediction, we want to restore the original value.

input.addEventListener('keydown', event => {
  if (!currentPrediction) {
    // ...
  } else {
    // ...
    if (currentPrediction === firstPrediction && key === 'ArrowUp') {
      input.value = userEnteredValue
    }
    // ...
  }
})

Likewise, if the user press Down from the last prediction, we want to restore the original value.

input.addEventListener('keydown', event => {
  if (!currentPrediction) {
    // ...
  } else {
    // ...
    if (currentPrediction === lastPrediction && key === 'ArrowDown') {
      input.value = userEnteredValue
    }
    // ...
  }
})
Input value reverts back to the value the user entered.

Refactoring

At this point, we have a huge chunk of code in the input event listener.

input.addEventListener('keydown', event => {
  const { key } = event
  if (key !== 'ArrowUp' && key !== 'ArrowDown') return

  const predictions = [...ul.children]
  if (predictions.length === 0) return

  const firstPrediction = predictions[0]
  const lastPrediction = predictions[predictions.length - 1]
  const currentPrediction = ul.querySelector('.is-highlighted')

  if (!currentPrediction) {
    userEnteredValue = input.value.trim()
    if (key === 'ArrowUp') {
      event.preventDefault()
      lastPrediction.classList.add('is-highlighted')
      input.value = lastPrediction.querySelector('span').textContent
    }

    if (key === 'ArrowDown') {
      firstPrediction.classList.add('is-highlighted')
      input.value = firstPrediction.querySelector('span').textContent
    }
  } else {
    currentPrediction.classList.remove('is-highlighted')
    if (currentPrediction === firstPrediction && key === 'ArrowUp') {
      input.value = userEnteredValue
    }

    if (currentPrediction === lastPrediction && key === 'ArrowDown') {
      input.value = userEnteredValue
    }

    if (key === 'ArrowUp') {
      const previousPrediction = currentPrediction.previousElementSibling
      if (previousPrediction) {
        event.preventDefault()
        previousPrediction.classList.add('is-highlighted')
        input.value = previousPrediction.querySelector('span').textContent
      }
    }

    if (key === 'ArrowDown') {
      const nextPrediction = currentPrediction.nextElementSibling
      if (nextPrediction) {
        nextPrediction.classList.add('is-highlighted')
        input.value = nextPrediction.querySelector('span').textContent
      }
    }
  }
})

We wrote code to do these two things four times:

  1. Highlight the prediction the user is considering
  2. Replace inputā€™s value with the prediction

We can create a function called considerPrediction that does this.

const considerPrediction = (prediction, event) => {
  event.preventDefault()
  prediction.classList.add('is-highlighted')
  input.value = prediction.querySelector('span').textContent
}

(Itā€™s okay to prevent the default behaviour of the Down arrow key as well. Why? Because the Down arrow key moves the cursor to the end of the text. And the cursor is already at the end of the text šŸ˜‰).

We can use considerPrediction like this:

input.addEventListener('keydown', event => {
  // ...
  if (!currentPrediction) {
    userEnteredValue = input.value.trim()
    if (key === 'ArrowUp') {
      considerPrediction(lastPrediction, event)
    }

    if (key === 'ArrowDown') {
      considerPrediction(firstPrediction, event)
    }
  } else {
    // ...
    if (key === 'ArrowUp') {
      const previousPrediction = currentPrediction.previousElementSibling
      if (previousPrediction) {
        considerPrediction(previousPrediction, event)
      }
    }

    if (key === 'ArrowDown') {
      const nextPrediction = currentPrediction.nextElementSibling
      if (nextPrediction) {
        considerPrediction(nextPrediction, event)
      }
    }
  }
  // ...
})

Pay attention to the last two sets. Notice we had to check if a prediction is truth before we use considerPrediction? We can throw this truthy check into considerPrediction.

const considerPrediction = (prediction, event) => {
  if (!prediction) return
  event.preventDefault()
  prediction.classList.add('is-highlighted')
  input.value = prediction.querySelector('span').textContent
}

Using considerPrediction (again):

input.addEventListener('keydown', event => {
  // ...
  if (!currentPrediction) {
    userEnteredValue = input.value.trim()
    if (key === 'ArrowUp') {
      considerPrediction(lastPrediction, event)
    }

    if (key === 'ArrowDown') {
      considerPrediction(firstPrediction, event)
    }
  } else {
    // ...
    if (key === 'ArrowUp') {
      const previousPrediction = currentPrediction.previousElementSibling
      considerPrediction(previousPrediction, event)
    }

    if (key === 'ArrowDown') {
      const nextPrediction = currentPrediction.nextElementSibling
      considerPrediction(nextPrediction, event)
    }
  }
  // ...
})

Much simpler than before.

Removing indentation

The if/else statement contains many lines of code. It makes it hard to understand what else is.

To counter this, we can split the if/else into two if statements.

input.addEventListener('keydown', event => {
  // ...

  if (!currentPrediction) {
    userEnteredValue = input.value.trim()
    if (key === 'ArrowUp') {
      considerPrediction(lastPrediction, event)
    }

    if (key === 'ArrowDown') {
      considerPrediction(firstPrediction, event)
    }
  }

  if (currentPrediction) {
    currentPrediction.classList.remove('is-highlighted')
    if (currentPrediction === firstPrediction && key === 'ArrowUp') {
      input.value = userEnteredValue
    }

    if (currentPrediction === lastPrediction && key === 'ArrowDown') {
      input.value = userEnteredValue
    }

    if (key === 'ArrowUp') {
      const previousPrediction = currentPrediction.previousElementSibling
      considerPrediction(previousPrediction, event)
    }

    if (key === 'ArrowDown') {
      const nextPrediction = currentPrediction.nextElementSibling
      considerPrediction(nextPrediction, event)
    }
  }
  // ...
})

Thatā€™s it!