Skip to content
Generic composable function for Quasar QSelect with filtering

Generic composable function for Quasar QSelect with filtering

A Vue composable for simplifying the usage of QSelect with filtering in Quasar.

quasarjavascripttypescriptcomposable
by
PDan

Problem description

In order to make a Quasar QSelect filterable you need a @filter function that updates a destination variable that is used for :options.

Most of the time this requires an original (complete) list of options, a filtering function and a filtered list of options.

Also you may want to keep the options list opened while filtering (when you are using async filtering).

Solution

You can create a composable useFilteredSelect that encapsulates and hides all the complexity.

This composable:

  • receives as parameters:

    • a list (or ref or computed of a list) of options OR a function returning a list (or ref or computed of a list) of options or a promise of a list
    • an optional filtering function (you can skip this if you want to search the list options as string)
    • an optional object of QSelect props (so that you don't need to repeat them for multiple QSelects)
    • a callback function to be called after the QSelect list is updated (it is called with a ref to QSelect)
  • returns a reactive object that can be directly use with v-bind on QSelect - the returned object has:

    • the optional props
    • an options key with the filtered list of options
    • an onFilter event handler for QSelect
    • a loading key with the loading state of the filter (see below)

Keeping the list of options open while filtering

If you want to keep the list of options open while filtering then pass loading: true in the props.

In that case the returned reactive object will also include a loading key that will control the loading indicator on QSelect.

WARNING

The filtered options and loading status are shared by all QSelects using the same returned reactive object.

This composable will be combined with a filtering function. There are three pre-defined functions:

  • one that filters the options as string, case insensitive, match anywhere
  • one that receives a configuration object with the name of a key to search and the compare method and returns a function that filters the list by the content of that key as string, case insensitive, using compare methods (starts width, anywhere, ends with)
  • one that returns the whole list (for server side filtering)

The search function generator createMappedFilterFn gets a configuration object and returns a generic filtering function. You can specify (object and all configs are optional):

  • key: what key in the items to use (leave empty to search in the whole item as string)
  • compareType: one of 'includes', 'startsWith', or 'endsWith' (defaults to 'includes')
  • getMatchFn: a function that takes what you are searching for (needle) and returns a function that takes an item and returns a truthy value (if it matches or not) - you can use this to customize for special cases
ts
import { shallowRef, ref, unref, reactive } from 'vue';
import type { MaybeRef, ShallowRef } from 'vue';
import type { QSelect, QSelectProps } from 'quasar';

type MaybePromise<T> = T | Promise<T> | PromiseLike<T>;

interface UseProps extends Partial<Omit<QSelectProps, 'loading' | 'onFilter' | 'options'>> {
  options: ShallowRef<QSelectProps[ 'options' ]>;
  onFilter: QSelectProps[ 'onFilter' ];
  loading?: MaybeRef<boolean>;
}

export function createMappedFilterFn(config?: {
  key?: string | null;
  compareType?: 'includes' | 'startsWith' | 'endsWith';
  getMatchFn?: (needle: string) => <T>(item: T, index: number, list: T[]) => boolean;
}) {
  const compareType = config?.compareType || 'includes';
  const key = config?.key || '';
  const getMatchFn = config?.getMatchFn || (
    key !== ''
      ? (needle: string) => <T extends Record<string, unknown>>(item: T) => String(item[ key ]).toLocaleLowerCase()[ compareType ](needle)
      : (needle: string) => <T>(item: T) => String(item).toLocaleLowerCase()[ compareType ](needle)
  );

  return function <T>(search: string, list: T[]) {
    if ([ null, undefined, '' ].includes(search)) {
      return list;
    }

    const needle = String(search).toLocaleLowerCase();

    return list.filter(getMatchFn(needle) as (item: T) => boolean);
  };
}

export const stringFilterFn = createMappedFilterFn();
export const noFilterFn = <T>(_: unknown, items: T[]) => items;

export function useFilteredSelect<T>(
  optionsOrFn:
    | MaybeRef<T[]>
    | ((search: string) => MaybePromise<MaybeRef<T[]>>),
  filterFn: (search: string, list: T[]) => T[] = stringFilterFn,
  props?: Partial<QSelectProps>,
  afterFn?: (ref: QSelect) => void,
) {
  const getOptions = typeof optionsOrFn === 'function' ? optionsOrFn : () => optionsOrFn;
  const filteredOpts = shallowRef(typeof optionsOrFn === 'function' ? [] : unref(optionsOrFn));
  const loading = ref(false);
  const keepOpen = props && props.loading && unref(props.loading) === true;

  const useProps: UseProps = {
    ...props,
    options: filteredOpts,
    onFilter(
      search: string,
      doneFn: (callbackFn: () => void, afterFn?: (ref: QSelect) => void) => void,
      abortFn: () => void,
    ) {
      if (keepOpen === true) {
        doneFn(() => {});
      }
      loading.value = true;

      Promise.resolve(getOptions(search))
        .then((options) => {
          const newOpts = filterFn(search, unref(options));
          doneFn(() => {
            filteredOpts.value = newOpts;
            loading.value = false;
          }, afterFn);
        })
        .catch(() => {
          abortFn();
          loading.value = false;
        });
    },
  };

  if (keepOpen === true) {
    useProps.loading = loading;
  }

  return reactive(useProps);
}
vue
<template>
  <div class="col column no-wrap">
    <div class="q-pa-md column q-gutter-y-md" style="max-width: 400px">
      <q-select
        v-model="model1"
        label="String filter"
        v-bind="filteredSelectProps1"
      >
        <template #no-option>
          <q-item>
            <q-item-section class="text-grey"> No results </q-item-section>
          </q-item>
        </template>
      </q-select>

      <q-select
        v-model="model2"
        label="Obj filter - starts with"
        v-bind="filteredSelectProps2"
      >
        <template #no-option>
          <q-item>
            <q-item-section class="text-grey"> No results </q-item-section>
          </q-item>
        </template>
      </q-select>

      <q-select
        v-model="model3"
        label="Obj filter - mapped"
        v-bind="filteredSelectProps3"
      >
        <template #no-option>
          <q-item>
            <q-item-section class="text-grey"> No results </q-item-section>
          </q-item>
        </template>
      </q-select>

      <q-select
        v-model="model4"
        label="String filter - async get full list"
        hint="Keep the list open on filtering"
        v-bind="filteredSelectProps4"
      >
        <template #no-option>
          <q-item>
            <q-item-section class="text-grey"> No results </q-item-section>
          </q-item>
        </template>
      </q-select>

      <q-select
        v-model="model5"
        label="String filter - async get filtered list"
        v-bind="filteredSelectProps5"
      >
        <template #no-option>
          <q-item>
            <q-item-section class="text-grey"> No results </q-item-section>
          </q-item>
        </template>
      </q-select>

      <q-select
        v-model="model6"
        label="Ref string filter"
        v-bind="filteredSelectProps6"
      >
        <template #no-option>
          <q-item>
            <q-item-section class="text-grey"> No results </q-item-section>
          </q-item>
        </template>
      </q-select>
    </div>
  </div>
</template>

<script lang="ts">
const stringOptions = [
  'Google',
  'Facebook',
  'Twitter',
  'Apple',
  'Oracle',
].reduce((acc, name) => {
  for (let i = 100; i <= 300; i += 1) {
    acc.push(`${name} - ${i}`);
  }
  return acc;
}, [] as string[]);

const objOptions = [
  { label: 'Google', value: 1 },
  { label: 'Facebook', value: 2 },
  { label: 'Twitter', value: 3 },
  { label: 'Apple', value: 4 },
  { label: 'Oracle', value: 5 },
].reduce((acc, { label, value }) => {
  for (let i = 100; i <= 300; i += 1) {
    acc.push({ label: `${label} - ${i}`, value: `${value}#${i}` });
  }
  return acc;
}, [] as Array<{ label: string, value: string }>);
</script>

<script setup lang="ts">
import { ref } from 'vue';
import {
  useFilteredSelect,
  createMappedFilterFn,
  stringFilterFn,
  noFilterFn,
} from './useFilteredSelect';

const refOptions = ref(stringOptions);

const filteredSelectProps1 = useFilteredSelect(
  stringOptions,
  undefined /* same as stringFunctionFn */,
  { outlined: true, useInput: true, inputDebounce: 0, behavior: 'menu' },
);
const filteredSelectProps2 = useFilteredSelect(
  objOptions,
  createMappedFilterFn({ key: 'label', compareType: 'startsWith' }),
  {
    filled: true,
    useInput: true,
    inputDebounce: 0,
    color: 'red',
    behavior: 'menu',
  },
);
const filteredSelectProps3 = useFilteredSelect(
  objOptions,
  createMappedFilterFn({ key: 'label' }),
  {
    useInput: true,
    mapOptions: true,
    emitValue: true,
    inputDebounce: 0,
    behavior: 'menu',
  },
);
const filteredSelectProps4 = useFilteredSelect(
  () =>
    new Promise<Array<string>>((resolve) => {
      setTimeout(() => {
        resolve(stringOptions);
      }, 500);
    }),
  stringFilterFn /* same as undefined */,
  {
    outlined: true,
    useInput: true,
    inputDebounce: 0,
    loading: true,
    behavior: 'menu',
  },
);
const filteredSelectProps5 = useFilteredSelect(
  (search) =>
    new Promise<Array<string>>((resolve) => {
      setTimeout(() => {
        if ([null, undefined, ''].includes(search)) {
          return resolve(stringOptions);
        }

        const needle = String(search).toLocaleLowerCase();
        resolve(
          stringOptions.filter((item) =>
            String(item).toLocaleLowerCase().includes(needle),
          ),
        );
      }, 500);
    }),
  noFilterFn,
  { outlined: true, useInput: true, inputDebounce: 0, behavior: 'menu' },
);
const filteredSelectProps6 = useFilteredSelect(
  refOptions,
  undefined /* same as stringFunctionFn */,
  { outlined: true, useInput: true, inputDebounce: 0, behavior: 'menu' },
);

const model1 = ref(null);
const model2 = ref(null);
const model3 = ref(null);
const model4 = ref(null);
const model5 = ref(null);
const model6 = ref(null);
</script>

Changelog

2023-12-12

  • fix usage with options passed as ref #16613
  • fix TS typing issues #16613

Demo

Last updated: