Integrate a character key feature to cycle through options in a customized Vue select component

In my Vue/Nuxt.js project, I have implemented a custom select component with arrow key functionality for scrolling through options and selecting with the enter key. Now, I am trying to add "jump key" functionality where pressing a character key will jump to the first result starting with that letter and cycle through all others. I have written a method for this purpose:

setPointer(event) {
    //Logic for jump key functionality
}

sortedResults: sorted array of results shown in the dropdown

filteredResults: results filtered based on pressed key

pointer: currently highlighted index in sortedResults

oldPointer: original value of pointer at function start

filteredPointer: position of pointer in filteredResults

newPointer: updated pointer value after function execution

movePointerDown(): function to increment pointer by one

The goal is to set pointer to the matching item in sortedResults, handle when pointer is already pointing to an item shared between sortedResults and filteredResults, and scroll the new pointer into view relative to the old one.

If anyone can provide assistance in refining this logic, it would be greatly appreciated. As the sole front-end developer in my workplace, I sometimes struggle without others to bounce ideas off.

Answer №1

After revisiting my thought process, I was able to find a solution to my own query. I have taken the time to encapsulate the entire select component I created and presented it here as a code snippet for potential future reference. Originally crafted for a Vue/Nuxt.js build in a runtime environment, it should still be easily understandable.

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>

Below is an isolated version of the setPointer method:

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: Contains an array of alphabetically-sorted results displayed in the select dropdown. Each item in the array is an object with a text property.

filteredResults: This array holds results filtered from sortedResults based on the key pressed.

pointer: Indicates the currently highlighted index within sortedResults.

filteredZeroIndex: Denotes the position of the first item in filteredResults within sortedResults, aiding in determining the placement of the pointer relative to filteredResults’ internal index.

$refs.options: An array referenced inside a v-for element tied to sortedResults, where each item corresponds to an entry in sortedResults.

Initially, I attempted using yoduh's scrollIntoView() suggestion, but this led to unintended viewport scrolling. Subsequently, I replaced it with a "scroll to offset" approach where the scroll position aligns relative to the dropdown rather than the viewport.

Answer №2

Instead of trying to calculate the exact scroll amount, there are alternative functions like Element.scrollIntoView() that can automatically scroll to a specific element; you just need to identify which element. I devised this method using scrollIntoView to be executed on a keydown event, assuming all dropdown options have a class name of "option"

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

keydown(e) {
  const key = e.key.toLowerCase()
  // proceed only if a single letter from A-Z is pressed
  if (key.length === 1 && key !== e.key.toUpperCase()) {
    if (prevKey === key) {
      rotate++
      let filtered = options.filter(o => o.toLowerCase().startsWith(key))
      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() 
    }
  }
}

I am aware that using document.getElementsByClassName may not align with Vue practices, but it suited my workflow better. You might explore using $refs as an alternative?

It's important to note that what you're attempting is rather unconventional for a select input. Most selects/dropdowns allow users to spell out their selection, continuously refining the closest match as they type. This functionality can be achieved with something like this if you're curious:

const options = ['broccoli', 'carrots',  ... ]
let searchTerm = '' 

keypress(e) {
  if (
    (e.key.length === 1 && e.key.toLowerCase() !== e.key.toUpperCase()) ||
    e.key === 'Backspace'
  ) {
    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()
    }
  }
}

Similar questions

If you have not found the answer to your question or you are interested in this topic, then look at other similar questions below or use the search

Obtaining the node generated from a document fragment

Suppose I add a document fragment to the DOM in the following way: const ul = document.querySelector("ul"); const fruits = ["Apple", "Orange", "Banana", "Melon"]; const fragment = new DocumentFragment(); ...

Creating multiple Vue apps under one project can be a great way to manage different sections

Recently, I started learning Vue.js and working on an e-commerce project using Firebase. While exploring Firebase, I discovered the multiple hosting features it offers which could assist me in hosting the client and admin sides on two different domains. ...

Using jQuery, retrieve the "select" element within a specific row of a

I am working on a table row that has the following HTML structure: When the user interacts with the "Name" field, I trigger the "select" event and pass it to the nameDropChanged function. Once I have a reference to this select element, I need to change th ...

Searching and updating a value in an array using JavaScript

