Multiselect - Rank/prioritize exact matches? (How to extend multiselect?)

tldr; I need guidance on how to properly extend the original multiselect field and override its filtered method. It is working, but definitely isn’t built ‘the right way’ - and the tag component is not appearing correctly.

–

Problem

I have a multiselect of a large number of queried options in the format:

* City, Country
* Country

for example:

...
* Berlin, Germany
* Frankfurt, Germany
* Germany
* Munich, Germany
...

When searching specifically for Germany in a multiselect field, all items would return without prioritizing the exact match of Germany. In an extremely long list this becomes quite cumbersome where the user would have to scroll through the entire list to find the Country itself. (Of course there are ways around adding something more unique to the title, but is not ideal).

Looking through the source it seems it is indeed just an unweighted regex match.

–

Attempted Solution

In trying to solve this, my plan was to extend the multiselect field to use fuse.js for a more robust search. I read through My first Panel field | Kirby CMS but am not entirely sure how to extend <k-multiselect-field>. I’m assuming I can’t just use:

<k-multiselect-field :id="id" :disabled="disabled" :max="max" :min="min" :layout="layout" :options="options" />

since I would need to override this components methods, correct?

In a fairly sloppy form, I’ve just copied the whole field and have adjusted the Vue component to use fuse and it is working except the inherited tag input is not displaying properly (Lacks label, input field, count, dropdown etc…):

The field is actually there and can be clicked on, and excitingly, the search works as I wished:

Compare to the standard multiselect where the more relevant option is buried and the user would have to scroll all the way down to find it:

–

The embarrassingly hacked together plugin

zip file available here: Dropbox - fuse-multiselect.zip - Simplify your life

index.php

<?php

Kirby::plugin('waffl/fusemultiselect', [
	'fields' => [
		'fusemultiselect' => [
			'extends' => 'tags',
			'props' => [
				/**
				 * Unset inherited props
				 */
				'accept' => null,
				/**
				 * Custom icon to replace the arrow down.
				 */
				'icon' => function (string $icon = null) {
					return $icon;
				},
				/**
				 * Enable/disable the search in the dropdown
				 * Also limit displayed items (display: 20)
				 * and set minimum number of characters to search (min: 3)
				 */
				'search' => function ($search = true) {
					return $search;
				},
				/**
				 * If `true`, selected entries will be sorted
				 * according to their position in the dropdown
				 */
				'sort' => function (bool $sort = false) {
					return $sort;
				},
			]
		]
	]
]);

src/index.js

import FuseMultiselectField from "./components/fields/FuseMultiselectField.vue";
panel.plugin('waffl/fusemultiselect', {
	fields: {
		fusemultiselect: FuseMultiselectField
	}
});

src/components/fields/FuseMultiSelectField.vue

<template>
	<div class="fuse-wrapper">
  <k-draggable
    :list="state"
    :options="dragOptions"
    :data-layout="layout"
    element="k-dropdown"
    class="k-multiselect-input"
    @click.native="$refs.dropdown.toggle"
    @end="onInput"
  >
    <k-tag
      v-for="tag in sorted"
      :ref="tag.value"
      :key="tag.value"
      :removable="true"
      @click.native.stop
      @remove="remove(tag)"
      @keydown.native.left="navigate('prev')"
      @keydown.native.right="navigate('next')"
      @keydown.native.down="$refs.dropdown.open"
    >
      <!-- eslint-disable-next-line vue/no-v-html -->
      <span v-html="tag.text" />
    </k-tag>

    <k-dropdown-content
      slot="footer"
      ref="dropdown"
      @open="onOpen"
      @close="onClose"
      @keydown.native.esc.stop="close"
    >
      <k-dropdown-item
        v-if="search"
        icon="search"
        class="k-multiselect-search"
      >
        <input
          ref="search"
          v-model="q"
          :placeholder="search.min ? $t('search.min', { min: search.min }) : $t('search') + ' …'"
          @keydown.esc.stop="escape"
        >
      </k-dropdown-item>

      <div class="k-multiselect-options">
        <k-dropdown-item
          v-for="option in visible"
          :key="option.value"
          :icon="isSelected(option) ? 'check' : 'circle-outline'"
          :class="{
            'k-multiselect-option': true,
            'selected': isSelected(option),
            'disabled': !more
          }"
          @click.prevent="select(option)"
          @keydown.native.enter.prevent.stop="select(option)"
          @keydown.native.space.prevent.stop="select(option)"
        >
          <!-- eslint-disable-next-line vue/no-v-html -->
          <span v-html="option.display" />
          <!-- eslint-disable-next-line vue/no-v-html -->
          <span class="k-multiselect-value" v-html="option.info" />
        </k-dropdown-item>

        <k-dropdown-item
          v-if="filtered.length === 0"
          :disabled="true"
          class="k-multiselect-option"
        >
          {{ emptyLabel }}
        </k-dropdown-item>
      </div>

      <k-button
        v-if="visible.length < filtered.length"
        class="k-multiselect-more"
        @click.stop="limit = false"
      >
        {{ $t("search.all") }} ({{ filtered.length }})
      </k-button>
    </k-dropdown-content>
  </k-draggable>
	</div>
