Advanced Select Field
A select field the way production apps actually need it: debounced server-side search, page-based infinite scroll, switchable dataset variants, chips with removal, inline creation of missing options, icon and description rendering, and extensible before--/after-- slots around every inherited QSelect slot. It is a Quasar-based extension — one ivue class driving one SFC around Quasar's QSelect — and the working proof that ivue slots straight into an existing UI framework rather than replacing it. Extracted from a production application built on ivue, with the app's service layer swapped for the playground's ServerApi.
Eight configurations of the same class, live — from a static plain list to avatar-chip multi-select with backend search:
Open in StackBlitz ⚡ — boots the playground on this example's route with the class open.
The performance story
The class exposes 54 derived values as plain getters and exactly one computed() — the writable model proxy, which earns its ~300 bytes as the destructurable v-model handle. In the composable or options-API idiom, each of those 54 derivations is a computed() allocated per instance: a form with ten selects carries ~540 computed refs before the user types a key. Here they cost zero bytes per instance — plain getters on a shared prototype, reactive through leaf tracking, re-derived only when their inputs change and a consumer is watching. That is beyond what a hand-written Quasar wrapper gives you, and it falls out of the standard rather than out of effort.
What to notice in the playground
- Search hits the backend. Typing sends the same
filters=name ILIKE '%…%'expression a PostgreSQL backend consumes — the in-browser mock evaluates the identical grammar, so swapping in a real server changes nothing. - Client-search variant filters the already-fetched list in a plain getter — compare the two side by side.
- Variants switch the field between server-filtered datasets (people / companies) without remounting.
- Create appears when nothing matches; it POSTs and selects the new row.
The source
// ChooseField — an advanced select built around Quasar's QSelect.
//
// Server-side fetch, debounced server search, infinite-scroll pagination,
// client-side refinement (equality filters + 'field:asc' sort), variants
// (switchable filter presets), chips, and create-new-option — all as one
// ivue Reactive() class.
//
// Derivation census: 54 derived values are PLAIN getters (0 bytes/instance,
// reactive via leaf tracking). Exactly 1 computed(): `model` — it must be a
// writable ref handle so the SFC can destructure it as a v-model target and
// route writes through the create-option interception; that stable-handle +
// setter requirement is what earns its ~300 bytes.
import type { QSelect } from 'quasar';
import { computed, ref, shallowRef, watch } from 'vue';
import { Reactive } from '../../../ivue';
import { ServerApi } from '../server/ServerApi';
import type {
ChooseFieldEmits,
ChooseFieldProps,
ChooseFieldVariant,
ChooseOption,
KeyValueRow,
OptionFilter,
} from './ChooseFieldProps';
/** Sentinel `value` of the synthetic "Create …" option row. */
export const CREATE_OPTION_VALUE = '__create_option__';
class $ChooseField {
constructor(
public props: ChooseFieldProps,
public emit: ChooseFieldEmits,
) {
this.activeVariantIndex.value = this.defaultActiveVariantIndex;
if (this.fetchPath) {
this.seedDisplayedFromModel();
this.fetchInitialOptions();
} else {
// Static options: whenever the options prop is reassigned, re-refine.
watch(
() => this.props.options,
() => this.applyFilter(this.searchTerm.value),
{ immediate: true },
);
}
// Server-side query changed (variant switch or prop change) → refetch.
watch(
() => this.serverQuerySignature,
() => this.onServerQueryChanged(),
);
// Client-side refinement changed → re-filter the loaded options.
watch(
() => this.clientQuerySignature,
() => this.applyFilter(this.searchTerm.value),
);
}
// --- state ---
get selectEl() {
return ref<QSelect | null>(null);
}
get displayedOptions() {
return shallowRef<ChooseOption[]>([]);
}
get fetchedOptions() {
return shallowRef<KeyValueRow[]>([]);
}
get searchTerm() {
return ref('');
}
get fetchPage() {
return ref(1);
}
get fetchedPages() {
return shallowRef<Record<number, true>>({});
}
get lastFetchedCount() {
return ref(0);
}
get fetching() {
return ref(false);
}
get creating() {
return ref(false);
}
get errorMessage() {
return ref('');
}
get activeVariantIndex() {
return ref(-1);
}
/**
* v-model proxy — the ONE computed(): a writable ref handle the SFC
* destructures for `v-model`; its setter intercepts the create sentinel.
*/
get model() {
return computed({
get: () => this.props.modelValue,
set: (value: any) => this.onModelWrite(value),
});
}
// --- props passthrough (leaf-tracked plain getters) ---
get multiple() {
return this.props.multiple;
}
get useChips() {
return this.props.useChips;
}
get useInput() {
return this.props.useInput;
}
get inputDebounce() {
return this.props.inputDebounce;
}
get dropdownIcon() {
return this.props.dropdownIcon;
}
get options() {
return this.props.options;
}
get optionValue() {
return this.props.optionValue;
}
get optionsCover() {
return this.props.optionsCover;
}
get prependOptions() {
return this.props.prependOptions;
}
get appendOptions() {
return this.props.appendOptions;
}
get clearable() {
return this.props.clearable;
}
get clearIcon() {
return this.props.clearIcon;
}
get newValueMode() {
return this.props.newValueMode;
}
get optionClass() {
return this.props.optionClass;
}
get icon() {
return this.props.icon;
}
get variants() {
return this.props.variants;
}
get label() {
return this.props.label;
}
get dense() {
return this.props.dense;
}
get disable() {
return this.props.disable;
}
get readonly() {
return this.props.readonly;
}
get outlined() {
return this.props.outlined;
}
get fetchPath() {
return this.props.fetchPath;
}
get fetchScrollThreshold() {
return this.props.fetchScrollThreshold;
}
get fetchRowsPerPage() {
return this.props.fetchRowsPerPage;
}
get createPath() {
return this.props.createPath;
}
get createEntityAsOption() {
return this.props.createEntityAsOption;
}
// --- prop refinements ---
get hint() {
return this.readonly ? undefined : this.props.hint;
}
get hideDropdownIcon() {
return this.readonly || this.props.hideDropdownIcon;
}
get loading() {
return this.props.loading || this.fetching.value || this.creating.value;
}
get createLabel() {
return this.props.createLabel || 'Create new';
}
get fetchOnFocus() {
return !!this.fetchPath && this.props.fetchOnFocus;
}
get fetchSearch() {
return !!this.fetchPath && this.props.fetchSearch;
}
get fetchPagination() {
return !!this.fetchPath && this.props.fetchPagination;
}
get useFetchSearch() {
return this.fetchSearch || this.fetchPagination;
}
get canCreate() {
return !!this.createPath;
}
// --- chips ---
get chipBorderRadius() {
return this.props.roundChips ? '50px' : '5px';
}
get chipClass() {
return [{ 'ivue-chip__singular': !this.multiple }, this.props.chipClass];
}
// --- variants ---
get activeVariant(): ChooseFieldVariant | undefined {
return this.variants.length && this.activeVariantIndex.value > -1
? this.variants[this.activeVariantIndex.value]
: undefined;
}
get defaultActiveVariantIndex() {
const index = this.variants.findIndex((variant) => variant.default);
return index === -1 && this.variants.length ? 0 : index;
}
// Variant-aware query knobs: the active variant overrides the props.
get fetchFilters() {
return this.activeVariant?.fetchFilters ?? this.props.fetchFilters;
}
get fetchSort() {
return this.activeVariant?.fetchSort ?? this.props.fetchSort;
}
get optionFilters(): OptionFilter[] {
return this.activeVariant?.optionFilters ?? this.props.optionFilters;
}
get optionSort() {
return this.activeVariant?.optionSort ?? this.props.optionSort;
}
isActiveVariant(index: number) {
return this.activeVariantIndex.value === index;
}
setVariant(index: number) {
this.activeVariantIndex.value = index;
}
// --- server query (derived, all plain) ---
/** Search text lowercased with single quotes doubled (safe in the filter grammar). */
get escapedSearchTerm() {
return this.searchTerm.value.toLowerCase().replaceAll("'", "''");
}
/** `name ILIKE '%term%' OR id::TEXT ILIKE '%term%'` — the server search expression. */
get fetchSearchQuery() {
if (!this.useFetchSearch || this.searchTerm.value === '') return '';
const term = this.escapedSearchTerm;
return `name ILIKE '%${term}%' OR id::TEXT ILIKE '%${term}%'`;
}
/** fetchFilters and the search expression, each parenthesized, ANDed together. */
get fetchFiltersQuery() {
if (this.fetchFilters && this.fetchSearchQuery) {
return `(${this.fetchFilters}) AND (${this.fetchSearchQuery})`;
}
return this.fetchFilters || this.fetchSearchQuery;
}
get fetchPathQuery() {
const queries: string[] = [];
if (this.fetchPagination) {
queries.push(`page=${this.fetchPage.value}`);
queries.push(`rowsPerPage=${this.fetchRowsPerPage}`);
}
if (this.fetchFiltersQuery) {
queries.push(`filters=${encodeURIComponent(this.fetchFiltersQuery)}`);
}
if (this.fetchSort) {
queries.push(`sort=${encodeURIComponent(this.fetchSort)}`);
}
return queries.join('&');
}
get fetchFullPath() {
const [path, ...queryParts] = this.fetchPath.split('?');
const baseQuery = queryParts.length ? `?${queryParts.join('?')}` : '';
if (!this.fetchPathQuery) return path + baseQuery;
return path + (baseQuery ? `${baseQuery}&` : '?') + this.fetchPathQuery;
}
/** Watch signatures — change means "the query is different now". */
get serverQuerySignature() {
return `${this.fetchFilters}|${this.fetchSort}`;
}
get clientQuerySignature() {
return `${JSON.stringify(this.optionFilters)}|${this.optionSort}`;
}
// --- options resolution (derived, all plain) ---
/** Either the fetched result set or the static options prop, plus pre/append. */
get resolvedOptions(): ChooseOption[] {
return [
...this.prependOptions,
...(this.fetchPath ? this.fetchedOptions.value : this.options),
...this.appendOptions,
];
}
get hasMoreToFetch() {
return this.lastFetchedCount.value === this.fetchRowsPerPage;
}
// --- fetch ---
/** Object model values display in the input before the first fetch lands. */
seedDisplayedFromModel() {
const value = this.props.modelValue;
if (typeof value === 'object' && value !== null) {
this.displayedOptions.value = Array.isArray(value) ? [...value] : [value];
}
}
async fetchInitialOptions() {
if (this.fetchedOptions.value.length) return;
this.fetchedOptions.value = await this.fetchOptionsRequest();
this.applyFilter(this.searchTerm.value);
}
async fetchOptionsRequest(): Promise<KeyValueRow[]> {
this.errorMessage.value = '';
this.fetching.value = true;
this.fetchedPages.value = {
...this.fetchedPages.value,
[this.fetchPage.value]: true,
};
try {
const result = await ServerApi.getPaginated<KeyValueRow>(
this.fetchFullPath,
);
this.lastFetchedCount.value = result.data.length;
this.fetchPage.value++;
return result.data;
} catch (error: any) {
this.errorMessage.value = String(error?.message ?? error);
return [];
} finally {
this.fetching.value = false;
}
}
async refetchOptions() {
this.resetFetchState();
this.fetchedOptions.value = await this.fetchOptionsRequest();
this.applyFilter(this.searchTerm.value);
}
resetFetchState() {
this.fetchPage.value = 1;
this.fetchedPages.value = {};
this.fetchedOptions.value = [];
}
/** Infinite scroll: fetch the next page when the viewport nears the list end. */
async onVirtualScroll(details: { to: number; ref: any }) {
if (!this.fetchPagination) return;
const lastIndex = this.displayedOptions.value.length - 1;
const remainingBelowViewport = lastIndex - details.to;
if (
!this.fetching.value &&
this.hasMoreToFetch &&
remainingBelowViewport < this.fetchScrollThreshold &&
!(this.fetchPage.value in this.fetchedPages.value)
) {
const nextPage = await this.fetchOptionsRequest();
if (nextPage.length) {
this.fetchedOptions.value = [...this.fetchedOptions.value, ...nextPage];
this.applyFilter(this.searchTerm.value);
}
details.ref?.refresh?.();
}
}
async onServerQueryChanged() {
if (this.fetchPath) await this.refetchOptions();
this.applyFilter(this.searchTerm.value);
}
async onFocus() {
if (this.fetchOnFocus) await this.refetchOptions();
}
// --- search & filter ---
/** QSelect @input-value — the raw typed text (already debounced by inputDebounce). */
async onInputValue(value: string) {
this.searchTerm.value = value;
if (this.useFetchSearch) await this.refetchOptions();
}
/** QSelect @filter — must resolve the options inside the update callback. */
onFilter(inputValue: string, update: (callbackFn: () => void) => void) {
update(() => this.applyFilter(inputValue));
}
applyFilter(inputValue: string) {
const refined = this.refineOptions(this.resolvedOptions);
this.displayedOptions.value = this.useFetchSearch
? refined // server already searched
: refined.filter((option) => this.matchesSearch(inputValue, option));
this.prependCreateOptionRow();
}
/** Client-side refinement: `{ key, value }` equality filters, then 'field:asc' sort. */
refineOptions(options: ChooseOption[]): ChooseOption[] {
let refined = options;
if (this.optionFilters.length) {
refined = refined.filter((option) =>
this.optionFilters.every(
(filter) => (option as KeyValueRow)?.[filter.key] === filter.value,
),
);
}
if (this.optionSort) {
refined = [...refined].sort((first, second) =>
this.compareBySort(first, second),
);
}
return refined;
}
compareBySort(first: ChooseOption, second: ChooseOption) {
for (const sortPart of this.optionSort.split(',')) {
const [field, direction] = sortPart.split(':');
const firstValue = (first as KeyValueRow)?.[field.trim()];
const secondValue = (second as KeyValueRow)?.[field.trim()];
if (firstValue === secondValue) continue;
const ascending = (direction?.trim() || 'asc') === 'asc';
return (firstValue > secondValue ? 1 : -1) * (ascending ? 1 : -1);
}
return 0;
}
matchesSearch(inputValue: string, option: ChooseOption) {
const needle = inputValue.toLowerCase().trim();
if (needle === '') return true;
if (typeof option === 'string' || typeof option === 'number') {
return String(option).toLowerCase().includes(needle);
}
// Search the first layer of the option's own values.
return Object.values(option as KeyValueRow).some((cellValue) =>
String(cellValue ?? '')
.toLowerCase()
.includes(needle),
);
}
// --- option label & description resolution ---
optionLabelOf(option: ChooseOption): string {
if (typeof option === 'string' || typeof option === 'number') {
return String(option);
}
return String(this.firstPresentValue(option, this.labelKeys) ?? '');
}
optionDescriptionOf(option: ChooseOption): string {
if (typeof option !== 'object' || option === null) return '';
return String(this.firstPresentValue(option, this.descriptionKeys) ?? '');
}
optionIconOf(option: ChooseOption): string {
return typeof option === 'object' && option !== null
? ((option as KeyValueRow).icon ?? '')
: '';
}
get labelKeys() {
return this.props.optionLabel
? [this.props.optionLabel]
: this.props.optionLabelPriority;
}
get descriptionKeys() {
return this.props.optionDescription
? [this.props.optionDescription]
: this.props.optionDescriptionPriority;
}
firstPresentValue(row: KeyValueRow, keys: string[]) {
for (const key of keys) {
const candidate = row?.[key];
if (candidate !== undefined && candidate !== null && candidate !== '') {
return candidate;
}
}
return undefined;
}
/** QSelect option-value fn: the optionValue prop's key, else id, else the row itself. */
optionValueOf(option: ChooseOption) {
if (typeof option !== 'object' || option === null) return option;
const row = option as KeyValueRow;
if (this.optionValue) return row[this.optionValue];
return row.id ?? row.value ?? row;
}
// --- create new option ---
/** Prepend the synthetic "Create …" row while the user has typed a new term. */
prependCreateOptionRow() {
if (!this.canCreate || !this.createEntityAsOption) return;
if (!this.searchTerm.value.trim()) return;
// An option with this exact label already exists (loaded or selected):
// offer nothing to create — selecting it is the only correct action.
if (this.findOptionByLabel(this.searchTerm.value)) return;
const [firstOption] = this.displayedOptions.value;
if ((firstOption as KeyValueRow)?.value === CREATE_OPTION_VALUE) return;
const term = this.searchTerm.value.trim();
const text = `${this.createLabel || 'Create new'} '${term}'`;
this.displayedOptions.value = [
{
// label under BOTH the default key and the active optionLabel key,
// so custom option-label props still render the affordance text
label: text,
...(this.props.optionLabel ? { [this.props.optionLabel]: text } : {}),
createTerm: term,
icon: 'add',
value: CREATE_OPTION_VALUE,
},
...this.displayedOptions.value,
];
}
isCreateOptionRow(option: ChooseOption) {
return (option as KeyValueRow)?.value === CREATE_OPTION_VALUE;
}
/** POST the typed term as a new entity, add it to the options, select it. */
async createOption() {
const name = this.searchTerm.value.trim();
if (!this.canCreate || !name) return;
// Duplicate guard: an option with the same label (case-insensitive)
// already loaded or already selected gets SELECTED, never re-created.
const existing = this.findOptionByLabel(name);
if (existing) {
this.searchTerm.value = '';
this.selectEl.value?.updateInputValue('', true);
this.applyFilter('');
if (!this.isSelectedOption(existing)) this.selectCreated(existing);
return;
}
this.creating.value = true;
try {
const created = await ServerApi.postCustom(this.createPath, { name });
this.fetchedOptions.value = [created, ...this.fetchedOptions.value];
this.searchTerm.value = '';
this.selectEl.value?.updateInputValue('', true);
this.applyFilter('');
this.selectCreated(created);
} catch (error: any) {
this.errorMessage.value = String(error?.message ?? error);
} finally {
this.creating.value = false;
}
}
findOptionByLabel(label: string): KeyValueRow | undefined {
const wanted = label.trim().toLowerCase();
const pools: any[] = [
...this.fetchedOptions.value,
...(Array.isArray(this.props.modelValue)
? this.props.modelValue
: this.props.modelValue
? [this.props.modelValue]
: []),
];
return pools.find(
(option) =>
String(this.optionLabelOf(option)).trim().toLowerCase() === wanted,
);
}
isSelectedOption(option: KeyValueRow): boolean {
const selected = Array.isArray(this.props.modelValue)
? this.props.modelValue
: this.props.modelValue
? [this.props.modelValue]
: [];
return selected.some(
(entry: any) =>
this.optionValueOf(entry) === this.optionValueOf(option) &&
this.optionLabelOf(entry) === this.optionLabelOf(option),
);
}
selectCreated(created: KeyValueRow) {
if (this.multiple) {
const current = Array.isArray(this.props.modelValue)
? this.props.modelValue
: [];
this.updateModelValue([...current, created]);
} else {
this.updateModelValue(created);
this.selectEl.value?.hidePopup();
}
}
// --- model value ---
/** All writes route here: intercept the create sentinel, pass the rest through. */
onModelWrite(value: any) {
const isArrayValue = Array.isArray(value);
const lastAdded = isArrayValue ? value[value.length - 1] : value;
if ([lastAdded, (lastAdded as KeyValueRow)?.value].includes(CREATE_OPTION_VALUE)) {
this.createOption();
return;
}
this.updateModelValue(value);
}
updateModelValue(value: any) {
this.emit('update:model-value', value);
}
onRemove(details: any) {
this.emit('remove', details);
}
}
export namespace ChooseField {
export const $Class = $ChooseField; // raw — children `extends` this
export let Class = Reactive($Class); // reactive — you `new` this
export type Instance = typeof Class.Instance; // defineExpose type & reactive() interop
}<script lang="ts" setup>
import {
QBtn,
QChip,
QIcon,
QItem,
QItemLabel,
QItemSection,
QSelect,
QTooltip,
} from 'quasar';
import { ChooseField } from './ChooseField';
import {
type ChooseFieldSlots,
chooseFieldEmits,
chooseFieldProps,
} from './ChooseFieldProps';
const props = defineProps(chooseFieldProps);
const emit = defineEmits(chooseFieldEmits);
const choose = new ChooseField.Class(props, emit);
const {
// state refs
displayedOptions,
activeVariantIndex,
// computed refs
model,
// element refs
selectEl,
} = choose;
/**
* Extensible-slot mechanism (ported showcase feature): every consumer slot —
* plus the ones this component fills itself — is forwarded into QSelect,
* each wrapped by a `before--<slot>` / `after--<slot>` pair, so a wrapping
* component can decorate around ANY QSelect slot without replacing it.
*/
const slots = defineSlots<ChooseFieldSlots>();
const activeSlots = new Set(
Object.keys(slots)
.map((slotName) => slotName.replace(/^(before|after)--/, ''))
.concat(['prepend', 'selected-item', 'before-options', 'option', 'no-option']),
);
defineExpose(choose as ChooseField.Instance);
</script>
<template>
<q-select
ref="selectEl"
v-model="model"
class="ivue-choose"
:label="choose.label"
:hint="choose.hint"
:dense="choose.dense"
:outlined="choose.outlined"
:readonly="choose.readonly"
:disable="choose.disable"
:loading="choose.loading"
:multiple="choose.multiple"
:use-input="choose.useInput"
:use-chips="false"
:input-debounce="choose.inputDebounce"
:options="displayedOptions"
:option-value="(option: any) => choose.optionValueOf(option)"
:option-label="(option: any) => choose.optionLabelOf(option)"
:options-cover="choose.optionsCover"
:dropdown-icon="choose.dropdownIcon"
:hide-dropdown-icon="choose.hideDropdownIcon"
:clearable="choose.clearable"
:clear-icon="choose.clearIcon"
:new-value-mode="choose.newValueMode"
@focus="() => choose.onFocus()"
@filter="(inputValue, doneFn) => choose.onFilter(inputValue, doneFn)"
@input-value="(value) => choose.onInputValue(value)"
@remove="(details) => choose.onRemove(details)"
@virtual-scroll="(details: any) => choose.onVirtualScroll(details)"
>
<!-- Every active slot forwards through, wrapped in before--/after-- hooks. -->
<template v-for="slot of activeSlots" :key="slot" #[slot]="scope">
<template v-if="slot === 'prepend'">
<slot name="before--prepend" v-bind="scope || {}" />
<slot name="prepend" v-bind="scope || {}">
<q-icon v-if="choose.icon" :name="choose.icon" />
</slot>
<slot name="after--prepend" v-bind="scope || {}" />
</template>
<template v-else-if="slot === 'selected-item'">
<slot name="before--selected-item" v-bind="scope || {}" />
<slot name="selected-item" v-bind="scope || {}">
<q-chip
v-if="choose.useChips"
:key="scope.index"
class="ivue-choose__chip"
removable
dense
size="14px"
icon-remove="close"
:tabindex="scope.tabindex"
color="white"
text-color="dark"
:class="choose.chipClass"
@remove="() => scope.removeAtIndex(scope.index)"
>
{{ choose.optionLabelOf(scope.opt) }}
</q-chip>
<span v-else class="ivue-choose__selected">
{{ choose.optionLabelOf(scope.opt) }}
</span>
</slot>
<slot name="after--selected-item" v-bind="scope || {}" />
</template>
<template v-else-if="slot === 'before-options'">
<slot name="before--before-options" v-bind="scope || {}" />
<slot name="before-options" v-bind="scope || {}">
<!-- VARIANT SWITCHER -->
<div v-if="choose.variants.length > 1" class="ivue-choose__variants">
<q-btn
v-for="(variant, index) in choose.variants"
:key="variant.label"
flat
square
dense
class="ivue-choose__variant-btn"
:icon="variant.icon"
:color="choose.isActiveVariant(index) ? 'primary' : 'grey-8'"
:class="{ 'ivue-choose__variant--active': activeVariantIndex === index }"
@click="choose.setVariant(index)"
>{{ variant.label }}</q-btn
>
</div>
</slot>
<slot name="after--before-options" v-bind="scope || {}" />
</template>
<template v-else-if="slot === 'option'">
<slot name="before--option" v-bind="scope || {}" />
<slot name="option" v-bind="scope || {}">
<q-item
v-bind="scope.itemProps"
class="ivue-choose__option"
:class="choose.optionClass"
>
<q-item-section v-if="choose.optionIconOf(scope.opt)" avatar>
<q-icon :name="choose.optionIconOf(scope.opt)" />
</q-item-section>
<q-item-section>
<q-item-label v-if="scope.opt?.createTerm">
{{ choose.createLabel || 'Create new' }}
<span class="ivue-choose__create-term">{{
scope.opt.createTerm
}}</span>
</q-item-label>
<q-item-label v-else>
{{ choose.optionLabelOf(scope.opt) }}
</q-item-label>
<q-item-label v-if="choose.optionDescriptionOf(scope.opt)" caption>
{{ choose.optionDescriptionOf(scope.opt) }}
</q-item-label>
</q-item-section>
</q-item>
</slot>
<slot name="after--option" v-bind="scope || {}" />
</template>
<template v-else-if="slot === 'no-option'">
<slot name="before--no-option" v-bind="scope || {}" />
<slot name="no-option" v-bind="scope || {}">
<div v-if="choose.variants.length > 1" class="ivue-choose__variants">
<q-btn
v-for="(variant, index) in choose.variants"
:key="variant.label"
flat
square
dense
class="ivue-choose__variant-btn"
:icon="variant.icon"
:color="choose.isActiveVariant(index) ? 'primary' : 'grey-8'"
@click="choose.setVariant(index)"
>{{ variant.label }}</q-btn
>
</div>
<!-- CREATE AFFORDANCE when nothing matches -->
<q-item
v-if="choose.canCreate && scope.inputValue"
clickable
@click="choose.createOption()"
>
<q-item-section avatar><q-icon name="add" /></q-item-section>
<q-item-section>
<q-item-label>
{{ choose.createLabel }}: "{{ scope.inputValue }}"
</q-item-label>
</q-item-section>
</q-item>
<q-item v-else>
<q-item-section class="text-grey">No results</q-item-section>
</q-item>
</slot>
<slot name="after--no-option" v-bind="scope || {}" />
</template>
<template v-else-if="slot === 'append' && choose.canCreate">
<slot name="before--append" v-bind="scope || {}" />
<slot name="append" v-bind="scope || {}">
<!-- CREATE NEW PLUS ICON -->
<q-btn round dense flat icon="add" @click.stop.prevent="choose.createOption()">
<q-tooltip anchor="top middle" self="bottom middle" :offset="[0, 5]">
{{ choose.createLabel }}
</q-tooltip>
</q-btn>
</slot>
<slot name="after--append" v-bind="scope || {}" />
</template>
<!-- Any other QSelect slot the consumer supplied: forward it wrapped. -->
<template v-else>
<slot :name="`before--${slot}` as any" v-bind="scope || {}" />
<slot :name="slot as any" v-bind="scope || {}" />
<slot :name="`after--${slot}` as any" v-bind="scope || {}" />
</template>
</template>
</q-select>
</template>
<style>
/*
* Hint spacing fix: QSelect renders .q-field__bottom flush against the
* control; give the hint native-feeling breathing room.
*/
.ivue-choose .q-field__bottom {
padding-top: 6px;
min-height: 22px;
}
.ivue-choose__selected {
margin-right: 4px;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ivue-choose .q-chip--dense {
height: auto;
border-radius: v-bind('choose.chipBorderRadius');
background: #f3f3f3;
margin: 3px 3px 3px 0;
border: 1px solid #dae6ea;
padding: 3px 12px;
}
.ivue-choose .q-chip.ivue-chip__singular {
background: none;
border: none;
padding-left: 4px;
}
/* avatar chips carry their own image at the left edge — tight left padding */
.ivue-choose .q-chip--dense.contact-field__chip {
padding: 3px 10px 3px 4px;
}
/* the to-be-created value renders as a round chip-like token */
.ivue-choose__create-term {
display: inline-block;
padding: 1px 12px;
margin-left: 4px;
border: 1px solid currentColor;
border-radius: 50px;
font-weight: 500;
}
/* Quasar reserves 56px for icon sections — a comfortable 10px gap instead */
.ivue-choose__option .q-item__section--avatar {
min-width: 0;
padding-right: 10px;
}
.ivue-choose__variants {
display: flex;
flex-wrap: wrap;
gap: 2px;
padding: 4px;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
}
.ivue-choose__variant--active {
background: rgba(0, 0, 0, 0.06);
}
.ivue-choose__variant-btn {
font-size: 13px;
padding: 7px 16px;
}
.ivue-choose__variant-btn .q-icon {
font-size: 18px;
margin-right: 6px;
}
</style>// ChooseFieldProps.ts — the params/defaults architecture for ChooseField.
//
// Params are declared twice on purpose: once as Vue prop TYPES, once as a
// plain DEFAULTS object; `propsWithDefaults` merges them into a
// defineComponent()-style props object (wrapping object/array defaults in
// factories). Extending fields (see ContactFieldProps.ts) spread and
// override both maps, so a subclass component redefines only what differs.
import type { QSelectOption, QSelectProps, QSelectSlots } from 'quasar';
import type { ExtractPropTypes, PropType } from 'vue';
import {
type ExtendSlots,
type ExtractEmitTypes,
type ExtractPropDefaultTypes,
type IFnParameter,
propsWithDefaults,
} from '../../../ivue';
import { baseFieldProps } from '../field-kit';
export type KeyValueRow = Record<string, any>;
export type ChooseOption = string | number | QSelectOption | KeyValueRow;
/**
* Client-side option filter: a `{ key, value }` equality predicate applied
* to each loaded option row. Deliberately tiny — the server-side
* `fetchFilters` string handles anything richer.
*/
export interface OptionFilter {
key: string;
value: any;
}
/** A named preset of server + client filtering the user can switch between. */
export interface ChooseFieldVariant {
label: string;
default?: true;
icon?: string;
fetchFilters?: string;
fetchSort?: string;
/** Client filters & sort are applied after server-side fetch filters & sort. */
optionFilters?: OptionFilter[];
optionSort?: string;
}
/* === Choose Field Params === */
/** Params Types */
export const chooseFieldParamsTypes = {
/** === QSelect Overrides === */
multiple: { type: Boolean as PropType<boolean> },
/** Chips */
useChips: { type: Boolean as PropType<boolean> },
roundChips: { type: Boolean as PropType<boolean> },
/** Input */
useInput: { type: Boolean as PropType<boolean> },
inputDebounce: { type: Number as PropType<number> },
/** Icons */
dropdownIcon: { type: String as PropType<string> },
hideDropdownIcon: { type: Boolean as PropType<boolean> },
/** Options */
options: { type: Array as PropType<ChooseOption[]> },
optionValue: { type: String as PropType<string> },
optionsCover: { type: Boolean as PropType<boolean> },
prependOptions: { type: Array as PropType<ChooseOption[]> },
appendOptions: { type: Array as PropType<ChooseOption[]> },
/** Clearable */
clearable: { type: Boolean as PropType<boolean> },
clearIcon: { type: String as PropType<string> },
/** New Value Mode */
newValueMode: {
type: String as PropType<'add' | 'add-unique' | 'toggle' | undefined>,
},
/** === QSelect Overrides End === */
/** === Custom Choose Field Params === */
/** Client-side filtering — `{ key, value }` equality rows; @see fetchFilters for server side. */
optionFilters: { type: Array as PropType<OptionFilter[]> },
/** Client-side sorting in 'field:asc,field2:desc' format; @see fetchSort for server side. */
optionSort: { type: String as PropType<string> },
/** Options */
optionClass: { type: String as PropType<string> },
/** Label */
optionLabel: { type: String as PropType<string> },
optionLabelPriority: { type: Array as PropType<string[]> },
/** Description */
optionDescription: { type: String as PropType<string> },
optionDescriptionPriority: { type: Array as PropType<string[]> },
/** Chip */
chipClass: { type: String as PropType<string> },
/** Icon */
icon: { type: String as PropType<string> },
/** Variants */
variants: { type: Array as PropType<ChooseFieldVariant[]> },
/** Fetch */
fetchPath: { type: String as PropType<string> },
fetchOnFocus: { type: Boolean as PropType<boolean> },
fetchScrollThreshold: { type: Number as PropType<number> },
/** Fetch Filters */
fetchFilters: { type: String as PropType<string> },
fetchSort: { type: String as PropType<string> },
/** Fetch Search */
fetchSearch: { type: Boolean as PropType<boolean> },
/** Fetch Pagination */
fetchPagination: { type: Boolean as PropType<boolean> },
fetchRowsPerPage: { type: Number as PropType<number> },
/** Create */
createPath: { type: String as PropType<string> },
createLabel: { type: String as PropType<string> },
createEntityAsOption: { type: Boolean as PropType<boolean> },
};
/** Params Defaults */
export const chooseFieldParamsDefaults: ExtractPropDefaultTypes<
typeof chooseFieldParamsTypes
> = {
/** === QSelect Overrides === */
multiple: false,
/** Chips */
useChips: false,
roundChips: false,
/** Input */
useInput: false,
inputDebounce: 250,
/** Icons */
dropdownIcon: 'arrow_drop_down',
hideDropdownIcon: false,
/** Options */
options: [],
optionValue: '',
optionsCover: false,
prependOptions: [], // Extra options ahead of fetched/static options.
appendOptions: [], // Extra options after fetched/static options.
/** Clearable */
clearable: false,
clearIcon: 'close',
/** New Value Mode */
newValueMode: undefined,
/** === QSelect Overrides End === */
/** === Custom Choose Field Params === */
optionFilters: [], // Client-side equality filters, applied after any server fetch.
optionSort: '', // Client-side sort, 'field:asc,field2:desc' — same grammar as fetchSort.
/** Options */
optionClass: '',
/** Option Label */
optionLabel: '', // Custom prop to use for the label.
optionLabelPriority: ['label', 'name', 'value', 'id'], // Fallback chain when optionLabel is not set.
/** Option Description */
optionDescription: '', // Custom prop to use for the description.
optionDescriptionPriority: ['description', 'caption'], // Fallback chain when optionDescription is not set.
/** Chips */
chipClass: '',
/** Icon */
icon: '',
/** Variants */
variants: [],
/** Fetch */
fetchPath: '', // List endpoint to fetch options from ('' = purely client-side options).
fetchOnFocus: true, // Refetch on each focus, for an always-fresh-data feel.
fetchScrollThreshold: 5, // Items left below the viewport that trigger the next-page fetch.
/** Fetch Filters */
fetchFilters: '', // Server-side filter expression; @see optionFilters for client side.
fetchSort: '', // Server-side sort: 'columnName:asc,columnName2:desc'; @see optionSort for client side.
/** Fetch Search */
fetchSearch: false, // Search through the server even without pagination.
/** Fetch Pagination */
fetchPagination: false, // Implies server search — client search over a partial page lies.
fetchRowsPerPage: 20,
/** Create */
createPath: '', // POST endpoint enabling the create-new-option affordance.
createLabel: '',
createEntityAsOption: true, // Show the create affordance as the first option row while typing.
};
/** Params */
export const chooseFieldParams = propsWithDefaults(
chooseFieldParamsDefaults,
chooseFieldParamsTypes,
);
export type ChooseFieldParams = ExtractPropTypes<typeof chooseFieldParams>;
/** Props */
export const chooseFieldProps = {
...baseFieldProps,
...chooseFieldParams,
};
export type ChooseFieldProps = ExtractPropTypes<typeof chooseFieldProps>;
/** Emits */
export const chooseFieldEmits = {
'update:model-value': (value: any) => true,
remove: (details: IFnParameter<QSelectProps, 'onRemove', 0>) => true,
};
export type ChooseFieldEmits = ExtractEmitTypes<typeof chooseFieldEmits>;
/** Slots — every QSelect slot, plus a 'before--'/'after--' pair around each. */
export type ChooseFieldSlots = ExtendSlots<QSelectSlots>;<script lang="ts" setup>
// ContactField — ChooseField preconfigured for the '/contact' endpoint,
// with avatar-decorated options and chips in two display modes:
// full (default): avatar + name + email in options AND selected chips
// compact: smaller avatar, name only, denser rows
import { QChip, QItem, QItemLabel, QItemSection } from 'quasar';
import { computed } from 'vue';
import ChooseField from './ChooseField.vue';
import ContactAvatar from './ContactAvatar.vue';
import {
contactFieldEmits,
contactFieldProps,
} from './ContactFieldProps';
const props = defineProps(contactFieldProps);
const emit = defineEmits(contactFieldEmits);
/** Everything except our own `compact` passes straight into ChooseField. */
const chooseProps = computed(() => {
const { compact, modelValue, ...passthrough } = props;
return passthrough;
});
const avatarSize = computed(() => (props.compact ? 20 : 32));
</script>
<template>
<ChooseField
:class="props.compact ? 'contact-field--compact' : 'contact-field--full'"
v-bind="chooseProps"
:model-value="props.modelValue"
@update:model-value="(value: any) => emit('update:model-value', value)"
@remove="(details: any) => emit('remove', details)"
>
<!-- CONTACT OPTION: avatar + name (+ email in full mode) -->
<template #option="scope">
<q-item
v-bind="scope.itemProps"
:dense="props.compact"
class="contact-field__option"
:class="{ 'contact-field__option--compact': props.compact }"
>
<q-item-section avatar>
<ContactAvatar :name="scope.opt?.name ?? ''" :size="avatarSize" />
</q-item-section>
<q-item-section>
<q-item-label>{{ scope.opt?.name }}</q-item-label>
<q-item-label v-if="!props.compact && scope.opt?.email" caption>
{{ scope.opt.email }}
</q-item-label>
</q-item-section>
</q-item>
</template>
<!-- SELECTED CHIP: avatar + name (+ email in full mode) -->
<template #selected-item="scope">
<q-chip
removable
dense
:size="props.compact ? '12px' : '14px'"
icon-remove="close"
:tabindex="scope.tabindex"
color="white"
text-color="dark"
class="contact-field__chip"
@remove="() => scope.removeAtIndex(scope.index)"
>
<ContactAvatar
:name="scope.opt?.name ?? ''"
:size="props.compact ? 16 : 22"
class="contact-field__chip-avatar"
/>
<span class="contact-field__chip-name">{{ scope.opt?.name }}</span>
<span v-if="!props.compact && scope.opt?.email" class="contact-field__chip-email">
{{ scope.opt.email }}
</span>
</q-chip>
</template>
</ChooseField>
</template>
<style>
/* Quasar reserves 56px for avatar sections — far too much air between the
avatar and the name. Tighten it; tighter still in compact mode. */
.contact-field__option {
padding-left: 10px;
}
.contact-field__option .q-item__section--avatar {
min-width: 0;
padding-right: 10px;
}
.contact-field__option--compact .q-item__section--avatar {
padding-right: 7px;
}
.contact-field__chip {
padding: 4px 10px 4px 4px;
/* never wider than the field — long emails ellipsize instead */
max-width: 100%;
}
.contact-field__chip .q-chip__content {
min-width: 0;
}
.contact-field__chip-avatar {
margin-right: 6px;
}
.contact-field__chip-name {
font-weight: 500;
white-space: nowrap;
}
.contact-field__chip-email {
margin-left: 6px;
opacity: 0.65;
font-size: 0.85em;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.contact-field--compact .q-field__control {
min-height: 36px;
}
</style>// ContactFieldProps.ts — ChooseField params preconfigured for contacts.
//
// The params/defaults architecture pays off here: the contact field spreads
// the choose-field types and defaults and overrides ONLY what differs —
// chips on, server search + pagination against '/contact', contact-shaped
// label/description priorities — then adds its own `compact` display mode.
import type { ExtractPropTypes, PropType } from 'vue';
import {
type ExtractEmitTypes,
type ExtractPropDefaultTypes,
propsWithDefaults,
} from '../../../ivue';
import { baseFieldProps } from '../field-kit';
import {
chooseFieldEmits,
chooseFieldParamsDefaults,
chooseFieldParamsTypes,
} from './ChooseFieldProps';
export const contactFieldParamsTypes = {
...chooseFieldParamsTypes,
/** Compact display mode: smaller avatar, name only, denser rows. */
compact: { type: Boolean as PropType<boolean> },
};
export const contactFieldParamsDefaults: ExtractPropDefaultTypes<
typeof contactFieldParamsTypes
> = {
...chooseFieldParamsDefaults,
/** Choose Field overrides. */
useChips: true,
roundChips: true,
useInput: true,
hideDropdownIcon: true,
fetchPath: '/contact',
fetchSearch: true,
fetchPagination: true,
fetchRowsPerPage: 8,
fetchSort: 'name:asc',
optionLabelPriority: ['name', 'email', 'id'],
optionDescriptionPriority: ['role', 'company', 'email'],
createLabel: 'Create contact',
/** Custom contact params. */
compact: false,
};
/** Params */
export const contactFieldParams = propsWithDefaults(
contactFieldParamsDefaults,
contactFieldParamsTypes,
);
export type ContactFieldParams = ExtractPropTypes<typeof contactFieldParams>;
/** Props */
export const contactFieldProps = {
...baseFieldProps,
...contactFieldParams,
};
export type ContactFieldProps = ExtractPropTypes<typeof contactFieldProps>;
/** Emits */
export const contactFieldEmits = { ...chooseFieldEmits };
export type ContactFieldEmits = ExtractEmitTypes<typeof contactFieldEmits>;<script setup lang="ts">
import ChooseField from './ChooseField.vue';
import ContactField from './ContactField.vue';
import { ChooseFieldExample } from './ChooseFieldExample';
const example = new ChooseFieldExample.Class();
// the state destructure
const {
// state refs
basicPick,
iconPick,
serverContact,
clientContact,
compactContact,
teamPicks,
tagPicks,
variantPick,
resetting,
} = example;
</script>
<template>
<div class="pane pane-fields">
<p class="note">
One production-grade select component, eight configurations — every
variation below is the SAME ChooseField class, driven entirely by
props. Server search, pagination and option creation run against the
in-browser mock backend (localStorage); point ServerApi at
server-node/server.ts and nothing else changes.
</p>
<div class="field-grid">
<section>
<h3>Basic — static options</h3>
<ChooseField
v-model="basicPick"
label="Plan"
hint="Simple list, no server involved"
:options="example.planOptions"
clearable
/>
</section>
<section>
<h3>Icons & descriptions</h3>
<ChooseField
v-model="iconPick"
label="Plan with details"
hint="optionDescriptionPriority renders the caption line"
:options="example.planOptions"
icon="tune"
clearable
/>
</section>
<section>
<h3>Contact — search from the backend</h3>
<ContactField
v-model="serverContact"
label="Assignee"
hint="Debounced ILIKE search + pagination, server-side"
fetch-path="/contact"
fetch-search
fetch-pagination
use-input
clearable
/>
</section>
<section>
<h3>Contact — search via client JS</h3>
<ContactField
v-model="clientContact"
label="Assignee (client filter)"
hint="Whole list fetched once; typing filters in-memory"
fetch-path="/contact"
use-input
clearable
/>
</section>
<section>
<h3>Contact — compact</h3>
<ContactField
v-model="compactContact"
label="Owner"
hint="Smaller avatar, name only, denser rows"
fetch-path="/contact"
fetch-search
use-input
compact
dense
clearable
/>
</section>
<section>
<h3>Multiple — avatar chips</h3>
<ContactField
v-model="teamPicks"
label="Team"
hint="multiple + useChips; remove from the chip"
fetch-path="/contact"
fetch-search
use-input
multiple
use-chips
round-chips
clearable
/>
</section>
<section>
<h3>Create new options</h3>
<ChooseField
v-model="tagPicks"
label="Tags"
hint="Type a new tag and create it — POSTs to the backend"
fetch-path="/tag"
create-path="/tag"
option-label="name"
use-input
multiple
use-chips
round-chips
clearable
/>
</section>
<section>
<h3>Variants — people / companies</h3>
<ChooseField
v-model="variantPick"
label="Counterparty"
hint="One field, two server-filtered datasets"
fetch-path="/contact"
fetch-search
use-input
:variants="example.variants"
clearable
/>
</section>
</div>
<div class="row" style="margin-top: 20px">
<button
class="btn"
type="button"
:disabled="resetting"
@click="example.resetSandbox()"
>
{{ resetting ? 'Resetting…' : 'Reset sandbox data' }}
</button>
<span class="mono">
your edits live in localStorage — private to this browser
</span>
</div>
</div>
</template>
<style scoped src="../../example-pane.css"></style>
<style scoped>
.pane-fields {
max-width: 920px;
}
.field-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(340px, 100%), 1fr));
gap: 22px 26px;
}
.field-grid h3 {
margin: 0 0 10px;
padding-bottom: 6px;
border-bottom: 1px solid rgba(148, 163, 184, 0.18);
font-size: 13.5px;
font-weight: 700;
letter-spacing: 0.02em;
color: #f1f5ff;
}
/* Quasar renders on a light-first palette; keep fields readable on the
playground's dark shell. */
.pane-fields :deep(.q-field) {
--q-primary: #6366f1;
}
</style>The props architecture — one typed params object, one plain defaults object, merged by propsWithDefaults(), spread and re-defaulted by ContactField — has its own guide: Extensible Components.
The backend path
The field talks to ServerApi, a transport-pluggable gateway. The playground installs the in-browser mock (localStorage rows, the same filter grammar); a real deployment installs httpTransport(baseUrl) against server-node/server.ts — a TypeScript Express reference implementation with the generic filtered / sorted / paginated list endpoint this field consumes.