vuematerial/vue-material

View on GitHub
src/components/MdAutocomplete/MdAutocomplete.vue

Summary

Maintainability
Test Coverage
F
4%
<template>
  <md-field class="md-autocomplete" :class="fieldClasses" md-clearable :md-inline="isBoxLayout">
    <md-menu md-direction="bottom-start" :md-dense="mdDense" md-align-trigger md-full-width :md-active.sync="showMenu">
      <md-input
        v-model="searchTerm"
        v-bind="$attrs"
        :id="mdInputId"
        :name="mdInputName"
        :maxlength="mdInputMaxlength"
        :placeholder="mdInputPlaceholder"
        @focus.stop="openOnFocus"
        @blur="hideOptions"
        @input="onInput"
        @click.stop.prevent="openOnFocus" />

      <md-menu-content :class="contentClasses" v-show="hasScopedEmptySlot || hasFilteredItems">
        <div class="md-autocomplete-loading" v-if="isPromisePending">
          <md-progress-spinner :md-diameter="40" :md-stroke="4" md-mode="indeterminate" />
        </div>

        <div class="md-autocomplete-items" v-if="hasFilteredItems">
          <md-menu-item v-for="(item, index) in getOptions()" :key="index" @click="selectItem(item, $event)">
            <slot name="md-autocomplete-item" :item="item" :term="searchTerm" v-if="$scopedSlots['md-autocomplete-item']" />
            <template v-else>{{ item }}</template>
          </md-menu-item>
        </div>

        <md-menu-item v-else-if="hasScopedEmptySlot">
          <div class="md-autocomplete-empty">
            <slot name="md-autocomplete-empty" :term="searchTerm" />
          </div>
        </md-menu-item>
      </md-menu-content>
    </md-menu>

    <slot />
  </md-field>
</template>

<script>
  import fuzzy from 'fuzzysearch'
  import isPromise from 'is-promise'
  import MdPropValidator from 'core/utils/MdPropValidator'

  export default {
    name: 'MdAutocomplete',
    props: {
      value: {
        type: null,
        required: true
      },
      mdDense: Boolean,
      mdLayout: {
        type: String,
        default: 'floating',
        ...MdPropValidator('md-layout', [
          'floating',
          'box'
        ])
      },
      mdOpenOnFocus: {
        type: Boolean,
        default: true
      },
      mdFuzzySearch: {
        type: Boolean,
        default: true
      },
      mdOptions: {
        type: [Array, Promise],
        required: true
      },
      mdInputName: String,
      mdInputId: String,
      mdInputMaxlength: [String, Number],
      mdInputPlaceholder: [String, Number]
    },
    data () {
      return {
        searchTerm: this.value,
        showMenu: false,
        triggerPopover: false,
        isPromisePending: false,
        filteredAsyncOptions: []
      }
    },
    computed: {
      isBoxLayout () {
        return this.mdLayout === 'box'
      },
      fieldClasses () : string {
        if (this.isBoxLayout) {
          return 'md-autocomplete-box'
        }
        return ''
      },
      contentClasses () {
        if (this.isBoxLayout) {
          return 'md-autocomplete-box-content'
        }
        return ''
      },
      shouldFilter () {
        return this.mdOptions[0] && this.searchTerm
      },
      filteredStaticOptions () {
        if (this.isPromise(this.mdOptions)) {
          return false
        }

        const firstItem = this.mdOptions[0]

        if (this.shouldFilter) {
          if (typeof firstItem === 'string') {
            return this.filterByString()
          } else if (typeof firstItem === 'object') {
            return this.filterByObject()
          }
        }

        return this.mdOptions
      },
      hasFilteredItems () {
        return this.filteredStaticOptions.length > 0 || this.filteredAsyncOptions.length > 0
      },
      hasScopedEmptySlot () {
        return this.$scopedSlots['md-autocomplete-empty']
      }
    },
    watch: {
      mdOptions: {
        deep: true,
        immediate: true,
        handler () {
          if (this.isPromise(this.mdOptions)) {
            this.isPromisePending = true
            this.mdOptions.then(options => {
              this.filteredAsyncOptions = options
              this.isPromisePending = false
            })
          }
        }
      },

      value (val) {
        this.searchTerm = val
      }
    },
    methods: {
      getOptions () {
        if (this.isPromise(this.mdOptions)) {
          return this.filteredAsyncOptions
        }

        return this.filteredStaticOptions
      },
      isPromise (obj) {
        return isPromise(obj)
      },
      matchText (item) {
        const target = item.toLowerCase()
        const search = this.searchTerm.toLowerCase()

        if (this.mdFuzzySearch) {
          return fuzzy(search, target)
        }

        return target.includes(search)
      },
      filterByString () {
        return this.mdOptions.filter(item => this.matchText(item))
      },
      filterByObject () {
        return this.mdOptions.filter(item => {
          const values = Object.values(item)
          const valuesCount = values.length

          for (let i = 0; i <= valuesCount; i++) {
            if (typeof values[i] === 'string' && this.matchText(values[i])) {
              return true
            }
          }
        })
      },
      openOnFocus () {
        if (this.mdOpenOnFocus) {
          this.showOptions()
        }
      },
      onInput (value) {
        this.$emit('input', value)

        if (!this.mdOpenOnFocus) {
          this.showOptions()
        }

        if (this.searchTerm.constructor.toString().match(/function (\w*)/)[1].toLowerCase() !== 'inputevent') {
          this.$emit('md-changed', this.searchTerm)
        }
      },
      showOptions () {
        if (this.showMenu) {
          return false
        }

        this.showMenu = true
        this.$nextTick(() => {
          this.triggerPopover = true
          this.$emit('md-opened')
        })
      },
      hideOptions () {
        this.$nextTick(() => {
          this.triggerPopover = false
          this.$emit('md-closed')
        })
      },
      selectItem (item, $event) {
        const content = $event.target.textContent.trim()

        this.searchTerm = content
        this.$emit('input', item)
        this.$emit('md-selected', item)
        this.hideOptions()
      }
    }
  }
</script>

<style lang="scss">
  @import "~components/MdAnimation/variables";
  @import "~components/MdElevation/mixins";
  @import "~components/MdLayout/mixins";

  .md-autocomplete {
    .md-menu {
      width: 100%;
      display: flex;
    }
  }

  .md-autocomplete-loading {
    display: flex;
    align-items: center;
    justify-content: center;
    position: absolute;
    top: 0;
    right: 0;
    bottom: 0;
    left: 0;
    z-index: 100;
  }

  .md-field.md-inline.md-autocomplete-box {
    @include md-elevation(2);
    padding-top: 2px;
    border-radius: 2px;

    &.md-focused {
      z-index: 120;
    }

    &:before,
    &:after {
      display: none;
    }

    .md-toolbar & {
      min-height: 40px;
      height: 40px;
      margin: 0;
      box-shadow: none;
    }

    .md-menu {
      align-items: center;
    }

    .md-input {
      padding-left: 16px;
    }

    &.md-focused label,
    label,
    .md-input-action {
      top: 50%;
      transform: translateY(-50%);
    }

    .md-input-action {
      right: 8px;
    }

    &.md-focused label,
    label {
      margin-top: 2px;
      left: 16px;
    }
  }

  .md-autocomplete-box-content:after {
    height: 6px;
    position: absolute;
    top: -6px;
    right: 0;
    left: 0;
    z-index: 120;
    border-bottom: 1px solid;
    content: "";
  }
</style>