mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2024-12-20 19:06:06 +01:00
Refactor menu keyoboard navigation into mixin
This commit is contained in:
parent
f9f89e1e51
commit
f1ddbeadaf
@ -8,7 +8,7 @@
|
||||
<div class="bg-bg rounded-lg px-2 py-6 sm:p-6 md:p-8" @click.stop>
|
||||
<div class="flex">
|
||||
<div class="flex-grow p-1 min-w-48 sm:min-w-64 md:min-w-80">
|
||||
<ui-input-dropdown ref="newSeriesSelect" v-model="selectedSeries.name" :items="existingSeriesNames" :disabled="!isNewSeries" :label="$strings.LabelSeriesName" />
|
||||
<ui-input-dropdown ref="newSeriesSelect" v-model="selectedSeries.name" :items="existingSeriesNames" :disabled="!isNewSeries" :label="$strings.LabelSeriesName" @input="seriesNameInputHandler" />
|
||||
</div>
|
||||
<div class="w-24 sm:w-28 md:w-40 p-1">
|
||||
<ui-text-input-with-label ref="sequenceInput" v-model="selectedSeries.sequence" :label="$strings.LabelSequence" />
|
||||
@ -66,6 +66,11 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
seriesNameInputHandler() {
|
||||
if (this.$refs.sequenceInput) {
|
||||
this.$refs.sequenceInput.setFocus()
|
||||
}
|
||||
},
|
||||
setInputFocus() {
|
||||
if (this.isNewSeries) {
|
||||
// Focus on series input if new series
|
||||
@ -134,4 +139,4 @@ export default {
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
@ -4,13 +4,13 @@
|
||||
<div ref="wrapper" class="relative">
|
||||
<form @submit.prevent="submitForm">
|
||||
<div ref="inputWrapper" class="input-wrapper flex-wrap relative w-full shadow-sm flex items-center border border-gray-600 rounded px-2 py-2" :class="disabled ? 'pointer-events-none bg-black-300 text-gray-400' : 'bg-primary'">
|
||||
<input ref="input" v-model="textInput" :disabled="disabled" :readonly="!editable" class="h-full w-full bg-transparent focus:outline-none px-1" @focus="inputFocus" @blur="inputBlur" />
|
||||
<input ref="input" v-model="textInput" :disabled="disabled" :readonly="!editable" class="h-full w-full bg-transparent focus:outline-none px-1" @focus="inputFocus" @blur="inputBlur" @keydown="keydownHandler" />
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<ul ref="menu" v-show="isFocused && itemsToShow.length" class="absolute z-50 mt-0 w-full bg-bg border border-black-200 shadow-lg max-h-56 rounded 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-3 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-3 cursor-pointer hover:bg-black-400" :class="isMenuItemSelected(item) ? 'text-yellow-300' : ''" 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>
|
||||
@ -30,7 +30,10 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import menuKeyboardNavigationMixin from '@/mixins/menuKeyboardNavigation'
|
||||
|
||||
export default {
|
||||
mixins: [menuKeyboardNavigationMixin],
|
||||
props: {
|
||||
value: [String, Number],
|
||||
disabled: Boolean,
|
||||
@ -81,6 +84,9 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
keydownHandler(e) {
|
||||
this.menuNavigationHandler(e)
|
||||
},
|
||||
setFocus() {
|
||||
if (this.$refs.input && this.editable) this.$refs.input.focus()
|
||||
},
|
||||
|
@ -37,7 +37,10 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import menuKeyboardNavigationMixin from '@/mixins/menuKeyboardNavigation'
|
||||
|
||||
export default {
|
||||
mixins: [menuKeyboardNavigationMixin],
|
||||
props: {
|
||||
value: {
|
||||
type: Array,
|
||||
@ -63,8 +66,7 @@ export default {
|
||||
typingTimeout: null,
|
||||
isFocused: false,
|
||||
menu: null,
|
||||
filteredItems: null,
|
||||
selectedMenuItemIndex: null
|
||||
filteredItems: null
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@ -119,34 +121,8 @@ export default {
|
||||
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
|
||||
this.menuNavigationHandler(event)
|
||||
|
||||
clearTimeout(this.typingTimeout)
|
||||
this.typingTimeout = setTimeout(() => {
|
||||
this.search()
|
||||
@ -161,24 +137,6 @@ 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()
|
||||
@ -317,9 +275,6 @@ export default {
|
||||
this.textInput = null
|
||||
this.currentSearch = null
|
||||
this.selectedMenuItemIndex = null
|
||||
this.$nextTick(() => {
|
||||
this.blur()
|
||||
})
|
||||
},
|
||||
submitForm() {
|
||||
if (!this.textInput) return
|
||||
|
@ -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" :class="itemsToShow[selectedMenuItemIndex] === item ? 'text-yellow-300' : ''" 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="isMenuItemSelected(item) ? 'text-yellow-300' : ''" 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>
|
||||
@ -40,7 +40,10 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import menuKeyboardNavigationMixin from '@/mixins/menuKeyboardNavigation'
|
||||
|
||||
export default {
|
||||
mixins: [menuKeyboardNavigationMixin],
|
||||
props: {
|
||||
value: {
|
||||
type: Array,
|
||||
@ -63,8 +66,7 @@ export default {
|
||||
typingTimeout: null,
|
||||
isFocused: false,
|
||||
menu: null,
|
||||
items: [],
|
||||
selectedMenuItemIndex: null
|
||||
items: []
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@ -124,34 +126,7 @@ export default {
|
||||
this.items = 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
|
||||
this.menuNavigationHandler(event)
|
||||
clearTimeout(this.typingTimeout)
|
||||
this.typingTimeout = setTimeout(() => {
|
||||
this.search()
|
||||
@ -166,24 +141,6 @@ 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()
|
||||
@ -323,9 +280,6 @@ export default {
|
||||
this.textInput = null
|
||||
this.currentSearch = null
|
||||
this.selectedMenuItemIndex = null
|
||||
this.$nextTick(() => {
|
||||
this.blur()
|
||||
})
|
||||
},
|
||||
submitForm() {
|
||||
if (!this.textInput) return
|
||||
|
83
client/mixins/menuKeyboardNavigation.js
Normal file
83
client/mixins/menuKeyboardNavigation.js
Normal file
@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Mixin for keyboard navigation in dropdown menus.
|
||||
* This can be used in any component that has a dropdown menu with <li> items.
|
||||
* The following example shows how to use this mixin in your component:
|
||||
* <template>
|
||||
* <div>
|
||||
* <input type="text" @keydown="menuNavigationHandler">
|
||||
* <ul ref="menu">
|
||||
* <li v-for="(item, index) in itemsToShow" :key="index" :class="isMenuItemSelected(item) ? ... : ''" @click="clickedOption($event, item)">
|
||||
* {{ item }}
|
||||
* </li>
|
||||
* </ul>
|
||||
* </div>
|
||||
* </template>
|
||||
*
|
||||
* This mixin assumes the following are defined in your component:
|
||||
* itemsToShow: Array of items to show in the dropdown
|
||||
* clickedOption: Event handler for when an item is clicked
|
||||
* submitForm: Event handler for when the form is submitted
|
||||
*
|
||||
* It also assumes you have a ref="menu" on the menu element.
|
||||
*/
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
selectedMenuItemIndex: null
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
menuNavigationHandler(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()
|
||||
} else if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
if (this.selectedMenuItemIndex !== null) {
|
||||
this.clickedOption(event, items[this.selectedMenuItemIndex])
|
||||
} else {
|
||||
this.submitForm()
|
||||
}
|
||||
} else {
|
||||
this.selectedMenuItemIndex = null
|
||||
}
|
||||
},
|
||||
recalcScroll() {
|
||||
const menu = this.$refs.menu
|
||||
if (!menu) return
|
||||
var menuItems = menu.querySelectorAll('li')
|
||||
if (!menuItems.length) return
|
||||
var selectedItem = menuItems[this.selectedMenuItemIndex]
|
||||
if (!selectedItem) return
|
||||
var menuHeight = menu.offsetHeight
|
||||
var itemHeight = selectedItem.offsetHeight
|
||||
var itemTop = selectedItem.offsetTop
|
||||
var itemBottom = itemTop + itemHeight
|
||||
if (itemBottom > menu.scrollTop + menuHeight) {
|
||||
let menuPaddingBottom = parseFloat(window.getComputedStyle(menu).paddingBottom)
|
||||
menu.scrollTop = itemBottom - menuHeight + menuPaddingBottom
|
||||
} else if (itemTop < menu.scrollTop) {
|
||||
let menuPaddingTop = parseFloat(window.getComputedStyle(menu).paddingTop)
|
||||
menu.scrollTop = itemTop - menuPaddingTop
|
||||
}
|
||||
},
|
||||
isMenuItemSelected(item) {
|
||||
return this.selectedMenuItemIndex !== null && this.itemsToShow[this.selectedMenuItemIndex] === item
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user