I need help solving a Javascript issue I'm facing. I'm working on an e-commerce project built in Vue, and I want to implement the selection of product variants on the client-side. The data format being sent to the backend looks like this: { & ...

What steps can I take to give priority to a particular ajax call?

Every 10 seconds, two ajax based methods are executed. However, when I submit the form for processing, it waits for the previous ajax calls to complete before processing. I want to prioritize the form submission. Below is the script that I am using: func ...

Map with a responsive grid layout featuring two columns

My layout is currently set up with static markup that creates a flex design with 2 columns. <Row> <Col span={6}>content</Col> <Col span={6}>content</Col> </Row> <Row> <Col span={6}>content</Col> ...

Passing an array from the PHP View to a JavaScript function and plotting it

Greetings, I am currently facing the following tasks: Retrieving data from a database and saving it to an array (CHECK) Sending the array from Controller to View (CHECK) Passing that array to a JavaScript function using json_encode (CHECK) Plotting the ...

The text fields keep duplicating when the checkbox is unchecked

There are check boxes for Firstname, Lastname, and Email. Clicking on a checkbox should display the corresponding input type, and unchecking it should remove the input field. I am also attempting to retrieve the label of the selected checkbox without succ ...

ng-options encounters duplicate data when using group by in Internet Explorer

The dropdown menu that lists states works fine in Google Chrome, but duplicates groups in Internet Explorer. In Chrome, there are 2 groups, but in IE there are 4. How can I fix this issue in IE as well? Thank you. Here is the code snippet: <!DOCTYPE h ...

Retrieve the texture's color based on the UV coordinates

Currently, I'm working with version 73 of V. I am dealing with UV coordinates obtained from the intersection of a raycaster and a texture of an object. Is there a way to extract the color (RGB or RGBA) of the used texture at these specific UV coordina ...

Customizing Ext JS/Sencha Chart framework based on certain conditions

As someone who is new to Ext JS and Sencha charts, I have encountered a challenge with one of the charts in our application. Specifically, I needed to hide the dashes on the X-Axis of that particular chart. Our application is built using Ext JS version 5.1 ...

How can I incorporate icons alongside each row in a table with bootstrap vue?

I am facing an issue with my table, which utilizes Bootstrap Vue's table. Each row in the table corresponds to an item. The specific problem I am encountering is the need to add an icon next to each row that displays when hovered over and performs a f ...

Tips for transferring data between iframes on separate pages

I am currently working on implementing a web calendar module within an iframe on page-b. This module consists of 1 page with two sections. Upon entering zipcodes and house numbers, the inputs are hidden and the calendar is displayed. The technology used he ...

Discover the ultimate guide to creating an interactive website using solely JavaScript, without relying on any server-side

I'm facing a challenge: I have a desire to create a website that is mainly static but also includes some dynamic components, such as a small news blog. Unfortunately, my web server only supports static files and operates as a public dropbox directory. ...

Issue with showing an input field following the button click

I'm having a bit of trouble with this function that should add an input element to a div. I've tried using appendChild and also concatenating the object to innerHTML, but no luck so far. Could it be something my beginner's mind is missing? f ...

Is there a way to run a node script from any location in the command line similar to how Angular's "

Currently, I am developing a node module that performs certain functions. I want to create a command similar to Angular's ng command. However, I am facing compatibility issues with Windows and Linux operating systems. Despite my attempts to modify the ...

Executing a JavaScript/jQuery function on the following page

I'm currently working on developing an internal jobs management workflow and I'd like to enhance the user experience by triggering a JavaScript function when redirecting them to a new page after submitting a form. At the moment, I am adding the ...

Elasticsearch query fails to execute when encountering a special character not properly escaped

I am facing an issue with querying a keyword that includes a dot (.) at the end. While the query works perfectly on Kibana's console, it fails to execute on my application's function. The following function is responsible for creating the query b ...

Associate a variable with a model within a controller

My goal is to bind a variable in my Controller to a model linked to an input like this: <body ng-app="myApp"> <div ng-controller="MyController"> <input type="text" ng-model="myVar1" /> {{ myVar1 }} <br /> ...

Customizing the main icon in the Windows 10 Action Center through a notification from Microsoft Edge

I am facing an issue with setting the top icon of a notification sent from a website in Microsoft Edge. Let's consider this code as an example: Notification.requestPermission(function (permission) { if (permission == "granted") { new ...