Skip to content

Preset components

Make an app component, such as a CustomerSelect, from a bitboss-ui component with its props and slots preset.

On this page

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.

ts
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.

Ingrid Halvorsen

Model: 7

Same as customer

Model: null

Anneke de VriesMarta Villoresi

Model: [17,1]

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 rowa preset
has logic of its own: state, derived props, extra props it reads, markup around the controla wrapper SFC (below)
appears on one pageneither: set the props on BbSelect there
only restyles parts, everywhere or in one areaneither: 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:

bash
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:

ts
// 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, the pt functions and the model.
  • What defines the data is fixed; what a page may change is a default.items, itemText, itemValue, stash and multiple go in the second argument. label, placeholder and emptyText go in defaults.
  • multiple is fixed or left to the caller, never a default: the model's shape depends on it, and the helper rejects it in defaults.
  • A provider preset must keep the selection. Every call the select starts itself passes a reason other 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. stash keeps picked customers resolvable across later searches.
  • Preset slots are render functions written with h, and one renderer can serve several presets. An option slot 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 say CustomerSelect.

SearchInput presets a BbTextInput: the search type and the icon come from the preset, and the page sets only the label and the placeholder.

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 presetTyped asAt runtime
v-modelCustomer['id'] | null; Customer['id'][] with multiple, fixed in the preset or passed by the callerstraight to BbSelect
a fixed prop (item-text)a type errorthe preset's value wins
a default prop (label)the component's own type, optionalthe caller's value wins
every other propthe component's own typestraight through
events (@blur, @change)the component's own payloadsstraight through
slotsthe component's slots, with item typed as a Customerthe caller's slot replaces the preset's
per-item pt functions({ item }) => …, item is a Customerstraight through
vue
<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 to BbSelect's own loose type. The model is typed only from a key or a function, so write a getter:
    ts
    itemValue: (c: Customer) => c.meta.code,
    
  • With multiple left to the caller, a wrong model reports the whole union type in the error. Fix the model, or fix multiple in the preset.

Inside a form

Inside a BbForm a preset validates like the component it presets: name, rules and the rest are its props.

Choose a customer
Choose a customer

Submitted values

Nothing yet. Submit with no customer picked first.

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.

diff
 <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:

text
✗ 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.

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;
}