# Select

## Summary

`Select` is a [compound component](https://vega-ui.dev/docs/glossary/#compound-component) built from a root state container plus exported child parts for trigger, value, options, and native [form participation](https://vega-ui.dev/docs/glossary/#form-participation). `SelectListbox` renders in the browser top layer via `popover="manual"`, so it is never clipped by `overflow` or covered by a modal dialog. It is the field-oriented single-choice layer, not a generic floating-content primitive.

## Imports

```tsx
import {
  Select,
  SelectCombobox,
  SelectHiddenSelect,
  SelectIcon,
  SelectListbox,
  SelectOption,
  SelectValue,
  type SelectProps,
  type SelectSize,
  type SelectVariant,
} from '@vega-ui/react';
```

## Exported Types

- `SelectProps<V extends string | number = string>`
- `SelectSize = 'sm' | 'md' | 'lg' | string`
- `SelectVariant = 'inline' | 'field' | string`

## Minimal Composition

```tsx
import {
  Label,
  Select,
  SelectCombobox,
  SelectHiddenSelect,
  SelectIcon,
  SelectListbox,
  SelectOption,
  SelectValue,
} from '@vega-ui/react';
import { useId } from 'react';

export default function MinimalComposition() {
  const id = useId();

  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-3)', width: '100%', maxWidth: 280 }}>
      <Label htmlFor={id}>Interface language</Label>
      <Select defaultValue='en'>
        <SelectHiddenSelect name='language' />
        <SelectCombobox id={id}>
          <SelectValue placeholder='Choose a language' />
          <SelectIcon />
        </SelectCombobox>
        <SelectListbox>
          <SelectOption value='en'>English</SelectOption>
          <SelectOption value='de'>Deutsch</SelectOption>
          <SelectOption value='es'>Español</SelectOption>
          <SelectOption value='ja'>日本語</SelectOption>
        </SelectListbox>
      </Select>
    </div>
  );
}
```

## Required Parts

- `Select`: root state and context provider
- `SelectCombobox`: trigger surface
- `SelectValue`: selected value or placeholder
- `SelectListbox`: options container
- `SelectOption`: selectable option item

## Optional Parts

- `SelectHiddenSelect`: native [form participation](https://vega-ui.dev/docs/glossary/#form-participation)
- `SelectIcon`: visual affordance for the trigger

## Composition Order

Typical composition:

1. `Select`
2. `SelectHiddenSelect`
3. `SelectCombobox`
4. `SelectListbox`
5. `SelectOption`

## Variants

- Size: `sm`, `md`, `lg`
- Variant: `field`, `inline`
- State variants: [controlled](https://vega-ui.dev/docs/glossary/#controlled) value, [controlled](https://vega-ui.dev/docs/glossary/#controlled) open state, [disabled](https://vega-ui.dev/docs/glossary/#disabled), [read-only](https://vega-ui.dev/docs/glossary/#read-only), searchable composition

## Edge Cases

- `disabled` blocks interaction completely; `readOnly` preserves focusability while preventing changes. See [Disabled](https://vega-ui.dev/docs/glossary/#disabled) and [Read-Only](https://vega-ui.dev/docs/glossary/#read-only).
- Filtering inside the listbox should usually disable built-in [typeahead](https://vega-ui.dev/docs/glossary/#typeahead) with `typeahead={false}` to avoid keyboard conflicts.
- Values are typed as `string | number`; object values are not supported by the public API.
- `SelectHiddenSelect` is recommended when the value should participate in native [form participation](https://vega-ui.dev/docs/glossary/#form-participation).
- If the product needs richer arbitrary overlay content rather than one scalar value, the correct abstraction is usually `Popover` or `Dialog`, not `Select`.

## Common Mistakes

- Passing object values instead of `string` or `number`.
- Forgetting `SelectHiddenSelect` in native form flows.
- Leaving built-in [typeahead](https://vega-ui.dev/docs/glossary/#typeahead) enabled when embedding a searchable input into the listbox.
- Using `readOnly` when the intended behavior is a fully [disabled](https://vega-ui.dev/docs/glossary/#disabled) control.

## Examples

### Basic

#### Basic: field variant with a visible label

A visible `Label` points at the `SelectCombobox` by `id`, which gives the trigger its accessible name. Options are rendered from data, and the placeholder shows until the user picks a team.

```tsx
import {
  Label,
  Select,
  SelectCombobox,
  SelectIcon,
  SelectListbox,
  SelectOption,
  SelectValue,
} from '@vega-ui/react';
import { useId } from 'react';

const teams = [
  { value: 'billing', label: 'Billing' },
  { value: 'onboarding', label: 'Onboarding' },
  { value: 'platform', label: 'Platform reliability' },
  { value: 'security', label: 'Security & compliance' },
  { value: 'success', label: 'Customer success' },
];

export default function FieldVariant() {
  const id = useId();

  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-3)', width: '100%', maxWidth: 280 }}>
      <Label htmlFor={id}>Route the ticket to</Label>
      <Select>
        <SelectCombobox id={id}>
          <SelectValue placeholder='Choose a team' />
          <SelectIcon />
        </SelectCombobox>
        <SelectListbox>
          {teams.map((team) => (
            <SelectOption key={team.value} value={team.value}>
              {team.label}
            </SelectOption>
          ))}
        </SelectListbox>
      </Select>
    </div>
  );
}
```

### Controlled/Stateful

#### Controlled/Stateful: controlled value with clear action

The parent owns the priority and derives the response time from it. Clearing sets the value to `null`: `undefined` would switch the select to uncontrolled mode and keep the old selection.

```tsx
import {
  Button,
  Label,
  Select,
  SelectCombobox,
  SelectIcon,
  SelectListbox,
  SelectOption,
  SelectValue,
  Text,
} from '@vega-ui/react';
import { useId, useState } from 'react';

const priorities = [
  { value: 'low', label: 'Low', response: 'within 3 business days' },
  { value: 'normal', label: 'Normal', response: 'within 1 business day' },
  { value: 'urgent', label: 'Urgent', response: 'within 1 hour' },
];

export default function ControlledValueWithClearAction() {
  const id = useId();
  const [priority, setPriority] = useState<string | null>('normal');
  const current = priorities.find((item) => item.value === priority);

  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-3)', width: '100%', maxWidth: 340 }}>
      <Label htmlFor={id}>Priority</Label>
      <div style={{ display: 'flex', gap: 'var(--spacing-4)' }}>
        <Select style={{ flex: 1 }} value={priority} onChangeValue={(next) => setPriority(next ?? null)}>
          <SelectCombobox id={id}>
            <SelectValue placeholder='Not set' />
            <SelectIcon />
          </SelectCombobox>
          <SelectListbox>
            {priorities.map((item) => (
              <SelectOption key={item.value} value={item.value}>
                {item.label}
              </SelectOption>
            ))}
          </SelectListbox>
        </Select>
        <Button
          variant='secondary'
          appearance='ghost'
          disabled={priority === null}
          onClick={() => setPriority(null)}
        >
          Clear
        </Button>
      </div>
      <Text size={2} style={{ color: 'var(--label-secondary)', marginBlockStart: 'var(--spacing-2)' }}>
        {current ? `First response ${current.response}.` : 'No priority: the ticket waits in the general queue.'}
      </Text>
    </div>
  );
}
```

#### Controlled/Stateful: controlled open state

`open` and `onChangeOpen` let another control open the list. The button moves focus to the trigger first, so arrow keys and Enter work right away, and a hint is shown only while the list is open.

```tsx
import {
  Button,
  Label,
  Select,
  SelectCombobox,
  SelectIcon,
  SelectListbox,
  SelectOption,
  SelectValue,
  Text,
} from '@vega-ui/react';
import { useId, useRef, useState } from 'react';

export default function ControlledOpenState() {
  const id = useId();
  const comboboxRef = useRef<HTMLButtonElement>(null);
  const [open, setOpen] = useState(false);
  const [reviewer, setReviewer] = useState<string | null>(null);

  const chooseReviewer = () => {
    comboboxRef.current?.focus();
    setOpen(true);
  };

  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-3)', width: '100%', maxWidth: 320 }}>
      <Label htmlFor={id}>Reviewer</Label>
      <Select
        open={open}
        onChangeOpen={setOpen}
        value={reviewer}
        onChangeValue={(next) => setReviewer(next ?? null)}
      >
        <SelectCombobox id={id} ref={comboboxRef}>
          <SelectValue placeholder='Nobody assigned' />
          <SelectIcon />
        </SelectCombobox>
        <SelectListbox>
          <SelectOption value='amara'>Amara Okafor</SelectOption>
          <SelectOption value='lucas'>Lucas Moreau</SelectOption>
          <SelectOption value='mei'>Mei Tanaka</SelectOption>
        </SelectListbox>
      </Select>
      {reviewer === null && (
        <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--spacing-4)', marginBlockStart: 'var(--spacing-2)' }}>
          <Text size={2} style={{ color: 'var(--label-secondary)' }}>Needs one review.</Text>
          <Button size='sm' variant='secondary' appearance='ghost' onClick={chooseReviewer} style={{ flexShrink: 0 }}>
            Choose reviewer
          </Button>
        </div>
      )}
      {open && (
        <Text size={2} style={{ color: 'var(--label-secondary)' }}>
          Arrow keys move through the list, Enter picks, Escape closes.
        </Text>
      )}
    </div>
  );
}
```

### Form/Integration

#### Form/Integration: native form participation

`SelectHiddenSelect` with a `name` puts each value into `FormData`, so the submit handler reads the selects like native fields. Each trigger keeps its own `Label`.

```tsx
import {
  Button,
  Label,
  Select,
  SelectCombobox,
  SelectHiddenSelect,
  SelectIcon,
  SelectListbox,
  SelectOption,
  SelectValue,
  Text,
} from '@vega-ui/react';
import { FormEvent, useId, useState } from 'react';

export default function NativeFormParticipation() {
  const planId = useId();
  const cycleId = useId();
  const [submitted, setSubmitted] = useState('');

  const onSubmit = (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    const data = new FormData(event.currentTarget);
    setSubmitted(`plan=${data.get('plan')}, cycle=${data.get('cycle')}`);
  };

  return (
    <form
      onSubmit={onSubmit}
      style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-8)', width: '100%', maxWidth: 320 }}
    >
      <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-3)' }}>
        <Label htmlFor={planId}>Plan</Label>
        <Select defaultValue='team'>
          <SelectHiddenSelect name='plan' />
          <SelectCombobox id={planId}>
            <SelectValue />
            <SelectIcon />
          </SelectCombobox>
          <SelectListbox>
            <SelectOption value='starter'>Starter</SelectOption>
            <SelectOption value='team'>Team</SelectOption>
            <SelectOption value='enterprise'>Enterprise</SelectOption>
          </SelectListbox>
        </Select>
      </div>
      <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-3)' }}>
        <Label htmlFor={cycleId}>Billing cycle</Label>
        <Select defaultValue='yearly'>
          <SelectHiddenSelect name='cycle' />
          <SelectCombobox id={cycleId}>
            <SelectValue />
            <SelectIcon />
          </SelectCombobox>
          <SelectListbox>
            <SelectOption value='monthly'>Monthly</SelectOption>
            <SelectOption value='yearly'>Yearly, 2 months free</SelectOption>
          </SelectListbox>
        </Select>
      </div>
      <Button type='submit' style={{ alignSelf: 'flex-end', marginBlockStart: 'var(--spacing-4)' }}>Continue to payment</Button>
      {submitted && (
        <Text size={2} style={{ color: 'var(--label-secondary)' }}>
          Submitted: {submitted}
        </Text>
      )}
    </form>
  );
}
```

### Layout/Overlay

#### Layout/Overlay: inline variant inside sentence-like UI

`variant='inline'` makes the select part of a sentence. The surrounding text is not a label, so the trigger gets its own `aria-label`.

```tsx
import {
  Select,
  SelectCombobox,
  SelectHiddenSelect,
  SelectIcon,
  SelectListbox,
  SelectOption,
  SelectValue,
  Text,
} from '@vega-ui/react';

export default function InlineVariantInsideSentenceLikeUi() {
  return (
    <div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-2)', flexWrap: 'wrap', width: '100%' }}>
      <Text size={3}>Email me a digest</Text>
      <Select variant='inline' defaultValue='weekly'>
        <SelectHiddenSelect name='digest' />
        <SelectCombobox aria-label='Summary frequency'>
          <SelectValue />
          <SelectIcon />
        </SelectCombobox>
        <SelectListbox>
          <SelectOption value='daily'>every day</SelectOption>
          <SelectOption value='weekly'>every Monday</SelectOption>
          <SelectOption value='monthly'>on the 1st of the month</SelectOption>
          <SelectOption value='never'>never</SelectOption>
        </SelectListbox>
      </Select>
    </div>
  );
}
```

#### Layout/Overlay: select inside dialog

The list opens in the top layer above the modal dialog, so no portal is needed. The form inside the dialog reads the value with `FormData` and closes the dialog on submit.

```tsx
import type { CSSProperties, FormEvent } from 'react';
import {
  Button,
  Dialog,
  DialogClose,
  DialogContent,
  DialogTrigger,
  Heading,
  Icon,
  IconButton,
  Label,
  Select,
  SelectCombobox,
  SelectHiddenSelect,
  SelectIcon,
  SelectListbox,
  SelectOption,
  SelectValue,
  Text,
} from '@vega-ui/react';
import { XIcon } from '@vega-ui/icons';
import { useId, useState } from 'react';

export default function SelectInsideDialog() {
  const id = useId();
  const [open, setOpen] = useState(false);
  const [exported, setExported] = useState('');

  const onSubmit = (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    const data = new FormData(event.currentTarget);
    setExported(`Exporting the Q3 report as ${data.get('format')}.`);
    setOpen(false);
  };

  return (
    <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 'var(--spacing-8)', width: '100%' }}>
      <Dialog open={open} onChangeOpen={setOpen}>
        <DialogTrigger asChild>
          <Button>Export report</Button>
        </DialogTrigger>
        <DialogContent aria-labelledby={`${id}-title`}>
          <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--spacing-8)' }}>
            <Heading id={`${id}-title`} as='h2' size={4} fontWeight={600}>Export Q3 report</Heading>
            <DialogClose asChild>
              <IconButton
                aria-label='Close'
                variant='secondary'
                appearance='ghost'
                style={{ flexShrink: 0, '--icon-button-size': '24px', '--icon-button-br-ratio': 0.5 } as CSSProperties}
              >
                <Icon size='xs'><XIcon strokeWidth={2.5} /></Icon>
              </IconButton>
            </DialogClose>
          </div>
          <form
            onSubmit={onSubmit}
            style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-3)', marginBlockStart: 'var(--spacing-12)' }}
          >
            <Label htmlFor={id}>File format</Label>
            <Select defaultValue='xlsx'>
              <SelectHiddenSelect name='format' />
              <SelectCombobox id={id}>
                <SelectValue />
                <SelectIcon />
              </SelectCombobox>
              <SelectListbox>
                <SelectOption value='xlsx'>Excel workbook (.xlsx)</SelectOption>
                <SelectOption value='csv'>Comma-separated values (.csv)</SelectOption>
                <SelectOption value='pdf'>PDF document (.pdf)</SelectOption>
              </SelectListbox>
            </Select>
            <Button type='submit' style={{ alignSelf: 'flex-end', marginBlockStart: 'var(--spacing-9)' }}>Export</Button>
          </form>
        </DialogContent>
      </Dialog>
      {exported && <Text size={2} role='status' style={{ color: 'var(--label-secondary)' }}>{exported}</Text>}
    </div>
  );
}
```

#### Layout/Overlay: one select per table row

Each row has its own select that updates one member in the parent state. Every trigger is named after its row, so a screen reader announces "Role of Mei Tanaka" instead of a list of identical "Role" fields.

```tsx
import {
  Select,
  SelectCombobox,
  SelectIcon,
  SelectListbox,
  SelectOption,
  SelectValue,
  Table,
  TableBody,
  TableData,
  TableHead,
  TableHeading,
  TableRow,
  Text,
} from '@vega-ui/react';
import { useState } from 'react';

const initialMembers = [
  { id: 'u1', name: 'Amara Okafor', email: 'amara@northwind.io', role: 'owner' },
  { id: 'u2', name: 'Lucas Moreau', email: 'lucas@northwind.io', role: 'editor' },
  { id: 'u3', name: 'Mei Tanaka', email: 'mei@northwind.io', role: 'viewer' },
];

export default function SelectInTableRows() {
  const [members, setMembers] = useState(initialMembers);

  const changeRole = (id: string, role: string | undefined) => {
    if (role === undefined) return;
    setMembers((current) => current.map((member) => (member.id === id ? { ...member, role } : member)));
  };

  return (
    <Table fullWidth dataAlign='start'>
      <TableHead>
        <TableRow>
          <TableHeading>Member</TableHeading>
          <TableHeading>Role</TableHeading>
        </TableRow>
      </TableHead>
      <TableBody>
        {members.map((member) => (
          <TableRow key={member.id}>
            <TableData>
              <div style={{ display: 'flex', flexDirection: 'column' }}>
                <Text size={3}>{member.name}</Text>
                <Text size={2} style={{ color: 'var(--label-secondary)' }}>{member.email}</Text>
              </div>
            </TableData>
            <TableData>
              <Select
                size='sm'
                style={{ minWidth: 140 }}
                value={member.role}
                onChangeValue={(role) => changeRole(member.id, role)}
              >
                <SelectCombobox aria-label={`Role of ${member.name}`}>
                  <SelectValue />
                  <SelectIcon />
                </SelectCombobox>
                <SelectListbox>
                  <SelectOption value='owner'>Owner</SelectOption>
                  <SelectOption value='editor'>Editor</SelectOption>
                  <SelectOption value='viewer'>Viewer</SelectOption>
                </SelectListbox>
              </Select>
            </TableData>
          </TableRow>
        ))}
      </TableBody>
    </Table>
  );
}
```

### Error

#### Error: required choice with a linked message

The form checks the value on submit, marks the trigger with `aria-invalid`, links the message through `aria-describedby` and moves focus back to the field. Picking any option clears the error.

```tsx
import {
  Button,
  HelperText,
  Label,
  Select,
  SelectCombobox,
  SelectHiddenSelect,
  SelectIcon,
  SelectListbox,
  SelectOption,
  SelectValue,
  Text,
} from '@vega-ui/react';
import { FormEvent, useId, useRef, useState } from 'react';

export default function SelectRequiredError() {
  const id = useId();
  const errorId = `${id}-error`;
  const comboboxRef = useRef<HTMLButtonElement>(null);
  const [reason, setReason] = useState<string | null>(null);
  const [invalid, setInvalid] = useState(false);
  const [sent, setSent] = useState(false);

  const onChangeValue = (next: string | undefined) => {
    setReason(next ?? null);
    setInvalid(false);
  };

  const onSubmit = (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();

    if (reason === null) {
      setInvalid(true);
      comboboxRef.current?.focus();
      return;
    }

    setSent(true);
  };

  return (
    <form
      onSubmit={onSubmit}
      style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-3)', width: '100%', maxWidth: 320 }}
    >
      <Label htmlFor={id}>Why are you cancelling?</Label>
      <Select value={reason} onChangeValue={onChangeValue}>
        <SelectHiddenSelect name='reason' />
        <SelectCombobox
          id={id}
          ref={comboboxRef}
          aria-invalid={invalid}
          aria-describedby={invalid ? errorId : undefined}
        >
          <SelectValue placeholder='Choose a reason' />
          <SelectIcon />
        </SelectCombobox>
        <SelectListbox>
          <SelectOption value='price'>It is too expensive</SelectOption>
          <SelectOption value='features'>Missing features I need</SelectOption>
          <SelectOption value='switching'>Switching to another tool</SelectOption>
          <SelectOption value='other'>Something else</SelectOption>
        </SelectListbox>
      </Select>
      {invalid && (
        <HelperText id={errorId} error>
          Choose a reason so we can route your feedback.
        </HelperText>
      )}
      <Button type='submit' style={{ alignSelf: 'flex-end', marginBlockStart: 'var(--spacing-9)' }}>
        Cancel subscription
      </Button>
      {sent && (
        <Text size={2} role='status' style={{ color: 'var(--label-secondary)', marginBlockStart: 'var(--spacing-4)' }}>
          Your subscription ends on the last day of the billing period.
        </Text>
      )}
    </form>
  );
}
```

### Disabled

#### Disabled: inherited from a fieldset

The trigger is a native button, so a disabled `Fieldset` disables it and its hidden select together: the field cannot be opened and is left out of `FormData`. The switch toggles that state.

```tsx
import {
  Fieldset,
  Label,
  Select,
  SelectCombobox,
  SelectHiddenSelect,
  SelectIcon,
  SelectListbox,
  SelectOption,
  SelectValue,
  Switch,
  SwitchIndicator,
  SwitchInput,
} from '@vega-ui/react';
import { useId, useState } from 'react';

export default function FullyDisabledField() {
  const switchId = useId();
  const selectId = useId();
  const [useDefault, setUseDefault] = useState(true);

  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-8)', width: '100%', maxWidth: 320 }}>
      <div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-6)' }}>
        <Switch>
          <SwitchInput
            id={switchId}
            checked={useDefault}
            onChange={(event) => setUseDefault(event.currentTarget.checked)}
          />
          <SwitchIndicator />
        </Switch>
        <Label htmlFor={switchId}>Use workspace calendar settings</Label>
      </div>
      <Fieldset
        disabled={useDefault}
        style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-3)' }}
      >
        <Label htmlFor={selectId}>Week starts on</Label>
        <Select defaultValue='monday'>
          <SelectHiddenSelect name='weekStart' />
          <SelectCombobox id={selectId}>
            <SelectValue />
            <SelectIcon />
          </SelectCombobox>
          <SelectListbox>
            <SelectOption value='saturday'>Saturday</SelectOption>
            <SelectOption value='sunday'>Sunday</SelectOption>
            <SelectOption value='monday'>Monday</SelectOption>
          </SelectListbox>
        </Select>
      </Fieldset>
    </div>
  );
}
```

#### Disabled: read-only value with an explanation

`readOnly` keeps the trigger focusable and the value submitted, but the list does not open. The reason is linked to the trigger with `aria-describedby`.

```tsx
import {
  HelperText,
  Label,
  Select,
  SelectCombobox,
  SelectHiddenSelect,
  SelectIcon,
  SelectListbox,
  SelectOption,
  SelectValue,
} from '@vega-ui/react';
import { useId } from 'react';

export default function ReadOnlyField() {
  const id = useId();
  const hintId = `${id}-hint`;

  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-3)', width: '100%', maxWidth: 320 }}>
      <Label htmlFor={id}>Billing currency</Label>
      <Select readOnly defaultValue='eur'>
        <SelectHiddenSelect name='currency' />
        <SelectCombobox id={id} aria-describedby={hintId}>
          <SelectValue />
          <SelectIcon />
        </SelectCombobox>
        <SelectListbox>
          <SelectOption value='usd'>US dollar (USD)</SelectOption>
          <SelectOption value='eur'>Euro (EUR)</SelectOption>
          <SelectOption value='gbp'>Pound sterling (GBP)</SelectOption>
        </SelectListbox>
      </Select>
      <HelperText id={hintId}>
        Locked after the first invoice. Contact support to change it.
      </HelperText>
    </div>
  );
}
```

### Edge

#### Edge: hundreds of options

288 time slots in five-minute steps. The list scrolls by itself, opens on the selected option, and typeahead jumps to a slot: type "14" to reach 14:00.

```tsx
import {
  Label,
  Select,
  SelectCombobox,
  SelectHiddenSelect,
  SelectIcon,
  SelectListbox,
  SelectOption,
  SelectValue,
} from '@vega-ui/react';
import { useId } from 'react';

const slots = Array.from({ length: 24 * 12 }, (_, index) => {
  const hours = String(Math.floor(index / 12)).padStart(2, '0');
  const minutes = String((index % 12) * 5).padStart(2, '0');
  return `${hours}:${minutes}`;
});

export default function LongScrollableOptionList() {
  const id = useId();

  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-3)', width: '100%', maxWidth: 200 }}>
      <Label htmlFor={id}>Send the reminder at</Label>
      <Select defaultValue='09:00'>
        <SelectHiddenSelect name='reminderTime' />
        <SelectCombobox id={id}>
          <SelectValue />
          <SelectIcon />
        </SelectCombobox>
        <SelectListbox>
          {slots.map((slot) => (
            <SelectOption key={slot} value={slot}>
              {slot}
            </SelectOption>
          ))}
        </SelectListbox>
      </Select>
    </div>
  );
}
```

#### Edge: short value text for long options

Without children `SelectValue` copies the selected option on the client. Children replace that copy, so long options can keep a short trigger, and they also render on the server; the placeholder still shows while nothing is selected.

```tsx
import {
  Select,
  SelectCombobox,
  SelectIcon,
  SelectListbox,
  SelectOption,
  SelectValue,
} from '@vega-ui/react';
import { useState } from 'react';

export default function CustomValueText() {
  const [currency, setCurrency] = useState<string | null>('EUR');

  return (
    <Select style={{ width: 120 }} value={currency} onChangeValue={(next) => setCurrency(next ?? null)}>
      <SelectCombobox aria-label='Currency'>
        <SelectValue placeholder='Currency'>{currency}</SelectValue>
        <SelectIcon />
      </SelectCombobox>
      <SelectListbox>
        <SelectOption value='USD'>United States dollar (USD)</SelectOption>
        <SelectOption value='EUR'>Euro (EUR)</SelectOption>
        <SelectOption value='JPY'>Japanese yen (JPY)</SelectOption>
        <SelectOption value='CHF'>Swiss franc (CHF)</SelectOption>
      </SelectListbox>
    </Select>
  );
}
```

#### Edge: options loaded when the list opens

The projects are fetched on the first open, with `aria-busy` and a spinner while they load. The saved selection is not among the rendered options yet, so its name is passed to `SelectValue` as children.

```tsx
import {
  Label,
  Select,
  SelectCombobox,
  SelectIcon,
  SelectListbox,
  SelectOption,
  SelectValue,
  Spinner,
  Text,
} from '@vega-ui/react';
import { useId, useState } from 'react';

interface Project {
  id: string;
  name: string;
}

const fetchProjects = () => new Promise<Project[]>((resolve) => {
  setTimeout(() => resolve([
    { id: 'apollo', name: 'Apollo website' },
    { id: 'borealis', name: 'Borealis mobile app' },
    { id: 'cobalt', name: 'Cobalt billing API' },
    { id: 'delta', name: 'Delta data warehouse' },
  ]), 1200);
});

export default function AsyncOptions() {
  const id = useId();
  const [projects, setProjects] = useState<Project[] | null>(null);
  const [loading, setLoading] = useState(false);
  const [project, setProject] = useState<Project | null>({ id: 'apollo', name: 'Apollo website' });

  const onChangeOpen = (open: boolean) => {
    if (!open || projects !== null || loading) return;

    setLoading(true);
    fetchProjects().then((result) => {
      setProjects(result);
      setLoading(false);
    });
  };

  const onChangeValue = (next: string | undefined) => {
    const found = projects?.find((item) => item.id === next);
    setProject(found ?? null);
  };

  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-3)', width: '100%', maxWidth: 280 }}>
      <Label htmlFor={id}>Move the task to</Label>
      <Select value={project?.id ?? null} onChangeValue={onChangeValue} onChangeOpen={onChangeOpen}>
        <SelectCombobox id={id}>
          <SelectValue placeholder='Choose a project'>{project?.name}</SelectValue>
          <SelectIcon />
        </SelectCombobox>
        <SelectListbox aria-busy={loading}>
          {loading && (
            <div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-6)', padding: 'var(--spacing-6)' }}>
              <Spinner size={2} aria-label='Loading projects' />
              <Text size={2}>Loading projects…</Text>
            </div>
          )}
          {projects?.map((item) => (
            <SelectOption key={item.id} value={item.id}>
              {item.name}
            </SelectOption>
          ))}
        </SelectListbox>
      </Select>
    </div>
  );
}
```

## Anatomy

### Overview

`Select` is a compound component. The root owns open state, selection state, option registration, keyboard navigation, and typeahead behavior. The exported child parts consume that shared context.

### Required Parts

#### `Select`

Root provider for the whole composition.

- Required: yes
- Owns: selected value, open state, active option, keyboard interactions
- Must wrap every other `Select*` part

#### `SelectCombobox`

Visible trigger surface that opens and closes the listbox.

- Required: yes
- Typical children: `SelectValue`, `SelectIcon`
- Should remain inside `Select`

#### `SelectValue`

Current value renderer or placeholder renderer.

- Required: effectively yes in most UI
- Accepts custom children when the selected value needs richer rendering
- Should stay inside `SelectCombobox`

#### `SelectIcon`

Optional visual indicator for the trigger.

- Required: no
- Usually placed after `SelectValue`

#### `SelectListbox`

Container for selectable options.

- Required: yes
- Should contain one or more `SelectOption` children
- Renders in the top layer via `popover="manual"`, so no portal part is needed
- Handles focus itself: arrow keys move focus through the options, and focus returns to `SelectCombobox` when the list closes

#### `SelectOption`

Selectable item registered into the root option model.

- Required: yes
- Must be rendered inside `SelectListbox`
- Value type must match `string | number`

### Optional Parts

#### `SelectHiddenSelect`

Hidden native select for form submission and browser form semantics.

- Required: no
- Recommended: yes for native forms
- Omit only when the component is used outside normal form submission

#### `SelectIcon`

Optional visual indicator for the trigger.

- Required: no
- Usually placed after `SelectValue`

### Composition Order

Typical composition:

1. `Select`
2. `SelectHiddenSelect`
3. `SelectCombobox`
4. `SelectListbox`
5. `SelectOption`

### Valid Composition Patterns

```tsx
import {
  Label,
  Select,
  SelectCombobox,
  SelectHiddenSelect,
  SelectIcon,
  SelectListbox,
  SelectOption,
  SelectValue,
} from '@vega-ui/react';
import { useId } from 'react';

export default function MinimalComposition() {
  const id = useId();

  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-3)', width: '100%', maxWidth: 280 }}>
      <Label htmlFor={id}>Interface language</Label>
      <Select defaultValue='en'>
        <SelectHiddenSelect name='language' />
        <SelectCombobox id={id}>
          <SelectValue placeholder='Choose a language' />
          <SelectIcon />
        </SelectCombobox>
        <SelectListbox>
          <SelectOption value='en'>English</SelectOption>
          <SelectOption value='de'>Deutsch</SelectOption>
          <SelectOption value='es'>Español</SelectOption>
          <SelectOption value='ja'>日本語</SelectOption>
        </SelectListbox>
      </Select>
    </div>
  );
}
```

Every part in its place: the `Label` names the trigger through its `id`, and `SelectHiddenSelect` with a `name` submits the value with the form. Leave out only the hidden select when the value never goes through a form.

### Invalid Composition Patterns

#### `SelectOption` outside `SelectListbox`

This breaks option registration and keyboard navigation.

#### `SelectValue` outside `SelectCombobox`

This breaks the trigger composition contract and makes the selected value placement ambiguous.

#### Child parts outside `Select`

The public parts rely on root context. Rendering them outside `Select` breaks behavior.

#### Search input without disabling typeahead

If a text input is rendered inside `SelectListbox`, built-in typeahead should usually be disabled with `typeahead={false}`.
