Skip to content

select

A filterable combobox, rendered as the <dl-select> web component. It exists because a native <select> cannot do async-loaded, type-to-filter, multi-select options.

Click the field and type to filter. The value stored is the option's value, not its label.

ts
{
  fields: [
    {
      name: 'language',
      kind: 'select',
      displayName: 'Language',
      options: [
        { value: 'en', label: 'English' },
        { value: 'de', label: 'German' },
        { value: 'fr', label: 'French' },
      ],
      defaultValue: 'en',
    },
    {
      name: 'tags',
      kind: 'select',
      displayName: 'Tags (multiple)',
      multiple: true,
      options: ['docs', 'draft', 'internal'],
    },
  ],
}
Rendered form

Values

Options

OptionTypeNotes
optionsReactive<SelectOption[] | Promise<SelectOption[]>>Required. Four shapes, below
multiplebooleanValue becomes a string[]
placeholderReactive<string>Shown when nothing is selected
onOptionsError(ctx) => FieldMessage | undefinedTurns a rejected load into a tooltip message

Plus everything in the shared options.

The four option shapes

Plain strings — value and label are the same:

ts
options: ['Admin', 'Editor', 'Viewer'];

Value and label — when the stored value differs from what is shown:

ts
options: [
  { value: 'en', label: 'English' },
  { value: 'de', label: 'German' },
];

With displayWhenSelected — a third, shorter form shown once chosen, while the dropdown keeps the descriptive label:

ts
options: [
  { value: 'bk', label: 'Book', displayWhenSelected: 'Type: Book' },
  { value: 'art', label: 'Journal Article', displayWhenSelected: 'Type: Article' },
];

A function — sync or async, of the form context:

ts
options: async ({ data }) => {
  const res = await fetch(`/api/repos?owner=${String(data['owner'])}`);
  return (await res.json()).map((r) => ({ value: r.id, label: r.name }));
};

Async loading

When options returns a promise the field shows a loading skeleton. Two details make it feel deliberate rather than flickery:

  • The skeleton only appears after ~100 ms, so fast loads never flash one.
  • Once shown it stays for a minimum time rather than blinking out.

A value set while options are still loading is replayed once they arrive, so a defaultValue never races the fetch. The demo below delays for a second, and still ends up on French:

ts
{
  fields: [
    {
      name: 'lang',
      kind: 'select',
      displayName: 'Language (loads slowly)',
      options: async () => {
        await new Promise((r) => setTimeout(r, 1000));
        return [
          { value: 'en', label: 'English' },
          { value: 'fr', label: 'French' },
        ];
      },
      defaultValue: 'fr',
    },
  ],
}
Rendered form

Values

Reloading when another field changes

List reloadOnChangeOf to re-run an options function when a dependency changes:

ts
{
  name: 'owner',
  displayName: 'Owner',
},
{
  name: 'repo',
  kind: 'select',
  displayName: 'Repository',
  reloadOnChangeOf: ['owner'],
  options: async ({ data }) => fetchRepos(String(data['owner'])),
}

Stale responses are discarded

Each load claims a generation token. If the user keeps typing and a newer load starts, an older in-flight response is dropped rather than overwriting the fresher options. You do not need to debounce for correctness.

See Reactivity for the full picture.

Handling load failures

An options function that rejects leaves the field empty and silent unless you say what should happen. onOptionsError turns the error into a tooltip:

ts
{
  name: 'repo',
  displayName: 'Repository',
  tooltip: 'Pick a repository.',
  kind: 'select',
  options: async () => fetchRepos(),
  onOptionsError: ({ error }) => ({
    level: 'error',
    text: `Could not load repositories: ${(error as Error).message}`,
  }),
}

level is 'info', 'warning' or 'error'; the first two render as a warning marker, the third as an error marker. The tooltip resets automatically on the next successful reload.

Needs a tooltip to show in

The message is displayed on the field's ? marker, so the field must declare a tooltip for there to be somewhere to put it. Without one, the handler runs but nothing is visible.

Multi-select

ts
{
  name: 'tags',
  kind: 'select',
  displayName: 'Tags',
  multiple: true,
  options: ['docs', 'draft', 'internal'],
  defaultValue: ['docs'],
}

The value is a string[]. Each selection renders a removable tag; already selected options are marked and cannot be picked twice.

Rich option labels

Option labels are rendered as text by default. To include markup — a badge, say — mark it explicitly with html():

ts
import { html } from 'declarative-forms';

options: [
  {
    value: 'offline',
    label: html('Formless Writing <span class="dl-option-tag">Offline</span>'),
  },
];

See Text & HTML safety.

Values

getValue() returns the selected option's value, not its label — '' when nothing is selected, or [] for a multiple field.

ts
form.field('language')?.setValue('de');
form.getValues()['language']; // 'de'

Setting a value that matches no option is ignored rather than throwing.

Released under the MIT License.