Skip to content

Commit

Permalink
Add keyboard navigation to multi-select components
Browse files Browse the repository at this point in the history
  • Loading branch information
mikiher committed Mar 8, 2024
1 parent b4eed3b commit a5772f6
Show file tree
Hide file tree
Showing 2 changed files with 126 additions and 13 deletions.
84 changes: 74 additions & 10 deletions client/components/ui/MultiSelect.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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">
<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-sky-400' : ''" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent>
<div class="flex items-center">
<span class="font-normal ml-3 block truncate">{{ item }}</span>
</div>
Expand Down Expand Up @@ -62,7 +62,9 @@ export default {
currentSearch: null,
typingTimeout: null,
isFocused: false,
menu: null
menu: null,
filteredItems: null,
selectedMenuItemIndex: null
}
},
watch: {
Expand Down Expand Up @@ -91,24 +93,66 @@ export default {
return classes.join(' ')
},
itemsToShow() {
if (!this.currentSearch || !this.textInput) {
if (!this.currentSearch || !this.textInput || !this.filteredItems) {
return this.items
}
return this.items.filter((i) => {
var iValue = String(i).toLowerCase()
return iValue.includes(this.currentSearch.toLowerCase())
})
return this.filteredItems
}
},
methods: {
editItem(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)
}
console.log('ArrowDown. this.selectedMenuItemIndex=', this.selectedMenuItemIndex)
} else if (event.key === 'ArrowUp') {
if (this.selectedMenuItemIndex === null) {
this.selectedMenuItemIndex = items.length - 1
} else {
this.selectedMenuItemIndex = Math.max(this.selectedMenuItemIndex - 1, 0)
}
console.log('ArrowUp. this.selectedMenuItemIndex=', this.selectedMenuItemIndex)
}
this.recalcScroll()
return
} else if (event.key === 'Enter') {
if (this.selectedMenuItemIndex !== null) {
this.clickedOption(event, items[this.selectedMenuItemIndex])
} else {
console.log('Enter. this.textInput=', this.textInput)
this.submitForm()
}
return
}
this.selectedMenuItemIndex = null
clearTimeout(this.typingTimeout)
this.typingTimeout = setTimeout(() => {
this.currentSearch = this.textInput
this.search()
}, 100)
this.setInputWidth()
},
Expand All @@ -120,6 +164,24 @@ export default {
this.recalcMenuPos()
}, 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() {
if (!this.menu || !this.$refs.inputWrapper) return
var boundingBox = this.$refs.inputWrapper.getBoundingClientRect()
Expand Down Expand Up @@ -219,6 +281,7 @@ export default {
}
this.textInput = null
this.currentSearch = null
this.selectedMenuItemIndex = null
this.$emit('input', newSelected)
this.$nextTick(() => {
this.recalcMenuPos()
Expand All @@ -245,6 +308,7 @@ export default {
this.$emit('newItem', item)
this.textInput = null
this.currentSearch = null
this.selectedMenuItemIndex = null
this.$nextTick(() => {
this.blur()
})
Expand All @@ -260,7 +324,7 @@ export default {
this.clickedOption(null, matchesItem)
} else {
this.insertNewItem(this.textInput)
}
}
},
scroll() {
this.recalcMenuPos()
Expand Down
55 changes: 52 additions & 3 deletions client/components/ui/MultiSelectQueryInput.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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">
<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-sky-400' : ''" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent>
<div class="flex items-center">
<span class="font-normal ml-3 block truncate">{{ item.name }}</span>
</div>
Expand Down Expand Up @@ -63,7 +63,8 @@ export default {
typingTimeout: null,
isFocused: false,
menu: null,
items: []
items: [],
selectedMenuItemIndex: null
}
},
watch: {
Expand Down Expand Up @@ -122,7 +123,35 @@ export default {
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)
this.typingTimeout = setTimeout(() => {
this.search()
Expand All @@ -137,6 +166,24 @@ export default {
this.recalcMenuPos()
}, 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() {
if (!this.menu || !this.$refs.inputWrapper) return
var boundingBox = this.$refs.inputWrapper.getBoundingClientRect()
Expand Down Expand Up @@ -244,6 +291,7 @@ export default {
}
this.textInput = null
this.currentSearch = null
this.selectedMenuItemIndex = null
this.$emit('input', newSelected)
this.$nextTick(() => {
Expand Down Expand Up @@ -271,6 +319,7 @@ export default {
this.$emit('newItem', item)
this.textInput = null
this.currentSearch = null
this.selectedMenuItemIndex = null
this.$nextTick(() => {
this.blur()
})
Expand Down

0 comments on commit a5772f6

Please sign in to comment.