Add character key cycling through options in a custom Vue select component

Viewed 54

I have a custom-built select component in Vue/Nuxt.js seen below:

enter image description here

This select has arrow key functionality (pressing the arrow up and down keys makes it scroll through the options and pressing enter selects that option), and I'm trying to add "jump key" functionality to it as well (pressing a character key makes the dropdown jump to the first result which starts with that letter, then cycles through all the other results starting with that letter until it loops back around to the start).

So far, the method I've written for this purpose is here:

setPointer(event) {
    if (event.key != "ArrowDown" && event.key != "ArrowUp" && event.key != "Enter" && event.key != "Escape" && event.key != "Tab") {
        let filteredResults = this.sortedResults.filter(result => result.text.toUpperCase().startsWith(event.key.toUpperCase()))
        let oldPointer = this.pointer
        let filteredPointer = this.sortedResults.length - filteredResults.length + oldPointer

        if (this.pointer == -1 || this.sortedResults[this.pointer] == filteredResults[filteredResults.length -1]) {
            this.pointer = this.results.indexOf(filteredResults[0])
        }

        else if (this.sortedResults[this.pointer] == filteredResults[filteredPointer] && this.pointer > -1) {
            this.movePointerDown()
        }

        let newPointer = this.pointer
        if (this.pointer > oldPointer) {
            this.$refs.dropdown.scrollTop += (40 * (newPointer - oldPointer))
        }
        else if (this.pointer == 0) {
            this.$refs.dropdown.scrollTop = 0
        }

        else {
            this.$refs.dropdown.scrollTop -= (40 * (oldPointer - newPointer))
        }
    }
},

sortedResults: an array of alphabetically-sorted results, which appears in my select's dropdown. The array's items are objects each containing a string called text (they have to be objects for outside-scope-of-question reasons)

filteredResults: an array of results filtered from sortedResults by whether the pressed key matches the first letter of each item's text

pointer: the currently-highlighted index of sortedResults

oldPointer: original value of pointer when starting the function

filteredPointer: where pointer sits inside filteredResults, calculated by taking the difference between sortedResults and filteredResults and adding oldPointer to it

newPointer: new value of pointer after cycling through function movePointerDown(): increments pointer by one, is also used on a down arrow key press

The plan is to generate filteredResults, oldPointer, and filteredPointer, then do the following checks:

  • if pointer hasn't been set or is sitting on something outside of filteredResults, set it to the place in sortedResults where its item matches filteredResults[0]
  • if pointer is already pointing to an item shared by sortedResults and filteredResults, increment pointer by 1
  • UNLESS the item shared by sortedResults and filteredResults is at the end of filteredResults' array, then set pointer back to whatever the filteredResults[0] equivalent is in sortedResults
  • Then afterwards check where the new pointer is relative to the old one and scroll the new one into view wherever it is.

I've tied my brain into a bit of a knot over this one, and obviously the method above is incomplete and full of holes. If anyone can help me figure out how to get the logic I need out of this function, it would be significantly appreciated. I am the only in-house front-end web developer at my place of work, so I don't have much chance to bounce questions off of people there - I manage for the most part, but it can be a struggle.

2 Answers

Going back and rethinking my logic, I've managed to answer my own question. I've taken the time to take the full select component I wrote and put it here as a code snippet in case anyone finds it useful in the future. It was originally designed for a runtime-environment-based Vue/Nuxt.js build, but it should hopefully still be legible.

