Your app has a customer picker on a dozen pages, and each one repeats the same
BbSelect: the same provider, item-text, item-value and option row. A
preset turns that into one CustomerSelect you define once and use everywhere,
without writing a component file.
definePreset is a helper you copy into your app. It is not a bitboss-ui
export: its source is at the end of this page.
A select with its props already set
Pass the component, the props it always has, and the ones a page may still change.
export const CustomerSelect = definePreset(
BbSelect<Customer>,
{ items: getCustomers, itemText: 'name', itemValue: 'id' },
{ name: 'CustomerSelect', defaults: { label: 'Customer' } }
);
That is the whole component. It declares nothing of its own, so everything
reaches the select untouched: v-model, every BbSelect prop, event and slot.
There is no defineModel, no re-emitted event and no list of forwarded props.
The three fields below are the same two presets, used three ways.
Model: 7
Model: null
Model: [17,1]
<template>
<div class="grid max-w-2xl gap-x-4 gap-y-5 sm:grid-cols-2">
<!-- The preset's label, placeholder and option row, as defined. -->
<div class="grid content-start gap-2">
<CustomerSelect
id="invoice-customer"
v-model="customerId"
clearable
name="customer"
/>
<p class="m-0 text-xs opacity-70">
Model: <code>{{ JSON.stringify(customerId) }}</code>
</p>
</div>
<!-- A default overridden: the caller's label wins. -->
<div class="grid content-start gap-2">
<CustomerSelect
id="invoice-bill-to"
v-model="billToId"
clearable
label="Bill to"
name="bill-to"
placeholder="Same as customer"
/>
<p class="m-0 text-xs opacity-70">
Model: <code>{{ JSON.stringify(billToId) }}</code>
</p>
</div>
<!-- multiple fixed in the preset: the model is an array of ids. -->
<div class="grid content-start gap-2 sm:col-span-2">
<CustomerMultiSelect
id="invoice-copies"
v-model="copyIds"
label="Send copies to"
name="copies"
/>
<p class="m-0 text-xs opacity-70">
Model: <code>{{ JSON.stringify(copyIds) }}</code>
</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { CustomerMultiSelect, CustomerSelect } from './customers';
// The presets type these: a Customer's id, never `any`.
const customerId = ref<number | null>(7);
const billToId = ref<number | null>(null);
const copyIds = ref<number[]>([17, 1]);
</script>
The first field is the preset as defined, plus clearable, which the preset
never sets and passes straight through to BbSelect. The second overrides the
default label and placeholder. The third is a preset with multiple fixed, so
its model is an array of ids. Open any of them and the key accounts carry a
badge: that option row is part of both presets.
Preset, wrapper or neither
A preset only fixes props and slots. Anything with logic is a component file.
| The component… | Write |
|---|---|
always shows the same data or look: fixed items, item-text, item-value, a default label, an option row | a preset |
| has logic of its own: state, derived props, extra props it reads, markup around the control | a wrapper SFC (below) |
| appears on one page | neither: set the props on BbSelect there |
| only restyles parts, everywhere or in one area | neither: pt in the plugin options, or a named scope |
Copy the helper
Copy the helper's source unchanged into src/lib/definePreset.ts,
or anywhere you import from. The installed version ships it as a file:
cp node_modules/bitboss-ui/dist/ai/helpers/definePreset.ts src/lib/definePreset.ts
On Nuxt 4, put it in app/utils/definePreset.ts, where it is auto-imported and needs no import line. It needs TypeScript 5.0 or
later and nothing but vue.
The helper reads the types vue-tsc generates for generic components, which is
why the library ships it as a recipe rather than an export. The library
type-checks and mounts this file on every change. Line 1 stamps its revision:
when an update ships a newer one, npx bitboss-ui check reports your copy as
stale-define-preset, and the same cp brings it up to date.
Fixed props, defaults and slots
Keep a domain's presets in one .ts file, such as src/components/customers.ts.
This is the file behind the demos on this page:
// src/components/customers.ts
import { h } from 'vue';
import { BbBadge, BbSelect, BbTextInput } from 'bitboss-ui';
import { definePreset } from '@/lib/definePreset';
import { getCustomers, type Customer } from '@/api/customers';
const keyAccount = () =>
h(BbBadge, { size: 'sm', variant: 'secondary' }, () => 'Key account');
// One option row, shared by both customer presets.
const customerOption = ({ item, text }: { item: Customer; text: string }) =>
h('span', { class: 'flex w-full items-center gap-2 text-sm' }, [
h('span', { class: 'min-w-0 flex-1 truncate' }, text),
item.keyAccount ? keyAccount() : null,
]);
export const CustomerSelect = definePreset(
// The row type: slots, pt functions and v-model follow it.
BbSelect<Customer>,
// Fixed: what defines the data.
{ items: getCustomers, itemText: 'name', itemValue: 'id', stash: true },
{
name: 'CustomerSelect',
// Defaults: what a page may still change.
defaults: {
label: 'Customer',
placeholder: 'Choose a customer',
emptyText: 'No customers match',
},
slots: { option: customerOption },
}
);
export const CustomerMultiSelect = definePreset(
BbSelect<Customer>,
{
items: getCustomers,
itemText: 'name',
itemValue: 'id',
multiple: true, // fixed, so v-model is number[]
stash: true,
},
{
name: 'CustomerMultiSelect',
defaults: { label: 'Customers', emptyText: 'No customers match' },
slots: { option: customerOption },
}
);
// Any component, not only selects.
export const SearchInput = definePreset(
BbTextInput,
{ type: 'search', 'prepend:icon': 'lucide:search' },
{ name: 'SearchInput', defaults: { label: 'Search' } }
);
- Pass the row type:
BbSelect<Customer>. Without it the rows are untyped, and so are the slots, theptfunctions and the model. - What defines the data is fixed; what a page may change is a default.
items,itemText,itemValue,stashandmultiplego in the second argument.label,placeholderandemptyTextgo indefaults. multipleis fixed or left to the caller, never a default: the model's shape depends on it, and the helper rejects it indefaults.- A provider preset must keep the selection. Every call the select starts
itself passes a
reasonother than'search'; answer it with the selected customers too, or a saved id on an edit page resolves to nothing. See The reason and the prefill prop.stashkeeps picked customers resolvable across later searches. - Preset slots are render functions written with
h, and one renderer can serve several presets. Anoptionslot replaces the library's text element, so the row sets its own text size and truncation. For more than a line of markup, write the row as a component:option: (row) => h(CustomerOption, row). - Give every preset a
name, so Vue devtools and warnings sayCustomerSelect.
SearchInput presets a BbTextInput: the search type and the icon come from
the preset, and the page sets only the label and the placeholder.
- Marta Villoresi[email protected]
- Sofia Marchetti[email protected]
- Amara Okonkwo[email protected]
- Hugo Ferreira[email protected]
- and 16 more
<template>
<div class="grid max-w-md gap-3">
<!-- A BbTextInput preset: the search type and the icon come from it. -->
<SearchInput
id="customer-search"
v-model="query"
label="Find a customer"
name="customer-search"
placeholder="Name or email"
/>
<ul aria-label="Matching customers" class="m-0 grid list-none gap-1 p-0 text-sm">
<li v-for="customer in shown" :key="customer.id" class="flex items-center gap-3 py-1">
<span class="min-w-0 flex-1 truncate">{{ customer.name }}</span>
<span class="min-w-0 truncate text-xs opacity-70">{{ customer.email }}</span>
</li>
<li v-if="!results.length" class="py-1 opacity-70">No customers match.</li>
<li v-else-if="results.length > shown.length" class="py-1 text-xs opacity-70">
and {{ results.length - shown.length }} more
</li>
</ul>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { matches } from '~/demo-data';
import { customers } from '~/demo-data/customers';
import { SearchInput } from './customers';
const query = ref('');
const results = computed(() =>
customers.filter(
(customer) => matches(customer.name, query.value) || matches(customer.email, query.value)
)
);
const shown = computed(() => results.value.slice(0, 4));
</script>
Import a preset where you use it, like any component. Because it lives in a
.ts module, editing it reloads the components that import it, and their state
resets.
What the caller gets
The preset is typed for the caller, more strictly than the component it presets.
A plain BbSelect types its v-model as any; the preset knows the rows and
their key, so CustomerSelect takes a number | null.
| On the preset | Typed as | At runtime |
|---|---|---|
v-model | Customer['id'] | null; Customer['id'][] with multiple, fixed in the preset or passed by the caller | straight to BbSelect |
a fixed prop (item-text) | a type error | the preset's value wins |
a default prop (label) | the component's own type, optional | the caller's value wins |
| every other prop | the component's own type | straight through |
events (@blur, @change) | the component's own payloads | straight through |
| slots | the component's slots, with item typed as a Customer | the caller's slot replaces the preset's |
per-item pt functions | ({ item }) => …, item is a Customer | straight through |
<CustomerSelect v-model="customerId" clearable @blur="touch('customer')" />
<CustomerSelect v-model="ownerId" label="Owner">
<template #option="{ item }">{{ item.name }} · {{ item.email }}</template>
</CustomerSelect>
<SearchInput v-model="query" placeholder="Name or email" />
Three errors you will meet:
- Passing a fixed prop fails with
Type 'string' is not assignable to type 'undefined': the preset owns that prop. - A dotted path as
item-value('meta.code') cannot be typed, so the model falls back toBbSelect's own loose type. The model is typed only from a key or a function, so write a getter:itemValue: (c: Customer) => c.meta.code, - With
multipleleft to the caller, a wrong model reports the whole union type in the error. Fix the model, or fixmultiplein the preset.
Inside a form
Inside a BbForm a preset validates like the
component it presets: name, rules and the rest are its props.
Submitted values
Nothing yet. Submit with no customer picked first.
<template>
<div class="grid gap-6 md:grid-cols-[minmax(0,22rem)_minmax(0,1fr)]">
<!-- A preset joins the form like the BbSelect it presets: name, rules. -->
<BbForm class="flex flex-col gap-4" @submit="onSubmit">
<CustomerSelect id="transfer-from" label="From customer" name="from" rules="required" />
<CustomerSelect id="transfer-to" label="To customer" name="to" rules="required|not_same:@from" />
<BbButton class="self-start" type="submit" variant="primary">
Transfer open orders
</BbButton>
</BbForm>
<section aria-live="polite" class="grid content-start gap-2 text-sm">
<p class="m-0 font-medium">Submitted values</p>
<pre v-if="submitted" class="m-0 rounded-[var(--bb-radius)] bg-[var(--bb-muted)] p-3 text-xs">{{ JSON.stringify(submitted, null, 2) }}</pre>
<p v-else class="m-0 text-[color:var(--bb-text-muted)]">
Nothing yet. Submit with no customer picked first.
</p>
</section>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { defineRule } from 'vee-validate';
import { required } from '@vee-validate/rules';
import { BbButton, BbForm } from 'bitboss-ui';
import { delay } from '~/demo-data';
import { CustomerSelect } from './customers';
// In an app these live once in a validators module, not in the component.
defineRule('required', (value: unknown) => required(value) || 'Required.');
defineRule('not_same', (value: unknown, [other]: [unknown]) =>
value !== other || 'Pick a different customer.'
);
const submitted = ref<Record<string, unknown> | null>(null);
const onSubmit = async (values: Record<string, unknown>) => {
await delay(null, 500);
submitted.value = values;
};
</script>
Both fields are the same CustomerSelect. The second one's not_same:@from is
a custom rule, defined in the demo's script, and @from passes it the value of
the from field. Rules are registered as in
Validated forms.
When it needs logic of its own
Never wrap a preset in a component file to add one prop. A component that needs its own props or state has stopped being a preset: write the SFC, and declare only the props it reads.
A declared prop never reaches $attrs. A wrapper that declares every
BbSelect prop and binds two of them drops the caller's disabled, clearable
or pt in silence, and the type checker accepts them all. The - lines below
are such a wrapper; the + lines are the fix.
<script setup lang="ts">
import { BbSelect, type BbSelectProps } from 'bitboss-ui';
import { getCustomers, type Customer } from '@/api/customers';
const props = defineProps<
- Omit<BbSelectProps<Customer>, 'items' | 'itemText' | 'itemValue'>
+ Partial<Pick<BbSelectProps<Customer>, 'label'>>
>();
</script>
<template>
<BbSelect
- :hint="props.hint"
item-text="name"
item-value="id"
:items="getCustomers"
- :label="props.label"
+ :label="props.label ?? 'Customer'"
/>
</template>
On the - version, npx bitboss-ui check
reports wrapper-drops-props at the defineProps line:
✗ line 5: this wrapper declares BbSelect props it never
passes on: `adaptive`, `append:icon`, `autocomplete`,
`autofocus`, `clearable`, `comma`, `compact`,
`dependencies` and 50 more. A caller's value
type-checks and is silently dropped (declared props do
not fall through to $attrs). Bind each one on
<BbSelect>, or declare only what the wrapper uses
(`Pick<BbSelectProps<…>, 'label' | …>`) and let the
rest fall through
The fix takes the message's second option: it declares only label, and
$attrs carries the caller's hint, disabled and the rest to BbSelect.
The other rules for a wrapper (the row type, Omit, type-only imports) are in
Options and items.
The helper's source
Copy this file unchanged into src/lib/definePreset.ts.
// bitboss-ui definePreset rev 3 — update: cp node_modules/bitboss-ui/dist/ai/helpers/definePreset.ts <this file>
/**
* definePreset: an app component made of a bitboss-ui component with some
* props (and slots) already set, e.g. a `CustomerSelect`.
*
* App-side code, not exported by `bitboss-ui`: copy it into your project,
* cp node_modules/bitboss-ui/dist/ai/helpers/definePreset.ts src/lib/definePreset.ts
* and run the same command when `bitboss-ui check` reports
* `stale-define-preset` (the stamp on line 1 is older than the shipped one).
* Recipe: ai/guides/preset-components.md.
*
* export const CustomerSelect = definePreset(
* BbSelect<Customer>, // the row type
* { items: getCustomers, itemText: 'name', itemValue: 'id' }, // fixed
* { name: 'CustomerSelect', defaults: { label: 'Customer' } },
* );
*
* - `fixed` props are locked: passing one is a type error, and at runtime the
* preset value wins.
* - `defaults` props stay optional props; the caller's value wins.
* - `slots` are default slot renderers; a slot the caller passes wins.
* - Nothing is declared, so `v-model`, every other prop, every listener and
* every slot reach the component untouched: no re-emit, no forwarding list.
* - With `items` and a key as `itemValue` fixed, `v-model` is typed as that
* key's type (`number | null`, or `number[]` with `multiple`).
*
* Built against the types vue-tsc generates for `<script setup generic>`
* components; the library's wrapper bench (src/wrapper-bench/) type-checks
* and mounts it on every change.
*/
/* eslint-disable @typescript-eslint/no-explicit-any -- matching any component's props and functions */
import { defineComponent, h, type Component, type VNode } from 'vue';
type Empty = Record<never, never>;
// ─── Reading a component's public surface ───────────────────────────────────
type AnyFn = (...args: any) => any;
/** Props of a class-shaped component, or of a generic SFC (a function). */
type PropsOf<C> = C extends new (...args: any) => { $props: infer P }
? NonNullable<P>
: C extends (props: infer P, ...args: any) => any
? P
: never;
type CtxOf<C> = C extends AnyFn
? NonNullable<ReturnType<C>> extends { __ctx?: infer X }
? NonNullable<X>
: never
: never;
type SlotsOf<C> = C extends new (...args: any) => { $slots: infer S }
? NonNullable<S>
: CtxOf<C> extends { slots: infer S }
? NonNullable<S>
: Empty;
type EmitOf<C> = C extends new (...args: any) => { $emit: infer E }
? E
: CtxOf<C> extends { emit: infer E }
? E
: never;
// Vue's own props (key, ref, class, style, vnode hooks) are not presets.
type Reserved =
| 'key'
| 'ref'
| 'ref_for'
| 'ref_key'
| 'class'
| 'style'
| `onVnode${string}`;
// vue-tsc types a generic SFC's props as `… & Record<string, unknown>`. A
// mapped type or `Omit` over that folds every prop into the index signature
// and nothing is checked any more, so the signature is dropped first.
type OwnProps<P> = {
[K in keyof P as string extends K
? never
: K extends Reserved
? never
: K]: P[K];
};
// `const F` infers an array literal (`filterBy: []`, `items: [row]`) as a
// readonly tuple, which a mutable `string[]` prop rejects. A failed
// constraint makes F fall back to the constraint itself, and then EVERY prop
// reads as fixed (all `undefined` at the call site). So the constraint also
// accepts the readonly form of each array prop.
type Frozen<T> = T extends ReadonlyArray<infer E> ? readonly E[] : never;
type Presettable<P> = { [K in keyof P]?: P[K] | Frozen<P[K]> };
// ─── The v-model, from the fixed `items` + `itemValue` ─────────────────────
type ModelKeys = 'modelValue' | 'onUpdate:modelValue' | 'multiple';
type RowOf<P> = P extends { items?: infer I }
? Extract<NonNullable<I>, readonly unknown[]>[number]
: never;
/**
* With no `itemValue`, an option surface (a component with `itemText`) emits
* a row's own `value` when it has one, else the row (Q60.24). BbTable keeps
* the row.
*/
type DefaultValueOf<P> = P extends { itemText?: unknown }
? RowOf<P> extends infer R
? R extends { value: infer V }
? V
: R
: never
: RowOf<P>;
/**
* The value type when the preset decides it: a fixed key or getter as
* `itemValue`, or fixed `items` with no `itemValue` (the row's own `value`,
* else the row).
* `never` = not decided (a dotted path, or `itemValue` left to the caller):
* the component keeps its own model type.
*/
type ValueOf<P, F> = [RowOf<P>] extends [never]
? never
: F extends { itemValue: infer K }
? K extends keyof RowOf<P>
? RowOf<P>[K]
: K extends (row: any) => infer R
? R
: never
: F extends { items: unknown }
? DefaultValueOf<P>
: never;
type ModelProps<V, F> = F extends { multiple: true }
? { modelValue?: V[]; 'onUpdate:modelValue'?: (value: V[]) => any }
: F extends { multiple: false }
? {
modelValue?: V | null;
'onUpdate:modelValue'?: (value: V | null) => any;
}
:
| {
multiple?: false;
modelValue?: V | null;
'onUpdate:modelValue'?: (value: V | null) => any;
}
| {
multiple: true;
modelValue?: V[];
'onUpdate:modelValue'?: (value: V[]) => any;
};
// ─── The preset component's props, events and slots ────────────────────────
type Locked<F> = { [K in keyof F]?: never };
type PresetProps<P, F, D> = [ValueOf<P, F>] extends [never]
? Omit<OwnProps<P>, keyof F | keyof D> &
Partial<Pick<OwnProps<P>, keyof D & keyof OwnProps<P>>> &
Locked<F>
: Omit<OwnProps<P>, keyof F | keyof D | ModelKeys> &
Partial<Pick<OwnProps<P>, keyof D & keyof OwnProps<P>>> &
Locked<Omit<F, ModelKeys>> &
ModelProps<ValueOf<P, F>, F>;
type PresetEmit<C, P, F> = [ValueOf<P, F>] extends [never]
? EmitOf<C>
: EmitOf<C> &
((
e: 'update:modelValue',
value: F extends { multiple: true }
? Array<ValueOf<P, F>>
: ValueOf<P, F> | Array<ValueOf<P, F>> | null
) => void);
/** The shape vue-tsc gives a generic SFC, so templates check it the same way. */
type PresetComponent<Props, Slots, Emit> = ((
props: Props & { key?: PropertyKey; class?: unknown; style?: unknown },
ctx?: { attrs: Record<string, unknown>; slots: Slots; emit: Emit },
expose?: (exposed: Empty) => void,
setup?: unknown
) => VNode & {
__ctx?: {
props: Props;
slots: Slots;
emit: Emit;
attrs: Record<string, unknown>;
expose: (exposed: Empty) => void;
};
}) & { name?: string };
type PresetOptions<C, F, D> = {
/** Devtools and warning name, e.g. `'CustomerSelect'`. */
name?: string;
/** Props the caller may still override. The v-model keys cannot be defaults. */
defaults?: D &
Record<Extract<keyof D, keyof F | ModelKeys | 'itemValue'>, never>;
/** Default slot renderers; a slot the caller passes wins. */
slots?: Partial<SlotsOf<C>>;
};
export function definePreset<
C,
const F extends Presettable<OwnProps<PropsOf<C>>>,
const D extends Presettable<OwnProps<PropsOf<C>>> = Empty,
>(
component: C,
fixed: F,
options: PresetOptions<C, F, D> = {}
): PresetComponent<
PresetProps<PropsOf<C>, F, D>,
SlotsOf<C>,
PresetEmit<C, PropsOf<C>, F>
> {
const base = component as unknown as Component & {
name?: string;
__name?: string;
};
const defaults = (options.defaults ?? {}) as Record<string, unknown>;
const presetSlots = (options.slots ?? {}) as Record<string, unknown>;
return defineComponent({
name: options.name ?? `${base.name ?? base.__name ?? 'Anonymous'}Preset`,
inheritAttrs: false,
setup(_, { attrs, slots }) {
// The later key wins, camelCase or kebab-case alike: the caller's
// over a default, a fixed prop over the caller's.
return () =>
h(base, { ...defaults, ...attrs, ...fixed }, {
...presetSlots,
...slots,
} as never);
},
}) as never;
}