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.
Related guide pages
- Extensible Components — props, emits and slots that extend with the class.
- Components & Templates — one template, one logic owner; the state destructure.
- Inheritance & super —
extends $Class,super,override.
The source
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>;
}<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>// 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;
}
}<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>// 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;
}// 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
}<script setup lang="ts">
import ChooseField from './ChooseField.vue';
import ContactField from './ContactField.vue';
import { ChooseFieldExample } from './ChooseFieldExample';
const example = new ChooseFieldExample.Class();
// the state destructure
const {
// state refs
basicPick,
iconPick,
serverContact,
clientContact,
compactContact,
teamPicks,
tagPicks,
variantPick,
resetting
} = example;
</script>
<template>
<div class="pane pane-fields">
<p class="note">
One production-grade select component, eight configurations — every variation below is the
SAME ChooseField class, driven entirely by props. Server search, pagination and option
creation run against the in-browser mock backend (localStorage); point ServerApi at
server-node/server.ts and nothing else changes.
</p>
<div class="field-grid">
<section>
<h3>Basic — static options</h3>
<ChooseField
v-model="basicPick"
label="Plan"
hint="Simple list, no server involved"
:options="example.planOptions"
clearable
/>
</section>
<section>
<h3>Icons & descriptions</h3>
<ChooseField
v-model="iconPick"
label="Plan with details"
hint="optionDescriptionPriority renders the caption line"
:options="example.planOptions"
icon="tune"
clearable
/>
</section>
<section>
<h3>Contact — search from the backend</h3>
<ContactField
v-model="serverContact"
label="Assignee"
hint="Debounced ILIKE search + pagination, server-side"
fetch-path="/contact"
fetch-search
fetch-pagination
use-input
clearable
/>
</section>
<section>
<h3>Contact — search via client JS</h3>
<ContactField
v-model="clientContact"
label="Assignee (client filter)"
hint="Whole list fetched once; typing filters in-memory"
fetch-path="/contact"
use-input
clearable
/>
</section>
<section>
<h3>Contact — compact</h3>
<ContactField
v-model="compactContact"
label="Owner"
hint="Smaller avatar, name only, denser rows"
fetch-path="/contact"
fetch-search
use-input
compact
dense
clearable
/>
</section>
<section>
<h3>Multiple — avatar chips</h3>
<ContactField
v-model="teamPicks"
label="Team"
hint="multiple + useChips; remove from the chip"
fetch-path="/contact"
fetch-search
use-input
multiple
use-chips
round-chips
clearable
/>
</section>
<section>
<h3>Create new options</h3>
<ChooseField
v-model="tagPicks"
label="Tags"
hint="Type a new tag and create it — POSTs to the backend"
fetch-path="/tag"
create-path="/tag"
option-label="name"
use-input
multiple
use-chips
round-chips
clearable
/>
</section>
<section>
<h3>Variants — people / companies</h3>
<ChooseField
v-model="variantPick"
label="Counterparty"
hint="One field, two server-filtered datasets"
fetch-path="/contact"
fetch-search
use-input
:variants="example.variants"
clearable
/>
</section>
</div>
<div class="row" style="margin-top: 20px">
<button class="btn" type="button" :disabled="resetting" @click="example.resetSandbox()">
{{ 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.