new Vue({
    el: '.single-select',
    
    data: {
        hover: false,
        dropdownShow: false,
        input: '',
        selection: {},
        pointer: -1,
        filteredResults: [],
        diff: 0
    },
    
    computed: {
        visibleResults() {
            return this.dropdownShow && window.veg.length > 0
        },

        sortedResults() {
            return window.veg.sort((a, b) => this.compare(a, b))
        }
    },
    
    methods: {
        compare(a, b) {
            if (a.text < b.text) {
                return -1
            }
            if (a.text > b.text) {
                return 1
            }
            return 0
        },
        
        select(index) {
            if(index >= 0) {
                this.error = false
                this.selection = this.sortedResults[index]
                this.input = this.selection.text
                this.$emit('input', this.selection)
                this.closeDropdown()
            }

            else {
                this.error = true
                this.closeDropdown()
                this.hover = false
            }
        },
        
        showResults() {
            this.dropdownShow = true
            this.error = false
        },

        closeDropdown() {
            this.dropdownShow = false
            this.hover = false
        },

        toggleDropdown() {
            this.dropdownShow = !this.dropdownShow
            this.hover = !this.hover
        },

        setPointerIndex(index) {
            this.pointer = index
        },

        movePointerDown() {
            if (!this.sortedResults) {
                return
            }

            if (this.pointer >= this.sortedResults.length - 1) {
                return
            }

            if (!this.visibleResults) {
                return
            }

            this.pointer++

            if(this.pointer > 5) {
                this.$refs.dropdown.scrollTop += 40
            }
        },

        movePointerUp() {
            if (this.pointer > 0 && this.visibleResults) {
                this.pointer--

                if(this.pointer <= 5) {
                    this.$refs.dropdown.scrollTop -= 40
                }
            }
        },

        setPointer(event) {
            if (event.key != "ArrowDown" && event.key != "ArrowUp" && event.key != "Enter" && event.key != "Escape" && event.key != "Tab") {
                let filteredResults = this.sortedResults.filter(result => result.text.toUpperCase().startsWith(event.key.toUpperCase()))
                let filteredZeroIndex = this.sortedResults.indexOf(filteredResults[0])
                if (filteredResults.length > 0) {
                    if (this.pointer == -1 || this.sortedResults[this.pointer] == filteredResults[filteredResults.length -1] || !filteredResults.includes(this.sortedResults[this.pointer])) {
                        this.pointer = filteredZeroIndex
                    }

                    else if (this.sortedResults[this.pointer] == filteredResults[this.pointer - filteredZeroIndex] && this.pointer > -1) {
                        this.pointer++
                    }

                    this.$refs.dropdown.scrollTop = this.$refs.options[this.pointer].offsetTop
                }

                else {
                    return
                }
            }
        }
    },
})
body {
  padding: 2rem;
}

.single-select {
    max-width: 480px;
}

.single-select-results {
    max-height: 144px;
}
<!DOCTYPE html>
<html>
    <head>
        <script src="https://cdn.tailwindcss.com"></script>
    </head>
    
    <body>
        <div class="single-select">
            <p class="text-xs leading-5 font-medium">
                Select Option
            </p>
            <div class="mt-1 relative">
                <div @mouseover="hover = true" @mouseleave="hover = false" @mousedown="toggleDropdown" @keydown="setPointer($event)" @keydown.enter="select(pointer)" @keyup.tab.stop="closeDropdown" @keyup.esc.stop="closeDropdown" @keyup.down="movePointerDown" @keyup.up="movePointerUp" class="rnup-relative">
                    <input type="text" placeholder="Please select" v-model="input" readonly class="cursor-pointer select-none text-base w-full border rounded p-1">
                </div>
                 <div class="absolute z-10 w-full">
                    <div ref="dropdown" v-if="visibleResults" class="single-select-results overflow-y-auto mt-sm">
                        <div v-for="(result, index) in sortedResults" ref="options" @mouseover="setPointerIndex(index)" @click="select(index)" @keydown.enter="select(index)" @keyup.enter="select(index)" @keyup.tab.stop="closeDropdown" @keyup.esc.stop="closeDropdown" @keyup.down="movePointerDown" @keyup.up="movePointerUp">
                            <p :class="{ 'bg-slate-200' : index === pointer }">
                                {{ result.text }}
                            </p>
                        </div>
                    </div>
                </div>
            </div>
        </div>
        <script src="https://cdnjs.cloudflare.com/ajax/libs/vue/2.5.17/vue.js"></script>
        
        <script>
            window.veg = [
                {
                    text: 'Carrots',
                },

                {
                    text: 'Peas',
                },

                {
                    text: 'Sweetcorn',
                },

                {
                    text: 'Runner Beans',
                },

                {
                    text: 'Broccoli',
                },

                {
                    text: 'Cauliflower',
                },

                {
                    text: 'Cabbage',
                },

                {
                    text: 'Spinach',
                },

                {
                    text: 'Cake'
                },

                {
                    text: 'Spirulina'
                }
            ]
        </script>
    </body>
