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
import type { ExtractPropTypes, PropType } from 'vue';

import {
  type ExtractEmitTypes,
  type ExtractPropDefaultTypes,
  definePropTypes,
  propsWithDefaults,
  Reactive
} from '../../../ivue';
import { Static } from '../../../Static';
import { ChooseField } from './ChooseField';

/**
 * ContactField — ChooseField SUBCLASSED for the '/contact' endpoint:
 * avatar-decorated options and chips in two display modes (full /
 * compact). The class extends the base AND its contract in one motion —
 * the static getters below spread `super` and re-tune only what differs;
 * ContactField.vue constructs THIS instance with its own props and emit
 * and hands it to the base SFC through the `runner` prop (the ported v1
 * mechanism) — every inherited behavior (fetch, filter, variants,
 * chips) and every member below live on ONE object, driving both
 * templates.
 */
class $ContactField extends ChooseField.$Class {
  /* Contract — the choose-field contract, preconfigured for contacts:
     chips on, server search + pagination against '/contact',
     contact-shaped label/description priorities — plus one prop of its
     own, `compact`. Every line here is a DIFFERENCE from the base. */

  static override get propsTypes() {
    return definePropTypes({
        ...super.propsTypes,
  
        /** Compact display mode: smaller avatar, name only, denser rows. */
        compact: { type: Boolean as PropType<boolean> }
      });
  }

  static override get propsDefaults(): ExtractPropDefaultTypes<typeof $ContactField.propsTypes> {
    return {
      ...super.propsDefaults,

      /** Choose Field overrides. */
      useChips: true,
      roundChips: true,
      useInput: true,
      hideDropdownIcon: true,
      // fetchPath is NOT defaulted here — the class overrides the getter with
      // a super chain (`super.fetchPath || '/contact'`), the ported v1 idiom:
      // an explicit prop still wins, and the endpoint is behavior, not config.
      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
    };
  }

  /** Re-declared so `ContactField.Props` carries `compact`. */
  static override get props() {
    return propsWithDefaults(this.propsDefaults, this.propsTypes);
  }

  // Widen the inherited surfaces to the contact contract — the ported v1
  // idiom (`declare` emits nothing at runtime; the base constructor
  // assigned these).
  declare props: ContactField.Props;

  declare emit: ContactField.Emits;

  /* Props */

  get compact() {
    return this.props.compact;
  }

  /* Derived — the contact decoration vocabulary, named */

  get rootClass() {
    return this.compact ? 'contact-field--compact' : 'contact-field--full';
  }

  get avatarSize() {
    return this.compact ? 20 : 32;
  }

  get chipAvatarSize() {
    return this.compact ? 16 : 22;
  }

  get chipSize() {
    return this.compact ? '12px' : '14px';
  }

  /* Overrides — behavior extensions over the base, super-chained so an
     explicit prop always wins (ported v1 idiom) */

  /**
   * The contact endpoint is BEHAVIOR, not a default: unset resolves to
   * '/contact', an explicit `fetch-path` prop still wins.
   * @extends @see {$ChooseField.fetchPath}
   */
  override get fetchPath() {
    return super.fetchPath || '/contact';
  }

  /**
   * Typing a new value creates a contact when options are keyed by
   * email — 'add-unique' keeps the list deduplicated.
   * @extends @see {$ChooseField.newValueMode}
   */
  override get newValueMode() {
    return super.newValueMode ?? (this.optionValue === 'email' ? 'add-unique' : super.newValueMode);
  }

  /** Full mode shows the email line under the name — when there is one. */
  /** The avatar's name for an option — empty while the option is unresolved. */
  optionName(option: { name?: string } | null | undefined) {
    return option?.name ?? '';
  }

  showEmail(option: { email?: string } | undefined): boolean {
    return !this.compact && !!option?.email;
  }
}

export namespace ContactField {
  /* Identity */

  export const $Class = Static($ContactField); // anchor — children `extends` this
  export let Class = Reactive($Class); // reactive — you `new` this
  export type Instance = typeof Class.Instance; // defineExpose type & reactive() interop

