# Field: Select (category: selection)

URL: https://docs.forms.saastro.io/docs/select

<FieldPreview name="select" description="Interactive demo of select field" />

## Overview

The select field provides a dropdown menu for selecting a single option from a list. It's built on Radix UI Select for full accessibility support.

For native HTML select elements (better mobile support), see [Native Select](/docs/native-select).

## Usage

### Basic Select

```tsx

const config = FormBuilder.create('profile')
  .addField('country', (f) =>
    f
      .type('select')
      .label('Country')
      .placeholder('Select a country')
      .options([
        { label: 'United States', value: 'us' },
        { label: 'Canada', value: 'ca' },
        { label: 'Mexico', value: 'mx' },
        { label: 'United Kingdom', value: 'uk' },
      ])
      .required('Please select a country'),
  )
  .addStep('main', ['country'])
  .build();
```

### With Icons

```tsx

.addField('contactMethod', (f) =>
  f.type('select')
    .label('Preferred Contact')
    .options([
      { label: 'Email', value: 'email', icon: <Mail className="w-4 h-4" /> },
      { label: 'Phone', value: 'phone', icon: <Phone className="w-4 h-4" /> },
      { label: 'SMS', value: 'sms', icon: <MessageSquare className="w-4 h-4" /> },
    ])
    .required()
)
```

### JSON Configuration

```json
{
  "type": "select",
  "label": "Country",
  "placeholder": "Select a country",
  "options": [
    { "label": "United States", "value": "us" },
    { "label": "Canada", "value": "ca" },
    { "label": "Mexico", "value": "mx" }
  ],
  "schema": {
    "required": true,
    "requiredMessage": "Please select a country"
  }
}
```

## Props

| Prop          | Type                                                  | Default | Description                              |
| ------------- | ----------------------------------------------------- | ------- | ---------------------------------------- |
| `type`        | `'select'`                                            | -       | Field type (required)                    |
| `options`     | `Option[]`                                            | -       | Array of options (required)              |
| `placeholder` | `string`                                              | -       | Placeholder text when no option selected |
| `label`       | `string`                                              | -       | Field label                              |
| `helperText`  | `string`                                              | -       | Help text shown below field              |
| `size`        | `'sm' \| 'md' \| 'lg'`                                | `'md'`  | Input size variant                       |
| `columns`     | `Partial<Record<Breakpoint, number>>`                 | -       | Grid columns by breakpoint               |
| `disabled`    | `boolean \| function \| ConditionGroup`               | `false` | Disable the select                       |
| `hidden`      | `boolean \| function \| ConditionGroup \| Responsive` | `false` | Hide the field                           |

### Option Type

```tsx
type Option = {
  label: string;
  value: string;
  icon?: ReactNode;
  iconProps?: Record<string, unknown>;
};
```

## Conditional Options

Options can be loaded dynamically or filtered based on other field values:

```tsx
// In your component
const countryOptions = useMemo(() => {
  if (region === 'north-america') {
    return [
      { label: 'United States', value: 'us' },
      { label: 'Canada', value: 'ca' },
      { label: 'Mexico', value: 'mx' },
    ];
  }
  // ... other regions
}, [region]);
```

## Async Options

Load options from an API with `loadOptions` — an async function called **once when the field mounts**. While it resolves, the static `options` array is shown as a fallback (pass `[]` for a purely-async field) and the control is disabled. Available on `select`, `combobox`, and `native-select`.

```tsx
.addField('country', (f) =>
  f.type('select')
    .label('Country')
    .options([]) // fallback shown while loading
    .loadOptions(async () => {
      const res = await fetch('/api/countries');
      const data = await res.json();
      return data.map((c) => ({ label: c.name, value: c.code }));
    })
    .required('Please select a country'),
)
```

If the loader rejects, the static fallback is kept (the form does not crash).

### Serializable URL source

Because `loadOptions` is a function it isn't part of the JSON config. For builder/Hub forms — or any config you store as JSON — use `optionsSource` instead: a plain object that's compiled into the same loader (fetch + map), so it survives serialization.

```tsx
.addField('country', (f) =>
  f.type('select')
    .label('Country')
    .options([])
    .optionsSource({
      url: '/api/countries',
      path: 'data',        // dot-path to the array in the response (optional)
      labelKey: 'name',    // default 'label'
      valueKey: 'code',    // default 'value'
    }),
)
```

```json
{
  "type": "select",
  "label": "Country",
  "options": [],
  "optionsSource": { "url": "/api/countries", "path": "data", "labelKey": "name", "valueKey": "code" }
}
```

The request has a 10s abort timeout (configurable via `timeoutMs`). If both `loadOptions` and `optionsSource` are set, `loadOptions` wins.

### Server-side search (combobox)

A **combobox** can opt into server-side search with `serverSearch: true`: instead of loading once and filtering client-side, it re-runs the loader (debounced ~300ms) with the typed text so the server returns the matches. `loadOptions` receives the term, and `optionsSource` appends it as `?{searchParam}={term}`.

```tsx
.addField('city', (f) =>
  f.type('combobox')
    .label('City')
    .options([])
    .serverSearch(true)
    .optionsSource({ url: '/api/cities', searchParam: 'q', labelKey: 'name', valueKey: 'id' }),
)
// or with a function:
.addField('city', (f) =>
  f.type('combobox').label('City').options([]).serverSearch(true)
    .loadOptions(async (term) => fetch(`/api/cities?q=${term ?? ''}`).then((r) => r.json())),
)
```

Off by default — existing combobox behaviour (load-once + client filter) is unchanged. Dependency-driven reloading is still a planned follow-up.

## Validation

### Required

```tsx
.addField('country', (f) =>
  f.type('select')
    .label('Country')
    .options([...])
    .required('Please select a country')
)
```

### With Zod

```tsx

.addField('country', (f) =>
  f.type('select')
    .label('Country')
    .options([...])
    .schema(z.enum(['us', 'ca', 'mx', 'uk']))
)
```

## Styling

### Custom Classes

Apply CSS classes to different parts of the field:

```tsx
.addField('country', (f) =>
  f.type('select')
    .label('Country')
    .options([...])
    .classNames({
      wrapper: 'bg-muted/50 p-4 rounded-lg',
      input: 'border-primary',
      label: 'text-primary font-medium',
      error: 'text-destructive',
      helper: 'text-muted-foreground text-xs',
    })
)
```

### Responsive Layout

```tsx
.addField('country', (f) =>
  f.type('select')
    .label('Country')
    .options([...])
    .columns({ default: 12, md: 6, lg: 4 })
    .size('lg')
)
```

### JSON Styling

```json
{
  "type": "select",
  "label": "Country",
  "wrapper_className": "bg-muted/50 p-4 rounded-lg",
  "input_className": "border-primary",
  "label_className": "text-primary font-medium",
  "columns": { "default": 12, "md": 6 }
}
```

## Related Fields

- [Native Select](/docs/native-select) - HTML native select element
- [Combobox](/docs/combobox) - Searchable select with autocomplete
- [Command](/docs/command) - Command palette style selection
- [Radio](/docs/radio) - Radio buttons for visible options

## Accessibility

- Full keyboard navigation
- Screen reader announcements
- Focus management
- ARIA attributes automatically applied
- Works with form labels
