AutoComplete.vue 5.4 KB

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