Generic composable function for Quasar QSelect with filtering
A Vue composable for simplifying the usage of QSelect with filtering in Quasar.
A Vue composable for simplifying the usage of QSelect with filtering in Quasar.
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).
You can create a composable useFilteredSelect
that encapsulates and hides all the complexity.
This composable:
receives as parameters:
props
(so that you don't need to repeat them for multiple QSelects)returns a reactive object that can be directly use with v-bind
on QSelect - the returned object has:
props
options
key with the filtered list of optionsonFilter
event handler for QSelectloading
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:
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 casesimport { 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);
}
<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>