loading...

Keyboard control of your web app (beyond tabindex)

patarapolw profile image Pacharapol Withayasakpunt ・2 min read

In vanilla JS, of course, but also compatible with SPA.

The trick is to attach to window's onkeydown / onkeypress / onkeyup, and remove the handler when needed. (Using addEventListener / removeEventListener is OK too, but harder to prevent from attaching multiple times.)

  async startQuiz() {
    window.onkeypress = this.onQuizKeypress.bind(this)
    this.isQuizModal = true
  }

  endQuiz() {
    window.onkeypress = null
    this.isQuizModal = false
  }

  beforeDestroy() {
    window.onkeypress = null
  }

If you use class, function keyword or this keyword, you would also need to .bind(this).

About the real event, you might need several escaping mechanisms, before the real thing.

  onQuizKeypress(evt: KeyboardEvent) {
    if (!this.isQuizModal) {
      return
    }

    if (
      evt.target instanceof HTMLTextAreaElement ||
      evt.target instanceof HTMLInputElement
    ) {
      return
    }

    ...
  }

Add several utility functions,

    const click = async (el: any) => {
      if (!el) return

      if (el.classList?.add) {
        el.classList.add('active')
      }

      if (el.click) {
        const r = el.click()
        if (r instanceof Promise) {
          await r
          if (el.classList?.remove) {
            el.classList.remove('active')
          }
        }
        // else {
        //   await new Promise((resolve) => setTimeout(resolve, 500))
        //   if (el.classList?.remove) {
        //     el.classList.remove('active')
        //   }
        // }
      }
    }

    const mapKey = (map: Record<string, (() => any) | string>) => {
      let action = map[evt.key]
      while (typeof action === 'string') {
        action = map[action]
      }
      if (typeof action === 'function') action()
    }

Now, for the real key mapping,

  onQuizKeypress(evt: KeyboardEvent) {
    ... // Escape several things

    mapKey({
      '1': () => click(this.$refs.btnMarkRight),
      '2': () => click(this.$refs.btnMarkWrong),
      '3': () => click(this.$refs.btnMarkRepeat),
      q: () => click(this.$refs.btnHideAnswer),
      ' ': 'q',
      e: () => click(this.$refs.btnEditModal),
    })
  }

And now, for the styling. I just make it simple, yet clear.

button:focus, button:active, button.active {
  filter: grayscale(50%) saturate(75%);
}

If you are curious, I use this a real project -- patarapolw/zhquiz -- packages/nuxt/pages/quiz.vue. Visit my web app.

Posted on by:

patarapolw profile

Pacharapol Withayasakpunt

@patarapolw

Currently interested in TypeScript, Vue, Kotlin and Python. Looking forward to learning DevOps, though.

Discussion

markdown guide