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

Time:08-27

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.

CodePudding user response:

Using what @yoduh suggested for scrollintoView() and $refs, and going back and rethinking my logic, I've managed to answer my own question and rewrote the method setPointer to reflect this new logic:

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.options[this.pointer].scrollIntoView()
        }

        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.

Edit: 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.options[this.pointer].scrollIntoView()
                }

                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 >
            <p >
                Select Option
            </p>
            <div >
                <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" >
                    <input type="text" placeholder="Please select" v-model="input" readonly >
                </div>
                 <div >
                    <div ref="dropdown" v-if="visibleResults" >
                        <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 :>
                                {{ 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>

CodePudding user response:

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