SelectChipInput.vue 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230
  1. <template>
  2. <div class="formcontrol">
  3. <label class="formcontrol-label" for="" v-if="optional || label">
  4. {{ label }}
  5. <div class="formcontrol-optional" v-if="optional">Optional</div>
  6. </label>
  7. <div ref="container" class="select-multiple" :class="{ 'select-multiple--expanded': expanded }">
  8. <div class="select-multiple-value" @click="openDropDown">
  9. <span v-if="checkedItems.length === 0"></span>
  10. <div v-for="item of displayedItems" class="chip" :key="item.id">
  11. <span class="chip-label">{{ item.text }}</span>
  12. <button
  13. aria-label="Clear"
  14. class="chip-remove-btn"
  15. @click="toggle(item.index, $event)"
  16. ></button>
  17. </div>
  18. <div v-if="checkedItems.length > maxItemDisplayed" class="chip">
  19. <div class="chip-label">+{{ checkedItems.length - maxItemDisplayed }}</div>
  20. </div>
  21. <input
  22. class="select-multiple-input"
  23. ref="input"
  24. :placeholder="currentPlaceholder"
  25. v-model="inputValue"
  26. :size="Math.max(inputValue.length, currentPlaceholder.length)"
  27. @keyup="keyUp"
  28. />
  29. </div>
  30. <div class="dropdown-options pickable">
  31. <div
  32. v-for="(item, index) in filteredItems"
  33. :class="{
  34. 'select--checked': item.isChecked,
  35. 'select--active': index == activeIndex,
  36. }"
  37. :key="item.value"
  38. @click="toggle(item.index, $event)"
  39. v-html="highlight(item.text)"
  40. ></div>
  41. </div>
  42. </div>
  43. </div>
  44. </template>
  45. <script lang="ts">
  46. import "@/assets/css/chip.css";
  47. import AutocompleteValues from "@/models/AutocompleteOptions";
  48. import { defineComponent, PropType } from "vue";
  49. interface selectItem {
  50. index: number;
  51. isChecked: boolean;
  52. value: string;
  53. text: string;
  54. }
  55. export default defineComponent({
  56. name: "selectChip",
  57. props: {
  58. label: String,
  59. optional: Boolean,
  60. // Values selected by the component
  61. modelValue: {
  62. type: Array as PropType<Array<string>>,
  63. default: () => [],
  64. },
  65. /*
  66. * Potential values that could be selected by the user
  67. * type Array<String> | Array<AutocompleteValues>
  68. */
  69. autocompleteList: {
  70. type: Array as PropType<Array<string | AutocompleteValues>>,
  71. default: () => [],
  72. },
  73. placeholder: {
  74. type: String,
  75. default: "Choose among the list",
  76. },
  77. secondaryPlaceholder: {
  78. type: String,
  79. default: "+ ",
  80. },
  81. // Number of selected item displayed before displaying an ellipsis appear
  82. maxItemDisplayed: {
  83. type: Number,
  84. default: Infinity,
  85. },
  86. },
  87. data() {
  88. return {
  89. items: [] as Array<selectItem>,
  90. expanded: false,
  91. inputValue: "",
  92. activeIndex: -1,
  93. };
  94. },
  95. watch: {
  96. modelValue: function () {
  97. this.updateItems();
  98. },
  99. },
  100. emit: ["update:modelValue"],
  101. methods: {
  102. focus() {
  103. if (this.input) {
  104. this.input.focus();
  105. }
  106. },
  107. toggle(idx: number, e: MouseEvent): void {
  108. e.stopPropagation();
  109. this.items[idx].isChecked = !this.items[idx].isChecked;
  110. this.emitValue();
  111. this.focus();
  112. },
  113. emitValue() {
  114. const values = this.items.filter((o) => o.isChecked).map((o) => o.value);
  115. this.$emit("update:modelValue", values);
  116. },
  117. updateItems(): void {
  118. this.items = this.autocompleteList.map(
  119. (o: AutocompleteValues | string, idx: number): selectItem => {
  120. let id: string, txt: string;
  121. if (typeof o == "object") {
  122. id = "id" in o ? o.id : o;
  123. txt = "name" in o ? o.name : id;
  124. } else {
  125. id = o;
  126. txt = o;
  127. }
  128. return {
  129. index: idx,
  130. isChecked: this.modelValue.includes(id),
  131. value: id,
  132. text: txt,
  133. };
  134. }
  135. );
  136. },
  137. openDropDown: function (e: MouseEvent) {
  138. this.expanded = true;
  139. this.focus();
  140. window.addEventListener("click", this.closeDropDown);
  141. e.stopPropagation();
  142. },
  143. closeDropDown: function (e: MouseEvent) {
  144. if (this.$refs["container"]) {
  145. if (!(this.$refs["container"] as HTMLElement).contains(e.target as Node)) {
  146. this.expanded = false;
  147. window.removeEventListener("click", this.closeDropDown);
  148. e.stopPropagation();
  149. }
  150. }
  151. },
  152. highlight(txt: string): string {
  153. if (this.inputValue) {
  154. let reg = new RegExp(this.inputValue, "ig");
  155. let boldTxt = txt.match(reg);
  156. if (boldTxt !== null) {
  157. const plain = txt.split(reg);
  158. let output = "";
  159. for (let i = 0; i < boldTxt.length; i++) {
  160. output += plain[i] + "<b>" + boldTxt[i] + "</b>";
  161. }
  162. return output + plain.pop();
  163. }
  164. }
  165. return txt;
  166. },
  167. keyUp(e: KeyboardEvent) {
  168. if (e.key == "Enter") {
  169. if (this.selectedItem) this.selectedItem.isChecked = !this.selectedItem.isChecked;
  170. this.emitValue();
  171. }
  172. if (e.key == "ArrowUp") {
  173. this.activeIndex--;
  174. }
  175. if (e.key == "ArrowDown") {
  176. this.activeIndex++;
  177. }
  178. this.boundActiveIndex();
  179. /*
  180. if(e.key=="Backspace" && this.inputValue==""){
  181. // Remove the last entry
  182. const lastItem = this.checkedItems[this.checkedItems.length-1]
  183. if (lastItem){
  184. lastItem.isChecked = false
  185. }
  186. }
  187. */
  188. },
  189. boundActiveIndex() {
  190. if (this.activeIndex < 0) {
  191. this.activeIndex = this.filteredItems.length;
  192. }
  193. if (this.activeIndex > this.filteredItems.length) {
  194. this.activeIndex = 0;
  195. }
  196. },
  197. },
  198. computed: {
  199. checkedItems(): Array<selectItem> {
  200. return this.items.filter((i) => i.isChecked);
  201. },
  202. displayedItems(): Array<selectItem> {
  203. return this.checkedItems.filter((i, idx) => idx < this.maxItemDisplayed);
  204. },
  205. input(): HTMLInputElement {
  206. return this.$refs["input"] as HTMLInputElement;
  207. },
  208. filteredItems(): Array<selectItem> {
  209. if (this.inputValue) {
  210. const low = this.inputValue.toLowerCase();
  211. const output = this.items.filter((o) => o.text.toLowerCase().includes(low));
  212. return output;
  213. }
  214. return this.items;
  215. },
  216. selectedItem(): selectItem | undefined {
  217. return this.filteredItems[this.activeIndex];
  218. },
  219. currentPlaceholder(): string {
  220. return this.checkedItems.length > 0 ? this.secondaryPlaceholder : this.placeholder;
  221. },
  222. },
  223. beforeMount: function () {
  224. this.updateItems();
  225. },
  226. });
  227. </script>
  228. <style src="../assets/css/multiple-select.css"></style>