Skip to content

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

ts
// 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
}
vue
<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>
ts
// 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>;
vue
<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>
ts
// 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>;
vue
<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 &amp; 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.

Released under the MIT License.