  /* Types — DERIVED from the class's statics (emits are inherited whole) */

  export type Props = ExtractPropTypes<typeof $Class.props>;
  export type Emits = ExtractEmitTypes<typeof $Class.emits>;
}
vue
<script lang="ts" setup>
// ContactField — ChooseField SUBCLASSED 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
// The wrapper constructs the SUBCLASS instance with its own props and
// emit, then hands it to the base SFC via `runner` (ported v1 mechanism):
// one object drives the base's behavior AND this template's decoration.
import { QChip, QItem, QItemLabel, QItemSection } from 'quasar';

import ChooseField from './ChooseField.vue';
import ContactAvatar from './ContactAvatar.vue';
import { ContactField } from './ContactField';

const props = defineProps(ContactField.Class.props);
const emit = defineEmits(ContactField.Class.emits);

const field = ContactField.Class.runner(props, emit);

defineExpose(field as ContactField.Instance);
</script>

<template>
  <ChooseField :class="field.rootClass" :model-value="props.modelValue" :runner="field">
    <!-- CONTACT OPTION: avatar + name (+ email in full mode) -->
    <template #option="scope">
      <q-item
        v-bind="scope.itemProps"
        :dense="field.compact"
        class="contact-field__option"
        :class="{ 'contact-field__option--compact': field.compact }"
      >
        <q-item-section avatar>
          <ContactAvatar :name="field.optionName(scope.opt)" :size="field.avatarSize" />
        </q-item-section>
        <q-item-section>
          <q-item-label>{{ scope.opt?.name }}</q-item-label>
          <q-item-label v-if="field.showEmail(scope.opt)" 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="field.chipSize"
        icon-remove="close"
        :tabindex="scope.tabindex"
        color="white"
        text-color="dark"
        class="contact-field__chip"
        @remove="() => scope.removeAtIndex(scope.index)"
      >
        <ContactAvatar
          :name="field.optionName(scope.opt)"
          :size="field.chipAvatarSize"
          class="contact-field__chip-avatar"
        />
        <span class="contact-field__chip-name">{{ scope.opt?.name }}</span>
        <span v-if="field.showEmail(scope.opt)" 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
// 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 type { QSelectOption, QSelectProps, QSelectSlots } from 'quasar';
import type { ExtractPropTypes, PropType } from 'vue';

import {
  type ExtendSlots,
  type ExtractEmitTypes,
  type ExtractPropDefaultTypes,
  type IFnParameter,
  definePropTypes,
  propsWithDefaults,
  Reactive
} from '../../../ivue';
import { Static } from '../../../Static';
import { ServerApi } from '../server/ServerApi';
import { Field } from '../Field';

class $ChooseField extends Field.$Class {
  /** Sentinel `value` of the synthetic "Create …" option row — a live
   *  static knob (no `$`): a subclass can re-key it. */
  static readonly createOptionValue = '__create_option__';

  /* Contract — STATIC. The class owns its inputs the way it owns its
     state; ContactField extends them with `super` and re-tunes only
     what differs. Types AND defaults are declared separately on purpose:
     a subclass can re-default without re-typing (propsWithDefaults fuses
     them, wrapping object/array defaults in factories). */