</template>

<script>
import { required, minLength, maxLength } from "vuelidate/lib/validators";
import Fuse from 'fuse.js';

export default {
  inheritAttrs: false,
  props: {
    id: [Number, String],
    disabled: Boolean,
    max: Number,
    min: Number,
    layout: String,
    options: {
      type: Array,
      default() {
        return [];
      }
    },
    required: Boolean,
    search: [Object, Boolean],
    separator: {
      type: String,
      default: ","
    },
    sort: Boolean,
    value: {
      type: Array,
      required: true,
      default() {
        return [];
      }
    }
  },
  data() {
    return {
      state: this.value,
      q: null,
      limit: true,
      scrollTop: 0,
			fuse: new Fuse(this.options, {
				includeScore: true,
				keys: ['text', 'value']
			})
    };
  },
  computed: {
		filtered() {
			if (this.q && this.q.length >= (this.search.min || 0)) {
        return this.fuse.search(this.q)
          .map(option => ({
            ...option,
            display: this.toHighlightedString(option.item.text),
            info: this.toHighlightedString(option.item.value)
          }));
      }

			return this.options.map(option => ({
        ...option,
        display: option.text,
        info: option.value
      }));
		},
    draggable() {
      return this.state.length > 1 && !this.sort;
    },
    dragOptions() {
      return {
        disabled: !this.draggable,
        draggable: ".k-tag",
        delay: 1
      };
    },
    emptyLabel() {
      if (this.q) {
        return this.$t("search.results.none");
      }

      return this.$t("options.none");
    },
    more() {
      return !this.max || this.state.length < this.max;
    },
    regex() {
      return new RegExp(`(${RegExp.escape(this.q)})`, "ig");
    },
    sorted() {
      if (this.sort === false) {
        return this.state;
      }

      let items = this.state;

      const index = x => this.options.findIndex(y => y.value === x.value);
      return items.sort((a, b) => index(a) - index(b));
    },
    visible() {
      if (this.limit) {
        return this.filtered.slice(0, this.search.display || this.filtered.length);
      }

      return this.filtered;
    },
  },
  watch: {
    value(value) {
      this.state = value;
      this.onInvalid();
    }
  },
  mounted() {
    this.onInvalid();
    this.$events.$on("click", this.close);
    this.$events.$on("keydown.cmd.s", this.close);
  },
  destroyed() {
    this.$events.$off("click", this.close);
    this.$events.$off("keydown.cmd.s", this.close);
  },
  methods: {
    add(option) {
      if (this.more === true) {
        this.state.push(option);
        this.onInput();
      }
    },
    blur() {
      this.close();
    },
    close() {
      if (this.$refs.dropdown.isOpen === true) {
        this.$refs.dropdown.close();
        this.limit = true;
      }
    },
    escape() {
      if (this.q) {
        this.q = null;
        return;
      }

      this.close();
    },
    focus() {
      this.$refs.dropdown.open();
    },
    index(option) {
      return this.state.findIndex(item => item.value === option.value);
    },
    isFiltered(option) {
      return String(option.text).match(this.regex) ||
             String(option.value).match(this.regex);
    },
    isSelected(option) {
      return this.index(option) !== -1;
    },
    navigate(direction) {
      let current = document.activeElement;

      switch (direction) {
        case "prev":
          if (
            current &&
            current.previousSibling &&
            current.previousSibling.focus
          ) {
            current.previousSibling.focus();
          }
          break;
        case "next":
          if (
            current &&
            current.nextSibling &&
            current.nextSibling.focus
          ) {
            current.nextSibling.focus();
          }
          break;
      }
    },
    onClose() {
      if (this.$refs.dropdown.isOpen === false) {
        if (document.activeElement === this.$parent.$el) {
          this.q = null;
        }

        this.$parent.$el.focus();
      }
    },
    onInput() {
      this.$emit("input", this.sorted);
    },
    onInvalid() {
      this.$emit("invalid", this.$v.$invalid, this.$v);
    },
    onOpen() {
      this.$nextTick(() => {
        if (this.$refs.search && this.$refs.search.focus) {
          this.$refs.search.focus();
        }

        this.$refs.dropdown.$el.querySelector('.k-multiselect-options').scrollTop = this.scrollTop;
      });
    },
    remove(option) {
      this.state.splice(this.index(option), 1);
      this.onInput();
    },
    select(option) {
      this.scrollTop = this.$refs.dropdown.$el.querySelector('.k-multiselect-options').scrollTop;

      option = { text: option.item.text, value: option.item.value };

      if (this.isSelected(option)) {
        this.remove(option);
      } else {
        this.add(option);
      }
    },
    toHighlightedString(string) {
      // make sure that no HTML exists before in the string
      // to avoid XSS when displaying via `v-html`
      string = this.$helper.string.stripHTML(string);
      return string.replace(this.regex, "<b>$1</b>")
    },
  },
  validations() {
    return {
      state: {
        required: this.required ? required : true,
        minLength: this.min ? minLength(this.min) : true,
        maxLength: this.max ? maxLength(this.max) : true
      }
    };
  }
};
</script>