</html>

And here is the setPointer method isolated:

setPointer(event) {
    if (event.key != "ArrowDown" && event.key != "ArrowUp" && event.key != "Enter" && event.key != "Escape" && event.key != "Tab") {
        let filteredResults = this.sortedResults.filter(result => result.text.toUpperCase().startsWith(event.key.toUpperCase()))
        let filteredZeroIndex = this.sortedResults.indexOf(filteredResults[0])
        if (filteredResults.length > 0) {
            if (this.pointer == -1 || this.sortedResults[this.pointer] == filteredResults[filteredResults.length -1] || !filteredResults.includes(this.sortedResults[this.pointer])) {
                this.pointer = filteredZeroIndex
            }

            else if (this.sortedResults[this.pointer] == filteredResults[this.pointer - filteredZeroIndex] && this.pointer > -1) {
                this.pointer++
            }

            this.$refs.dropdown.scrollTop = this.$refs.options[this.pointer].offsetTop
        }

        else {
            return
        }
    }
}

sortedResults: an array of alphabetically-sorted results, which appears in my select's dropdown. The array's items are objects each containing a string called text (they have to be objects for outside-scope-of-question reasons)

filteredResults: an array of results filtered from sortedResults by whether the pressed key matches the first letter of each item's text

pointer: the currently-highlighted index of sortedResults

filteredZeroIndex: the location of filteredResults' first item within sortedResults, used to calculate where pointer currently sits relative to filteredResults' own internal index

$refs.options: An array specified as a ref on a v-for element tied to sortedResults, with each item corresponding to an item inside sortedResults.

Originally I used yoduh's scrollIntoView() suggestion, but this caused the entire viewport to scroll in a way that wasn't intended. Instead, I've replaced this with a "scroll to offset", which scrolls to wherever a given option in $refs.options is located relative to the dropdown and not the viewport.

Instead of determining the exact scroll amount there are other functions like Element.scrollIntoView() that will automatically scroll to a specific element, you just need to figure out which element. This is the method I came up with using scrollIntoView and is meant to run on keydown event and assumes all options in the dropdown have a class name called "option"

const options = ['broccoli', 'carrots',  ... ]
let prevKey = ''
let rotate = 0

keydown(e) {
  const key = e.key.toLowerCase()
  // only run if key pressed is a single letter a-Z
  if (key.length === 1 && key !== e.key.toUpperCase()) {
    // if this is a repeat key press, prepare to select the next dropdown option
    if (prevKey === key) {
      rotate++
      let filtered = options.filter(o => o.toLowerCase().startsWith(key))
      // rotate back around if we've reached the end of all matching options
      if (rotate + 1 > filtered.length) {
        rotate = 0
      }
    } else {
      rotate = 0
    }
    prevKey = key
    const optionIndex = options.findIndex(o => o.toLowerCase().startsWith(key))
    if (optionIndex !== -1) {
      let option =
        document.getElementsByClassName('option')[optionIndex + rotate]
      option.scrollIntoView() // option.focus() can work too
    }
  }
}

I know the document.getElementsByClassName is not very "Vue-like", but it was easier for me personally to work with. You may be able to do something with using $refs instead?

I also wanted to point out that what your doing is, in my experience, atypical functionality for a select input. Most selects/dropdowns out there usually let users spell out the option they want, continually scrolling and refining the closest matching selection as the user types. That would be achieved with something like this in case you're interested:

const options = ['broccoli', 'carrots',  ... ]
let searchTerm = '' // should be reset after making a selection or if dropdown loses focus

keypress(e) {
  if (
    (e.key.length === 1 && e.key.toLowerCase() !== e.key.toUpperCase()) ||
    e.key === 'Backspace'
  ) {
    // remove last character from search term
    if (e.key === 'Backspace') {
      searchTerm = searchTerm.substring(0, searchTerm.length - 1)
    } else {
      searchTerm += e.key.toLowerCase()
    }
    const optionIndex = options.findIndex(o =>
      o.toLowerCase().startsWith(searchTerm)
    )
    if (optionIndex !== -1) {
      let el = document.getElementsByClassName('option')[optionIndex]
      el.scrollIntoView()
    }
  }
}
Related