  /** Params Types */
  static override get propsTypes() {
    return definePropTypes({
        ...super.propsTypes,
  
        /** === 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<ChooseField.Option[]> },
        optionValue: { type: String as PropType<string> },
        optionsCover: { type: Boolean as PropType<boolean> },
        prependOptions: { type: Array as PropType<ChooseField.Option[]> },
        appendOptions: { type: Array as PropType<ChooseField.Option[]> },
        /** 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<ChooseField.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<ChooseField.Variant[]> },
  
        /** 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 */
  static override get propsDefaults(): ExtractPropDefaultTypes<typeof $ChooseField.propsTypes> {
    return {
        ...super.propsDefaults,
  
        /** === 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.
      };
  }

  /** Re-declared (one line) so the derived `ChooseField.Props` type
   *  carries the params above — see Field.props. */
  static override get props() {
    return propsWithDefaults(this.propsDefaults, this.propsTypes);
  }

  /** Emits */
  static get emits() {
    return {
        'update:model-value': (value: any) => true,
        remove: (details: IFnParameter<QSelectProps, 'onRemove', 0>) => true
      };
  }

  constructor(
    public props: ChooseField.Props,
    public emit: ChooseField.Emits
  ) {
    super();
    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)
    );
  }

  /** The one cast per class: instance code reads its own statics here. */
  protected get self() {
    return this.constructor as typeof $ChooseField;
  }

  // --- state ---

  get selectEl() {
    return ref<QSelect | null>(null);
  }

  get displayedOptions() {
    return shallowRef<ChooseField.Option[]>([]);
  }

  get fetchedOptions() {
    return shallowRef<ChooseField.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.
   */
  // computed: stable-handle — the v-model target the SFC destructures
  get model() {
    return computed({
      get: () => this.readModel(),
      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(): ChooseField.Variant | 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(): ChooseField.OptionFilter[] {
    return this.activeVariant?.optionFilters ?? this.props.optionFilters;
  }

  get optionSort() {
    return this.activeVariant?.optionSort ?? this.props.optionSort;
  }

  /** The variant switcher only renders when there is a choice to make. */
  get hasVariants() {
    return this.variants.length > 1;
  }

  // --- 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(): ChooseField.Option[] {
    return [
      ...this.prependOptions,
      ...(this.fetchPath ? this.fetchedOptions.value : this.options),
      ...this.appendOptions
    ];
  }

  get hasMoreToFetch() {
    return this.lastFetchedCount.value === this.fetchRowsPerPage;
  }

  get labelKeys() {
    return this.props.optionLabel ? [this.props.optionLabel] : this.props.optionLabelPriority;
  }

  get descriptionKeys() {
    return this.props.optionDescription
      ? [this.props.optionDescription]
      : this.props.optionDescriptionPriority;
  }

  isActiveVariant(index: number) {
    return this.activeVariantIndex.value === index;
  }

  setVariant(index: number) {
    this.activeVariantIndex.value = index;
  }

  variantColor(index: number) {
    return this.isActiveVariant(index) ? 'primary' : 'grey-8';
  }

  // --- slot forwarding (every QSelect slot, wrapped before--/after--) ---

  /** QSelect hands some slots no scope; forwarding always binds an object. */
  slotScope(scope: unknown): Record<string, any> {
    return (scope as Record<string, any>) || {};
  }

  beforeSlotName(slot: string) {
    return `before--${slot}`;
  }

  afterSlotName(slot: string) {
    return `after--${slot}`;
  }

  isPrependSlot(slot: string) {
    return slot === 'prepend';
  }

  isSelectedItemSlot(slot: string) {
    return slot === 'selected-item';
  }

  isBeforeOptionsSlot(slot: string) {
    return slot === 'before-options';
  }

  isOptionSlot(slot: string) {
    return slot === 'option';
  }

  isNoOptionSlot(slot: string) {
    return slot === 'no-option';
  }

  /** The append slot is only taken over when there is a create affordance to show. */
  isCreateAppendSlot(slot: string) {
    return slot === 'append' && this.canCreate;
  }

  /** Whether the typed text can become a new option. */
  canCreateFrom(scope: Record<string, any>) {
    return this.canCreate && !!scope.inputValue;
  }

  // --- 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<ChooseField.KeyValueRow[]> {
    this.errorMessage.value = '';
    this.fetching.value = true;
    this.fetchedPages.value = {
      ...this.fetchedPages.value,
      [this.fetchPage.value]: true
    };
    try {
      const result = await ServerApi.Class.getPaginated<ChooseField.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: ChooseField.Option[]): ChooseField.Option[] {
    let refined = options;
    if (this.optionFilters.length) {
      refined = refined.filter((option) =>
        this.optionFilters.every(
          (filter) => (option as ChooseField.KeyValueRow)?.[filter.key] === filter.value
        )
      );
    }
    if (this.optionSort) {
      refined = [...refined].sort((first, second) => this.compareBySort(first, second));
    }
    return refined;
  }

  compareBySort(first: ChooseField.Option, second: ChooseField.Option) {
    for (const sortPart of this.optionSort.split(',')) {
      const [field, direction] = sortPart.split(':');
      const firstValue = (first as ChooseField.KeyValueRow)?.[field.trim()];
      const secondValue = (second as ChooseField.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: ChooseField.Option) {
    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 ChooseField.KeyValueRow).some((cellValue) =>
      String(cellValue ?? '')
        .toLowerCase()
        .includes(needle)
    );
  }

  // --- option label & description resolution ---

  optionLabelOf(option: ChooseField.Option): string {
    if (typeof option === 'string' || typeof option === 'number') {
      return String(option);
    }
    return String(this.firstPresentValue(option, this.labelKeys) ?? '');
  }

  optionDescriptionOf(option: ChooseField.Option): string {
    if (typeof option !== 'object' || option === null) return '';
    return String(this.firstPresentValue(option, this.descriptionKeys) ?? '');
  }

  optionIconOf(option: ChooseField.Option): string {
    return typeof option === 'object' && option !== null
      ? ((option as ChooseField.KeyValueRow).icon ?? '')
      : '';
  }

  firstPresentValue(row: ChooseField.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: ChooseField.Option) {
    if (typeof option !== 'object' || option === null) return option;
    const row = option as ChooseField.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 ChooseField.KeyValueRow)?.value === this.self.createOptionValue) 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: this.self.createOptionValue
      },
      ...this.displayedOptions.value
    ];
  }

  isCreateOptionRow(option: ChooseField.Option) {
    return (option as ChooseField.KeyValueRow)?.value === this.self.createOptionValue;
  }

  /** 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.Class.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): ChooseField.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: ChooseField.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: ChooseField.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. */
  readModel() {
    return this.props.modelValue;
  }

  onModelWrite(value: any) {
    const isArrayValue = Array.isArray(value);
    const lastAdded = isArrayValue ? value[value.length - 1] : value;
    if (
      [lastAdded, (lastAdded as ChooseField.KeyValueRow)?.value].includes(
        this.self.createOptionValue
      )
    ) {
      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 {
  /* Identity */

  export const $Class = Static($ChooseField); // anchor — children `extends` this
  export let Class = Reactive($Class); // reactive — you `new` this
  export type Instance = typeof Class.Instance; // defineExpose type & reactive() interop

  /* Types — DERIVED from the class's statics, never hand-duplicated */

  export type Props = ExtractPropTypes<typeof $Class.props>;
  export type Emits = ExtractEmitTypes<typeof $Class.emits>;
  /** Every QSelect slot, plus a 'before--'/'after--' pair around each. */
  export type Slots = ExtendSlots<QSelectSlots>;

  export type KeyValueRow = Record<string, any>;
  export type Option = 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 Variant {
    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;
  }
}
vue
<script lang="ts" setup>
import { QBtn, QChip, QIcon, QItem, QItemLabel, QItemSection, QSelect, QTooltip } from 'quasar';

import { ChooseField } from './ChooseField';

const props = defineProps(ChooseField.Class.props);
const emit = defineEmits(ChooseField.Class.emits);

// The runner prop swaps the driving object (ported v1 mechanism): a
// wrapping component passes its OWN pre-built subclass INSTANCE — carrying
// the wrapper's props and emit — and this base renders through it; a
// CLASS runner (or none) is constructed here instead.
const choose = ChooseField.Class.runner(props, emit);

const {
  // state refs
  displayedOptions,
  // 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<ChooseField.Slots>();
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="choose.isPrependSlot(slot)">
        <slot name="before--prepend" v-bind="choose.slotScope(scope)" />
        <slot name="prepend" v-bind="choose.slotScope(scope)">
          <q-icon v-if="choose.icon" :name="choose.icon" />
        </slot>
        <slot name="after--prepend" v-bind="choose.slotScope(scope)" />
      </template>

      <template v-else-if="choose.isSelectedItemSlot(slot)">
        <slot name="before--selected-item" v-bind="choose.slotScope(scope)" />
        <slot name="selected-item" v-bind="choose.slotScope(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="choose.slotScope(scope)" />
      </template>

      <template v-else-if="choose.isBeforeOptionsSlot(slot)">
        <slot name="before--before-options" v-bind="choose.slotScope(scope)" />
        <slot name="before-options" v-bind="choose.slotScope(scope)">
          <!-- VARIANT SWITCHER -->
          <div v-if="choose.hasVariants" 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.variantColor(index)"
              :class="{ 'ivue-choose__variant--active': choose.isActiveVariant(index) }"
              @click="choose.setVariant(index)"
              >{{ variant.label }}</q-btn
            >
          </div>
        </slot>
        <slot name="after--before-options" v-bind="choose.slotScope(scope)" />
      </template>

      <template v-else-if="choose.isOptionSlot(slot)">
        <slot name="before--option" v-bind="choose.slotScope(scope)" />
        <slot name="option" v-bind="choose.slotScope(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 }}
                <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="choose.slotScope(scope)" />
      </template>

      <template v-else-if="choose.isNoOptionSlot(slot)">
        <slot name="before--no-option" v-bind="choose.slotScope(scope)" />
        <slot name="no-option" v-bind="choose.slotScope(scope)">
          <div v-if="choose.hasVariants" 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.variantColor(index)"
              @click="choose.setVariant(index)"
              >{{ variant.label }}</q-btn
            >
          </div>
          <!-- CREATE AFFORDANCE when nothing matches -->
          <q-item v-if="choose.canCreateFrom(scope)" 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="choose.slotScope(scope)" />
      </template>

      <template v-else-if="choose.isCreateAppendSlot(slot)">
        <slot name="before--append" v-bind="choose.slotScope(scope)" />
        <slot name="append" v-bind="choose.slotScope(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="choose.slotScope(scope)" />
      </template>

      <!-- Any other QSelect slot the consumer supplied: forward it wrapped. -->
      <template v-else>
        <slot :name="choose.beforeSlotName(slot) as any" v-bind="choose.slotScope(scope)" />
        <slot :name="slot as any" v-bind="choose.slotScope(scope)" />
        <slot :name="choose.afterSlotName(slot) as any" v-bind="choose.slotScope(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
// Field.ts — the minimal base contract every field example shares.
//
// The common QField passthrough (model, label, hint, density, read/disable
// states, loading, outlined styling) is declared ONCE, as statics on a
// base class: a field `extends Field.$Class` and its contract is
// inherited the way its behavior is — `super.propsTypes` spreads, one
// default re-tuned per line, nothing copied.

import type { PropType } from 'vue';

import {
  definePropTypes,
  propsWithDefaults,
  Reactive,
  type ExtractPropDefaultTypes
} from '../../ivue';
import { Static } from '../../Static';

class $Field {
  /* Contract — STATIC; subclasses extend with `super` */

  static get propsTypes() {
    return definePropTypes({
      modelValue: { type: null as unknown as PropType<any> },
      label: { type: String as PropType<string> },
      hint: { type: String as PropType<string> },
      dense: { type: Boolean as PropType<boolean> },
      disable: { type: Boolean as PropType<boolean> },
      readonly: { type: Boolean as PropType<boolean> },
      loading: { type: Boolean as PropType<boolean> },
      outlined: { type: Boolean as PropType<boolean> },
      /**
       * The driving runner — the universal shell's swap seam. A subclass
       * CLASS (this component constructs it with its own props and emit)
       * or a pre-built INSTANCE (a wrapping component constructs the
       * subclass with ITS props and emit and hands it down, so every emit
       * leaves through the wrapper). Unset = this component's own Class.
       */
      runner: { type: [Function, Object] as PropType<any> }
    });
  }

  static get propsDefaults(): ExtractPropDefaultTypes<typeof $Field.propsTypes> {
    return {
        modelValue: null,
        label: '',
        hint: '',
        dense: false,
        disable: false,
        readonly: false,
        loading: false,
        outlined: true,
        runner: null
      };
  }

  /** The fusion — written once here, read through the receiver: a
   *  subclass's `props` fuses ITS types and defaults. A subclass that
   *  ADDS props re-declares this one line so its derived `Props` type
   *  widens (a static's return type is not polymorphic in TypeScript). */
  static get props() {
    return propsWithDefaults(this.propsDefaults, this.propsTypes);
  }

  /**
   * Resolve the instance that drives a field SFC: the `runner` prop as an
   * INSTANCE is used as-is; as a CLASS it is constructed with this SFC's
   * props and emit; unset, the receiving class constructs itself. Every
   * field SFC is one line — `X.Class.runner(props, emit)` — and therefore
   * its own swap point.
   */
  static runner<This extends typeof $Field>(this: This, props: any, emit: any): InstanceType<This> {
    const runner = props.runner;
    if (typeof runner === 'object' && runner !== null) return runner;
    const RunnerClass = (typeof runner === 'function' ? runner : this) as This;
    return new RunnerClass(props, emit) as InstanceType<This>;
  }

  /** Fields take (props, emit); the base accepts either so `runner()` can
   *  construct any receiver uniformly. */
  constructor(..._arguments: any[]) {}
}

export namespace Field {
  export const $Class = Static($Field); // anchor — fields `extends` this
  export let Class = Reactive($Class);
  export type Instance = typeof Class.Instance;
}
ts
// ChooseFieldExample.ts — the Advanced Select Field showcase route state.
// Installs the in-browser mock backend for this route chunk; swap
// ServerApi.Class.use(httpTransport('http://localhost:4300')) to run the same
// components against server-node/server.ts.
import { ref } from 'vue';
import { Reactive } from '../../../ivue';
import { ServerApi } from '../server/ServerApi';
import { createMockServerTransport, resetMockServer } from '../server/MockServer';

class $ChooseFieldExample {
  constructor() {
    this.installMockServer();
  }

  // MUTABLE STATE — one model per showcased variation.
  get basicPick() {
    return ref<any>(null);
  }
  get iconPick() {
    return ref<any>(null);
  }
  get serverContact() {
    return ref<any>(null);
  }
  get clientContact() {
    return ref<any>(null);
  }
  get compactContact() {
    return ref<any>(null);
  }
  get teamPicks() {
    return ref<any[]>([]);
  }
  get tagPicks() {
    return ref<any[]>([]);
  }
  get variantPick() {
    return ref<any>(null);
  }
  get resetting() {
    return ref(false);
  }

  get resetLabel() {
    return this.resetting.value ? 'Resetting…' : 'Reset sandbox data';
  }

  // CONSTANTS — static options for the client-only variations.
  readonly planOptions = [
    { name: 'Hobby', description: 'For side projects', icon: 'rocket_launch' },
    { name: 'Pro', description: 'For production apps', icon: 'workspace_premium' },
    { name: 'Team', description: 'Shared workspaces', icon: 'groups' },
    { name: 'Enterprise', description: 'SSO, audit, SLAs', icon: 'apartment' }
  ];

  readonly variants = [
    {
      label: 'People',
      icon: 'person',
      default: true as const,
      fetchFilters: "kind = 'person'",
      fetchSort: 'name:asc'
    },
    {
      label: 'Companies',
      icon: 'apartment',
      fetchFilters: "kind = 'company'",
      fetchSort: 'name:asc'
    }
  ];

  async resetSandbox() {
    this.resetting.value = true;
    await resetMockServer();
    this.resetting.value = false;
  }

  /** This route runs against the in-browser mock backend; installing it
   *  here (not at import) keeps the class file free of side effects. Swap
   *  for `ServerApi.Class.use(httpTransport(...))` to run against server-node. */
  installMockServer() {
    ServerApi.Class.use(createMockServerTransport());
  }
}

export namespace ChooseFieldExample {
  export const $Class = $ChooseFieldExample; // 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 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()">
        {{ example.resetLabel }}
      </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.