Merge pull request #2721 from mikiher/keyboard-navigation-2

Add keyboard navigation to multi-select components
This commit is contained in:
advplyr 2024-03-10 09:45:16 -05:00 committed by GitHub
commit 9e44fe5524
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 125 additions and 15 deletions

View File

@ -17,7 +17,7 @@
<ul ref="menu" v-show="showMenu" class="absolute z-60 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-56 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label"> <ul ref="menu" v-show="showMenu" class="absolute z-60 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-56 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label">
<template v-for="item in itemsToShow"> <template v-for="item in itemsToShow">
<li :key="item" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent> <li :key="item" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" :class="itemsToShow[selectedMenuItemIndex] === item ? 'text-yellow-300' : ''" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent>
<div class="flex items-center"> <div class="flex items-center">
<span class="font-normal ml-3 block truncate">{{ item }}</span> <span class="font-normal ml-3 block truncate">{{ item }}</span>
</div> </div>
@ -54,7 +54,7 @@ export default {
menuDisabled: { menuDisabled: {
type: Boolean, type: Boolean,
default: false default: false
}, }
}, },
data() { data() {
return { return {
@ -62,7 +62,9 @@ export default {
currentSearch: null, currentSearch: null,
typingTimeout: null, typingTimeout: null,
isFocused: false, isFocused: false,
menu: null menu: null,
filteredItems: null,
selectedMenuItemIndex: null
} }
}, },
watch: { watch: {
@ -91,24 +93,63 @@ export default {
return classes.join(' ') return classes.join(' ')
}, },
itemsToShow() { itemsToShow() {
if (!this.currentSearch || !this.textInput) { if (!this.currentSearch || !this.textInput || !this.filteredItems) {
return this.items return this.items
} }
return this.items.filter((i) => { return this.filteredItems
var iValue = String(i).toLowerCase()
return iValue.includes(this.currentSearch.toLowerCase())
})
} }
}, },
methods: { methods: {
editItem(item) { editItem(item) {
this.$emit('edit', item) this.$emit('edit', item)
}, },
keydownInput() { search() {
if (!this.textInput) {
this.filteredItems = null
return
}
this.currentSearch = this.textInput
const results = this.items.filter((i) => {
var iValue = String(i).toLowerCase()
return iValue.includes(this.currentSearch.toLowerCase())
})
this.filteredItems = results || []
},
keydownInput(event) {
let items = this.itemsToShow
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault()
if (!items.length) return
if (event.key === 'ArrowDown') {
if (this.selectedMenuItemIndex === null) {
this.selectedMenuItemIndex = 0
} else {
this.selectedMenuItemIndex = Math.min(this.selectedMenuItemIndex + 1, items.length - 1)
}
} else if (event.key === 'ArrowUp') {
if (this.selectedMenuItemIndex === null) {
this.selectedMenuItemIndex = items.length - 1
} else {
this.selectedMenuItemIndex = Math.max(this.selectedMenuItemIndex - 1, 0)
}
}
this.recalcScroll()
return
} else if (event.key === 'Enter') {
if (this.selectedMenuItemIndex !== null) {
this.clickedOption(event, items[this.selectedMenuItemIndex])
} else {
this.submitForm()
}
return
}
this.selectedMenuItemIndex = null
clearTimeout(this.typingTimeout) clearTimeout(this.typingTimeout)
this.typingTimeout = setTimeout(() => { this.typingTimeout = setTimeout(() => {
this.currentSearch = this.textInput this.search()
}, 100) }, 100)
this.setInputWidth() this.setInputWidth()
}, },
@ -120,6 +161,24 @@ export default {
this.recalcMenuPos() this.recalcMenuPos()
}, 50) }, 50)
}, },
recalcScroll() {
if (!this.menu) return
var menuItems = this.menu.querySelectorAll('li')
if (!menuItems.length) return
var selectedItem = menuItems[this.selectedMenuItemIndex]
if (!selectedItem) return
var menuHeight = this.menu.offsetHeight
var itemHeight = selectedItem.offsetHeight
var itemTop = selectedItem.offsetTop
var itemBottom = itemTop + itemHeight
if (itemBottom > this.menu.scrollTop + menuHeight) {
let menuPaddingBottom = parseFloat(window.getComputedStyle(this.menu).paddingBottom)
this.menu.scrollTop = itemBottom - menuHeight + menuPaddingBottom
} else if (itemTop < this.menu.scrollTop) {
let menuPaddingTop = parseFloat(window.getComputedStyle(this.menu).paddingTop)
this.menu.scrollTop = itemTop - menuPaddingTop
}
},
recalcMenuPos() { recalcMenuPos() {
if (!this.menu || !this.$refs.inputWrapper) return if (!this.menu || !this.$refs.inputWrapper) return
var boundingBox = this.$refs.inputWrapper.getBoundingClientRect() var boundingBox = this.$refs.inputWrapper.getBoundingClientRect()
@ -222,6 +281,7 @@ export default {
} }
this.textInput = null this.textInput = null
this.currentSearch = null this.currentSearch = null
this.selectedMenuItemIndex = null
this.$emit('input', newSelected) this.$emit('input', newSelected)
this.$nextTick(() => { this.$nextTick(() => {
this.recalcMenuPos() this.recalcMenuPos()
@ -248,6 +308,7 @@ export default {
this.$emit('newItem', item) this.$emit('newItem', item)
this.textInput = null this.textInput = null
this.currentSearch = null this.currentSearch = null
this.selectedMenuItemIndex = null
this.$nextTick(() => { this.$nextTick(() => {
this.blur() this.blur()
}) })

View File

@ -20,7 +20,7 @@
<ul ref="menu" v-show="showMenu" class="absolute z-60 w-full bg-bg border border-black-200 shadow-lg max-h-56 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label"> <ul ref="menu" v-show="showMenu" class="absolute z-60 w-full bg-bg border border-black-200 shadow-lg max-h-56 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label">
<template v-for="item in itemsToShow"> <template v-for="item in itemsToShow">
<li :key="item.id" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent> <li :key="item.id" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" :class="itemsToShow[selectedMenuItemIndex] === item ? 'text-yellow-300' : ''" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent>
<div class="flex items-center"> <div class="flex items-center">
<span class="font-normal ml-3 block truncate">{{ item.name }}</span> <span class="font-normal ml-3 block truncate">{{ item.name }}</span>
</div> </div>
@ -63,7 +63,8 @@ export default {
typingTimeout: null, typingTimeout: null,
isFocused: false, isFocused: false,
menu: null, menu: null,
items: [] items: [],
selectedMenuItemIndex: null
} }
}, },
watch: { watch: {
@ -122,7 +123,35 @@ export default {
this.items = results || [] this.items = results || []
}, },
keydownInput() { keydownInput(event) {
let items = this.itemsToShow
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault()
if (!items.length) return
if (event.key === 'ArrowDown') {
if (this.selectedMenuItemIndex === null) {
this.selectedMenuItemIndex = 0
} else {
this.selectedMenuItemIndex = Math.min(this.selectedMenuItemIndex + 1, items.length - 1)
}
} else if (event.key === 'ArrowUp') {
if (this.selectedMenuItemIndex === null) {
this.selectedMenuItemIndex = items.length - 1
} else {
this.selectedMenuItemIndex = Math.max(this.selectedMenuItemIndex - 1, 0)
}
}
this.recalcScroll()
return
} else if (event.key === 'Enter') {
if (this.selectedMenuItemIndex !== null) {
this.clickedOption(event, items[this.selectedMenuItemIndex])
} else {
this.submitForm()
}
return
}
this.selectedMenuItemIndex = null
clearTimeout(this.typingTimeout) clearTimeout(this.typingTimeout)
this.typingTimeout = setTimeout(() => { this.typingTimeout = setTimeout(() => {
this.search() this.search()
@ -137,6 +166,24 @@ export default {
this.recalcMenuPos() this.recalcMenuPos()
}, 50) }, 50)
}, },
recalcScroll() {
if (!this.menu) return
var menuItems = this.menu.querySelectorAll('li')
if (!menuItems.length) return
var selectedItem = menuItems[this.selectedMenuItemIndex]
if (!selectedItem) return
var menuHeight = this.menu.offsetHeight
var itemHeight = selectedItem.offsetHeight
var itemTop = selectedItem.offsetTop
var itemBottom = itemTop + itemHeight
if (itemBottom > this.menu.scrollTop + menuHeight) {
let menuPaddingBottom = parseFloat(window.getComputedStyle(this.menu).paddingBottom)
this.menu.scrollTop = itemBottom - menuHeight + menuPaddingBottom
} else if (itemTop < this.menu.scrollTop) {
let menuPaddingTop = parseFloat(window.getComputedStyle(this.menu).paddingTop)
this.menu.scrollTop = itemTop - menuPaddingTop
}
},
recalcMenuPos() { recalcMenuPos() {
if (!this.menu || !this.$refs.inputWrapper) return if (!this.menu || !this.$refs.inputWrapper) return
var boundingBox = this.$refs.inputWrapper.getBoundingClientRect() var boundingBox = this.$refs.inputWrapper.getBoundingClientRect()
@ -247,6 +294,7 @@ export default {
} }
this.textInput = null this.textInput = null
this.currentSearch = null this.currentSearch = null
this.selectedMenuItemIndex = null
this.$emit('input', newSelected) this.$emit('input', newSelected)
this.$nextTick(() => { this.$nextTick(() => {
@ -274,6 +322,7 @@ export default {
this.$emit('newItem', item) this.$emit('newItem', item)
this.textInput = null this.textInput = null
this.currentSearch = null this.currentSearch = null
this.selectedMenuItemIndex = null
this.$nextTick(() => { this.$nextTick(() => {
this.blur() this.blur()
}) })