<style lang="scss">
@import '../../index.css';
</style>

example blueprint for testing:

pages/sandbox.yml

Title: Sandbox

fields:
  fuse:
    type: fusemultiselect
    label: Fuse Test
    accept: options
    search:
      display: 50
    options:
      - Aalen, Germany
      - Bad Mergentheim, Germany
      - Baden-Baden, Germany
      - Bruchsal, Germany
      - Esslingen, Germany
      - Freiburg im Breisgau, Germany
      - Freudenstadt, Germany
      - Friedrichshafen, Germany
      - Göppingen, Germany
      - Hechingen, Germany
      - Heidelberg, Germany
      - Heilbronn, Germany
      - Karlsruhe, Germany
      - Konstanz, Germany
      - Ludwigsburg, Germany
      - Mannheim, Germany
      - Offenburg, Germany
      - Pforzheim, Germany
      - Ravensburg, Germany
      - Reutlingen, Germany
      - Schwäbisch Gmünd, Germany
      - Schwäbisch Hall, Germany
      - Stuttgart, Germany
      - Tübingen, Germany
      - Ulm, Germany
      - Amberg, Germany
      - Ansbach, Germany
      - Aschaffenburg, Germany
      - Augsburg, Germany
      - Bad Reichenhall, Germany
      - Bamberg, Germany
      - Bayreuth, Germany
      - Berchtesgaden, Germany
      - Coburg, Germany
      - Dachau, Germany
      - Deggendorf, Germany
      - Dinkelsbühl, Germany
      - Donauwörth, Germany
      - Erlangen, Germany
      - Freising, Germany
      - Fürth, Germany
      - Füssen, Germany
      - Garmisch-Partenkirchen, Germany
      - Ingolstadt, Germany
      - Kempten, Germany
      - Landshut, Germany
      - Lindau, Germany
      - Memmingen, Germany
      - Mittenwald, Germany
      - Munich, Germany
      - Nördlingen, Germany
      - Nürnberg, Germany
      - Passau, Germany
      - Regensburg, Germany
      - Rothenburg ob der Tauber, Germany
      - Würzburg, Germany
      - Germany

To me that looks as if the k-field and some type of input components are missing.

Thanks @texnixe - I’m sorry with your advice I now realize I copied the component and not the actual field: kirby/MultiselectField.vue at main · getkirby/kirby · GitHub

I suppose then my only real question is - is the best way to accomplish this really to duplicate all the files and build them inside the plugin itself?

I’m assuming the answer is yes given that the it would need to override the field at the subcomponent level itself.