mirror of
				https://github.com/advplyr/audiobookshelf.git
				synced 2025-10-27 11:18:14 +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
 | 
			
		||||
 | 
			
		||||
@ -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