AutoComplete.vue 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231
  1. <template>
  2. <div class="autocomplete-wrapper">
  3. <input
  4. ref="acInput"
  5. v-model="text"
  6. type="text"
  7. autocomplete="off"
  8. :placeholder="placeholder"
  9. :spellcheck="spellcheck"
  10. :autocapitalize="autocapitalize"
  11. :autocorrect="spellcheck"
  12. :class="styles"
  13. @input="updateSuggestions"
  14. @keyup="updateSuggestions"
  15. @click="updateSuggestions"
  16. @keydown="handleKeystroke"
  17. @change="$emit('change', $event)"
  18. />
  19. <ul
  20. v-if="suggestions.length > 0 && suggestionsVisible"
  21. class="suggestions"
  22. :style="{ transform: `translate(${suggestionsOffsetLeft}px, 0)` }"
  23. >
  24. <li
  25. v-for="(suggestion, index) in suggestions"
  26. :key="`suggestion-${index}`"
  27. :class="{ active: currentSuggestionIndex === index }"
  28. @click.prevent="forceSuggestion(suggestion)"
  29. >
  30. {{ suggestion }}
  31. </li>
  32. </ul>
  33. </div>
  34. </template>
  35. <script>
  36. import { defineComponent } from "@nuxtjs/composition-api"
  37. export default defineComponent({
  38. props: {
  39. spellcheck: {
  40. type: Boolean,
  41. default: true,
  42. required: false,
  43. },
  44. autocapitalize: {
  45. type: String,
  46. default: "off",
  47. required: false,
  48. },
  49. placeholder: {
  50. type: String,
  51. default: "",
  52. required: false,
  53. },
  54. source: {
  55. type: Array,
  56. required: true,
  57. },
  58. value: {
  59. type: String,
  60. default: "",
  61. required: false,
  62. },
  63. styles: {
  64. type: String,
  65. default: "",
  66. },
  67. },
  68. data() {
  69. return {
  70. text: this.value,
  71. selectionStart: 0,
  72. suggestionsOffsetLeft: 0,
  73. currentSuggestionIndex: -1,
  74. suggestionsVisible: false,
  75. }
  76. },
  77. computed: {
  78. /**
  79. * Gets the suggestions list to be displayed under the input box.
  80. *
  81. * @returns {default.props.source|{type, required}}
  82. */
  83. suggestions() {
  84. const input = this.text.substring(0, this.selectionStart)
  85. return (
  86. this.source
  87. .filter(
  88. (entry) =>
  89. entry.toLowerCase().startsWith(input.toLowerCase()) &&
  90. input.toLowerCase() !== entry.toLowerCase()
  91. )
  92. // Cut off the part that's already been typed.
  93. .map((entry) => entry.substring(this.selectionStart))
  94. // We only want the top 6 suggestions.
  95. .slice(0, 6)
  96. )
  97. },
  98. },
  99. watch: {
  100. text() {
  101. this.$emit("input", this.text)
  102. },
  103. value(newValue) {
  104. this.text = newValue
  105. },
  106. },
  107. mounted() {
  108. this.updateSuggestions({
  109. target: this.$refs.acInput,
  110. })
  111. },
  112. methods: {
  113. updateSuggestions(event) {
  114. // Hide suggestions if ESC pressed.
  115. if (event.code && event.code === "Escape") {
  116. event.preventDefault()
  117. this.suggestionsVisible = false
  118. this.currentSuggestionIndex = -1
  119. return
  120. }
  121. // As suggestions is a reactive property, this implicitly
  122. // causes suggestions to update.
  123. this.selectionStart = this.$refs.acInput.selectionStart
  124. this.suggestionsOffsetLeft = 12 * this.selectionStart
  125. this.suggestionsVisible = true
  126. },
  127. forceSuggestion(text) {
  128. const input = this.text.substring(0, this.selectionStart)
  129. this.text = input + text
  130. this.selectionStart = this.text.length
  131. this.suggestionsVisible = true
  132. this.currentSuggestionIndex = -1
  133. },
  134. handleKeystroke(event) {
  135. switch (event.code) {
  136. case "ArrowUp":
  137. event.preventDefault()
  138. this.currentSuggestionIndex =
  139. this.currentSuggestionIndex - 1 >= 0
  140. ? this.currentSuggestionIndex - 1
  141. : 0
  142. break
  143. case "ArrowDown":
  144. event.preventDefault()
  145. this.currentSuggestionIndex =
  146. this.currentSuggestionIndex < this.suggestions.length - 1
  147. ? this.currentSuggestionIndex + 1
  148. : this.suggestions.length - 1
  149. break
  150. case "Tab": {
  151. const activeSuggestion =
  152. this.suggestions[
  153. this.currentSuggestionIndex >= 0 ? this.currentSuggestionIndex : 0
  154. ]
  155. if (!activeSuggestion) {
  156. return
  157. }
  158. event.preventDefault()
  159. const input = this.text.substring(0, this.selectionStart)
  160. this.text = input + activeSuggestion
  161. break
  162. }
  163. }
  164. },
  165. },
  166. })
  167. </script>
  168. <style scoped lang="scss">
  169. .autocomplete-wrapper {
  170. @apply relative;
  171. @apply contents;
  172. input:focus + ul.suggestions,
  173. ul.suggestions:hover {
  174. @apply block;
  175. }
  176. ul.suggestions {
  177. @apply hidden;
  178. @apply bg-popover;
  179. @apply absolute;
  180. @apply mx-2;
  181. @apply left-0;
  182. @apply z-50;
  183. @apply shadow-lg;
  184. top: calc(100% - 4px);
  185. border-radius: 0 0 8px 8px;
  186. li {
  187. @apply w-full;
  188. @apply block;
  189. @apply py-2 px-4;
  190. @apply text-secondary;
  191. &:last-child {
  192. border-radius: 0 0 8px 8px;
  193. }
  194. &:hover,
  195. &.active {
  196. @apply bg-accent;
  197. @apply text-accentContrast;
  198. @apply cursor-pointer;
  199. }
  200. }
  201. }
  202. }
  203. </style>