mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2024-12-20 19:06:06 +01:00
Merge pull request #3294 from mikiher/menu-keyboard-navigation-refactor
Refactor menu keyoboard navigation into mixin
This commit is contained in:
commit
9e0f17f7c6
@ -8,7 +8,7 @@
|
|||||||
<div class="bg-bg rounded-lg px-2 py-6 sm:p-6 md:p-8" @click.stop>
|
<div class="bg-bg rounded-lg px-2 py-6 sm:p-6 md:p-8" @click.stop>
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<div class="flex-grow p-1 min-w-48 sm:min-w-64 md:min-w-80">
|
<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>
|
||||||
<div class="w-24 sm:w-28 md:w-40 p-1">
|
<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" />
|
<ui-text-input-with-label ref="sequenceInput" v-model="selectedSeries.sequence" :label="$strings.LabelSequence" />
|
||||||
@ -66,6 +66,11 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
seriesNameInputHandler() {
|
||||||
|
if (this.$refs.sequenceInput) {
|
||||||
|
this.$refs.sequenceInput.setFocus()
|
||||||
|
}
|
||||||
|
},
|
||||||
setInputFocus() {
|
setInputFocus() {
|
||||||
if (this.isNewSeries) {
|
if (this.isNewSeries) {
|
||||||
// Focus on series input if new series
|
// Focus on series input if new series
|
||||||
|
@ -4,13 +4,13 @@
|
|||||||
<div ref="wrapper" class="relative">
|
<div ref="wrapper" class="relative">
|
||||||
<form @submit.prevent="submitForm">
|
<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'">
|
<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>
|
</div>
|
||||||
</form>
|
</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">
|
<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">
|
<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">
|
<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>
|
||||||
@ -30,7 +30,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import menuKeyboardNavigationMixin from '@/mixins/menuKeyboardNavigation'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
mixins: [menuKeyboardNavigationMixin],
|
||||||
props: {
|
props: {
|
||||||
value: [String, Number],
|
value: [String, Number],
|
||||||
disabled: Boolean,
|
disabled: Boolean,
|
||||||
@ -81,6 +84,9 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
keydownHandler(e) {
|
||||||
|
this.menuNavigationHandler(e)
|
||||||
|
},
|
||||||
setFocus() {
|
setFocus() {
|
||||||
if (this.$refs.input && this.editable) this.$refs.input.focus()
|
if (this.$refs.input && this.editable) this.$refs.input.focus()
|
||||||
},
|
},
|
||||||
|
@ -37,7 +37,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import menuKeyboardNavigationMixin from '@/mixins/menuKeyboardNavigation'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
mixins: [menuKeyboardNavigationMixin],
|
||||||
props: {
|
props: {
|
||||||
value: {
|
value: {
|
||||||
type: Array,
|
type: Array,
|
||||||
@ -63,8 +66,7 @@ export default {
|
|||||||
typingTimeout: null,
|
typingTimeout: null,
|
||||||
isFocused: false,
|
isFocused: false,
|
||||||
menu: null,
|
menu: null,
|
||||||
filteredItems: null,
|
filteredItems: null
|
||||||
selectedMenuItemIndex: null
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
@ -119,34 +121,8 @@ export default {
|
|||||||
this.filteredItems = results || []
|
this.filteredItems = results || []
|
||||||
},
|
},
|
||||||
keydownInput(event) {
|
keydownInput(event) {
|
||||||
let items = this.itemsToShow
|
this.menuNavigationHandler(event)
|
||||||
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()
|
||||||
@ -161,24 +137,6 @@ 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()
|
||||||
@ -317,9 +275,6 @@ export default {
|
|||||||
this.textInput = null
|
this.textInput = null
|
||||||
this.currentSearch = null
|
this.currentSearch = null
|
||||||
this.selectedMenuItemIndex = null
|
this.selectedMenuItemIndex = null
|
||||||
this.$nextTick(() => {
|
|
||||||
this.blur()
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
submitForm() {
|
submitForm() {
|
||||||
if (!this.textInput) return
|
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">
|
<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" :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">
|
<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>
|
||||||
@ -40,7 +40,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import menuKeyboardNavigationMixin from '@/mixins/menuKeyboardNavigation'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
mixins: [menuKeyboardNavigationMixin],
|
||||||
props: {
|
props: {
|
||||||
value: {
|
value: {
|
||||||
type: Array,
|
type: Array,
|
||||||
@ -63,8 +66,7 @@ export default {
|
|||||||
typingTimeout: null,
|
typingTimeout: null,
|
||||||
isFocused: false,
|
isFocused: false,
|
||||||
menu: null,
|
menu: null,
|
||||||
items: [],
|
items: []
|
||||||
selectedMenuItemIndex: null
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
@ -124,34 +126,7 @@ export default {
|
|||||||
this.items = results || []
|
this.items = results || []
|
||||||
},
|
},
|
||||||
keydownInput(event) {
|
keydownInput(event) {
|
||||||
let items = this.itemsToShow
|
this.menuNavigationHandler(event)
|
||||||
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()
|
||||||
@ -166,24 +141,6 @@ 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()
|
||||||
@ -323,9 +280,6 @@ export default {
|
|||||||
this.textInput = null
|
this.textInput = null
|
||||||
this.currentSearch = null
|
this.currentSearch = null
|
||||||
this.selectedMenuItemIndex = null
|
this.selectedMenuItemIndex = null
|
||||||
this.$nextTick(() => {
|
|
||||||
this.blur()
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
submitForm() {
|
submitForm() {
|
||||||
if (!this.textInput) return
|
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