# Listbox: Руководства

## Паттерны

### Список с фильтром

Когда использовать:

- список длинный, но варианты должны оставаться видимыми, пока пользователь сужает выбор
- из одного набора выбирают несколько значений, например метки или теги

Заметки о композиции:

- рендерите `TextField` над списком и свяжите его со списком через `aria-controls`
- фильтруйте отрендеренные `ListboxOption` по своему состоянию: список читает варианты из DOM, поэтому регистрировать ничего не нужно
- храните выбор отдельно от фильтра, чтобы скрытые запросом варианты оставались выбранными
- когда ничего не найдено, скройте список и покажите сообщение с `role='status'` вместо пустого блока

Компромиссы:

- фильтр и список — две остановки табуляции, поэтому стрелки работают только после перехода в список по Tab
- списку нужен `maxHeight`, иначе он растёт с каждым вариантом, который пропускает фильтр

```tsx
import { Listbox, ListboxOption, Text, TextField, TextFieldInput } from '@vega-ui/react';
import { useId, useState } from 'react';

const labels = [
  'bug', 'feature', 'documentation', 'design', 'performance', 'security',
  'accessibility', 'good first issue', 'help wanted', 'needs triage', 'wontfix', 'duplicate',
];

export default function FilteredOptions() {
  const id = useId();
  const [query, setQuery] = useState('');
  const [selected, setSelected] = useState<string[]>(['bug']);

  const matches = labels.filter((label) => label.includes(query.trim().toLowerCase()));

  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-4)', width: '100%', maxWidth: 300 }}>
      <TextField size='sm'>
        <TextFieldInput
          aria-label='Filter labels'
          aria-controls={id}
          placeholder='Filter labels'
          value={query}
          onChange={(event) => setQuery(event.currentTarget.value)}
        />
      </TextField>
      <Listbox
        id={id}
        aria-label='Labels'
        multiple
        value={selected}
        onChangeValue={setSelected}
        style={{ display: matches.length === 0 ? 'none' : undefined, maxHeight: 220, border: '1px solid var(--separator-opaque)' }}
      >
        {matches.map((label) => (
          <ListboxOption key={label} value={label}>
            {label}
          </ListboxOption>
        ))}
      </Listbox>
      {matches.length === 0 && (
        <Text size={2} role='status' style={{ color: 'var(--label-secondary)' }}>No labels match “{query}”.</Text>
      )}
      <Text size={2} style={{ color: 'var(--label-secondary)' }}>Selected: {selected.join(', ') || 'none'}</Text>
    </div>
  );
}
```

### Выбор, отправляемый с формой

Когда использовать:

- всегда видимый список — часть формы, которая отправляет данные
- у формы есть кнопка сброса, которая должна вернуть исходный выбор

Заметки о композиции:

- храните значение в состоянии через `value` и `onChangeValue`
- рендерите по одному `<input type='hidden'>` на каждое выбранное значение с `name` поля
- восстанавливайте исходное состояние в `onReset` формы, потому что список не слушает форму

Компромиссы:

- форма читает то же состояние, что показывает список, поэтому они не расходятся
- каждому списку в форме нужна такая обвязка; для компактного поля, которое отправляется само, используйте [Select](https://vega-ui.dev/ru/components/select/) с `SelectHiddenSelect`

```tsx
import { Button, Listbox, ListboxOption, Text } from '@vega-ui/react';
import { FormEvent, useId, useState } from 'react';

const initialChannels = ['email'];

export default function ListboxInForm() {
  const labelId = useId();
  const [channels, setChannels] = useState<string[]>(initialChannels);
  const [submitted, setSubmitted] = useState('');

  const onSubmit = (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    const data = new FormData(event.currentTarget);
    setSubmitted(data.getAll('channel').join(', ') || 'nothing');
  };

  const onReset = () => {
    setChannels(initialChannels);
    setSubmitted('');
  };

  return (
    <form
      onSubmit={onSubmit}
      onReset={onReset}
      style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-3)', width: '100%', maxWidth: 320 }}
    >
      <Text id={labelId} size={3} fontWeight={500}>Notify me about new orders by</Text>
      <Listbox aria-labelledby={labelId} multiple value={channels} onChangeValue={setChannels} style={{ border: '1px solid var(--separator-opaque)' }}>
        <ListboxOption value='email'>Email</ListboxOption>
        <ListboxOption value='sms'>SMS</ListboxOption>
        <ListboxOption value='push'>Push notification</ListboxOption>
        <ListboxOption value='slack'>Slack</ListboxOption>
      </Listbox>
      {channels.map((channel) => (
        <input key={channel} type='hidden' name='channel' value={channel} />
      ))}
      <div style={{ display: 'flex', justifyContent: 'flex-end', gap: 'var(--spacing-4)', marginBlockStart: 'var(--spacing-9)' }}>
        <Button type='reset' variant='secondary' appearance='ghost'>Reset</Button>
        <Button type='submit'>Save</Button>
      </div>
      {submitted && (
        <Text size={2} role='status' style={{ color: 'var(--label-secondary)', marginBlockStart: 'var(--spacing-4)' }}>
          Saved channels: {submitted}
        </Text>
      )}
    </form>
  );
}
```

### Обязательный выбор

Когда использовать:

- форма не должна продолжаться, пока не выбран один вариант
- список изначально пуст

Заметки о композиции:

- начинайте с `value={null}` и проверяйте его в `onSubmit` формы
- ставьте списку `aria-invalid` и связывайте сообщение через `aria-describedby` только пока ошибка видна
- снимайте ошибку в `onChangeValue`, как только пользователь выбрал

Компромиссы:

- сообщение озвучивается вместе с именем списка, когда пользователь к нему возвращается
- собственного стиля ошибки у списка нет, поэтому об ошибке сообщает текст

```tsx
import { Button, HelperText, Listbox, ListboxOption, Text } from '@vega-ui/react';
import { FormEvent, useId, useState } from 'react';

export default function ExplainAnEmptyRequiredChoice() {
  const id = useId();
  const errorId = `${id}-error`;
  const [plan, setPlan] = useState<string | null>(null);
  const [invalid, setInvalid] = useState(false);
  const [done, setDone] = useState(false);

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

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

    if (plan === null) {
      setInvalid(true);
      return;
    }

    setDone(true);
  };

  return (
    <form onSubmit={onSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-3)', width: '100%', maxWidth: 320 }}>
      <Text id={`${id}-label`} size={3} fontWeight={500}>Workspace plan</Text>
      <Listbox
        aria-labelledby={`${id}-label`}
        aria-invalid={invalid}
        aria-describedby={invalid ? errorId : undefined}
        value={plan}
        onChangeValue={onChangeValue}
        style={{ border: '1px solid var(--separator-opaque)' }}
      >
        <ListboxOption value='free'>Free, up to 3 members</ListboxOption>
        <ListboxOption value='team'>Team, $12 per member</ListboxOption>
        <ListboxOption value='business'>Business, $24 per member</ListboxOption>
      </Listbox>
      {invalid && <HelperText id={errorId} error>Choose a plan to create the workspace.</HelperText>}
      <Button type='submit' style={{ alignSelf: 'flex-end', marginBlockStart: 'var(--spacing-9)' }}>Create workspace</Button>
      {done && (
        <Text size={2} role='status' style={{ color: 'var(--label-secondary)', marginBlockStart: 'var(--spacing-4)' }}>
          Workspace created on the {plan} plan.
        </Text>
      )}
    </form>
  );
}
```

### Прокручиваемый список с группами

Когда использовать:

- варианты делятся на категории, по которым пользователь их просматривает, например страны или команды
- список слишком длинный, чтобы показать его целиком

Заметки о композиции:

- оберните каждую категорию в `ListboxGroup` и назовите её через `aria-labelledby`, указывающий на её заголовок
- задайте списку `maxHeight`: он сам прокручивается и держит активный вариант в поле зрения
- передавайте `label` вариантам с дополнительной разметкой, чтобы typeahead сверял название

Компромиссы:

- заголовки облегчают просмотр длинных списков
- заголовки — не варианты, поэтому стрелки их пропускают, а typeahead их не находит

```tsx
import { Listbox, ListboxGroup, ListboxOption, Text } from '@vega-ui/react';
import { useId } from 'react';

const countries = [
  {
    name: 'France',
    airports: [
      { code: 'CDG', name: 'Paris Charles de Gaulle' },
      { code: 'ORY', name: 'Paris Orly' },
      { code: 'NCE', name: 'Nice Côte d’Azur' },
      { code: 'LYS', name: 'Lyon Saint-Exupéry' },
    ],
  },
  {
    name: 'Germany',
    airports: [
      { code: 'FRA', name: 'Frankfurt' },
      { code: 'MUC', name: 'Munich' },
      { code: 'BER', name: 'Berlin Brandenburg' },
    ],
  },
  {
    name: 'Japan',
    airports: [
      { code: 'HND', name: 'Tokyo Haneda' },
      { code: 'NRT', name: 'Tokyo Narita' },
      { code: 'KIX', name: 'Osaka Kansai' },
    ],
  },
];

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

  return (
    <Listbox aria-label='Departure airport' defaultValue='MUC' style={{ width: '100%', maxWidth: 320, maxHeight: 260, border: '1px solid var(--separator-opaque)' }}>
      {countries.map((country) => (
        <ListboxGroup key={country.name} aria-labelledby={`${id}-${country.name}`}>
          <Text
            id={`${id}-${country.name}`}
            size={2}
            fontWeight={600}
            style={{ paddingInline: 'var(--spacing-10)', paddingBlockStart: 'var(--spacing-4)', color: 'var(--label-secondary)' }}
          >
            {country.name}
          </Text>
          {country.airports.map((airport) => (
            <ListboxOption key={airport.code} value={airport.code} label={airport.name} style={{ gap: 'var(--spacing-8)' }}>
              {airport.name}
              <span style={{ marginInlineStart: 'auto', opacity: 0.7 }}>{airport.code}</span>
            </ListboxOption>
          ))}
        </ListboxGroup>
      ))}
    </Listbox>
  );
}
```

### Список, зависящий от другой настройки

Когда использовать:

- выбор имеет смысл, только когда его разрешает другая настройка, например ручная замена автоматического значения

Заметки о композиции:

- передавайте `disabled` из состояния другого элемента управления
- сохраняйте значение, пока список отключён, чтобы при выключении настройки вернулся прежний выбор

Компромиссы:

- пользователи видят варианты, которые получат, даже пока не могут их выбрать
- отключённый список выпадает из порядка табуляции, поэтому его нельзя сфокусировать, чтобы прочитать варианты с клавиатуры

```tsx
import { Label, Listbox, ListboxOption, Switch, SwitchIndicator, SwitchInput } from '@vega-ui/react';
import { useId, useState } from 'react';

export default function DisabledList() {
  const switchId = useId();
  const [automatic, setAutomatic] = 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={automatic}
            onChange={(event) => setAutomatic(event.currentTarget.checked)}
          />
          <SwitchIndicator />
        </Switch>
        <Label htmlFor={switchId}>Pick the region automatically</Label>
      </div>
      <Listbox aria-label='Server region' disabled={automatic} defaultValue='eu-central' style={{ border: '1px solid var(--separator-opaque)' }}>
        <ListboxOption value='us-east'>US East (Virginia)</ListboxOption>
        <ListboxOption value='eu-central'>EU Central (Frankfurt)</ListboxOption>
        <ListboxOption value='ap-northeast'>Asia Pacific (Tokyo)</ListboxOption>
      </Listbox>
    </div>
  );
}
```

## Сравнение

### Быстрое правило выбора

Выбирайте `Listbox`, когда варианты должны оставаться видимыми одним списком, пользователь выбирает один или несколько из них, а список может быть достаточно длинным, чтобы прокручиваться. Выбирайте компактное поле или нативные элементы, когда места мало или значение должно отправляться с формой само.

### `Listbox` или `Select`

Используйте `Listbox`, когда:

- варианты должны быть видны, ничего не открывая
- пользователь выбирает несколько значений, с `multiple`
- список — основное содержимое панели, например колонки настроек или пикера

Используйте `Select`, когда:

- поле стоит в плотной форме и должно занимать одну строку
- выбор одиночный, и варианты можно скрыть до нужного момента
- значение должно отправляться с формой через `SelectHiddenSelect`

Компромисс:

- `Listbox` показывает все варианты, но занимает место по вертикали
- `Select` экономит место, но прячет варианты за кликом

### `Listbox` или `Radio`

Используйте `Listbox`, когда:

- вариантов много, и список нужно прокручивать
- пользователю помогают стрелки, `Home`, `End` и typeahead по всем вариантам

Используйте `Radio`, когда:

- вариантов немного, и у каждого есть место для описания
- выбор должен нативно отправляться с формой под одним `name`

Компромисс:

- `Listbox` — одна остановка табуляции с навигацией по списку и без нативного значения формы
- `Radio` нативный и готов к формам, но длинные группы трудно просматривать

### `Listbox` или `Checkbox`

Используйте `Listbox` с `multiple`, когда:

- варианты — значения одного поля, например метки или каналы
- набор длинный, и ему помогают typeahead и прокручиваемая область

Используйте `Checkbox`, когда:

- каждый вариант — независимая настройка «да или нет»
- каждый вариант должен отправляться нативно или нужен промежуточный «выбрать всё»

Компромисс:

- `Listbox` считает выбор одним значением-массивом
- `Checkbox` делает каждый вариант отдельным элементом управления

### `Listbox` или `SegmentedControl`

Используйте `Listbox`, когда:

- вариантов много или их подписи длинные
- список расположен вертикально

Используйте `SegmentedControl`, когда:

- вариантов от двух до пяти, короткие и в одну строку
- переключение должно ощущаться мгновенным, как смена вида

### Выбирайте этот компонент, когда

- варианты должны быть видны всегда
- из одного набора выбирают одно или несколько значений
- список может быть длинным, и ему нужны навигация с клавиатуры и typeahead
- значение живёт в состоянии, а обвязку с формой вы делаете сами

### Не выбирайте этот компонент, когда

- поле в форме должно оставаться компактным — используйте [Select](https://vega-ui.dev/ru/components/select/)
- значение должно отправляться нативно без дополнительной обвязки — используйте [Radio](https://vega-ui.dev/ru/components/radio/) или [Checkbox](https://vega-ui.dev/ru/components/checkbox/)
- варианту нужны собственные кнопки или ссылки внутри
- список содержит действия, а не значения

## Решение проблем

### Выбора нет в отправленной форме

#### Симптом

Список показывает выбранные варианты, но в данных формы нет поля для них.

#### Вероятная причина

`Listbox` — не элемент формы. Он рендерит `div` с `role='listbox'`, у которого нет `name` и который не участвует в отправке формы.

#### Как проверить

- отправьте форму и изучите `new FormData(form)`
- поищите внутри формы элемент с ожидаемым `name`

#### Решение

Храните значение в состоянии и рендерите по одному `<input type='hidden'>` на каждое выбранное значение. Восстанавливайте исходное состояние в `onReset` формы.

```tsx
import { Button, Listbox, ListboxOption, Text } from '@vega-ui/react';
import { FormEvent, useId, useState } from 'react';

const initialChannels = ['email'];

export default function ListboxInForm() {
  const labelId = useId();
  const [channels, setChannels] = useState<string[]>(initialChannels);
  const [submitted, setSubmitted] = useState('');

  const onSubmit = (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    const data = new FormData(event.currentTarget);
    setSubmitted(data.getAll('channel').join(', ') || 'nothing');
  };

  const onReset = () => {
    setChannels(initialChannels);
    setSubmitted('');
  };

  return (
    <form
      onSubmit={onSubmit}
      onReset={onReset}
      style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-3)', width: '100%', maxWidth: 320 }}
    >
      <Text id={labelId} size={3} fontWeight={500}>Notify me about new orders by</Text>
      <Listbox aria-labelledby={labelId} multiple value={channels} onChangeValue={setChannels} style={{ border: '1px solid var(--separator-opaque)' }}>
        <ListboxOption value='email'>Email</ListboxOption>
        <ListboxOption value='sms'>SMS</ListboxOption>
        <ListboxOption value='push'>Push notification</ListboxOption>
        <ListboxOption value='slack'>Slack</ListboxOption>
      </Listbox>
      {channels.map((channel) => (
        <input key={channel} type='hidden' name='channel' value={channel} />
      ))}
      <div style={{ display: 'flex', justifyContent: 'flex-end', gap: 'var(--spacing-4)', marginBlockStart: 'var(--spacing-9)' }}>
        <Button type='reset' variant='secondary' appearance='ghost'>Reset</Button>
        <Button type='submit'>Save</Button>
      </div>
      {submitted && (
        <Text size={2} role='status' style={{ color: 'var(--label-secondary)', marginBlockStart: 'var(--spacing-4)' }}>
          Saved channels: {submitted}
        </Text>
      )}
    </form>
  );
}
```

### Отключённый fieldset не отключает список

#### Симптом

Поля внутри `<fieldset disabled>` заблокированы, а список по-прежнему получает фокус и меняется.

#### Вероятная причина

Браузер отключает внутри fieldset только элементы формы, а список к ним не относится.

#### Как проверить

- пройдите по отключённому fieldset клавишей Tab и проверьте, получает ли список фокус
- изучите список: у него нет `aria-disabled`

#### Решение

Передавайте `disabled` в `Listbox` из того же состояния, которое отключает fieldset. Он отключает все варианты и убирает список из порядка табуляции.

```tsx
import { Label, Listbox, ListboxOption, Switch, SwitchIndicator, SwitchInput } from '@vega-ui/react';
import { useId, useState } from 'react';

export default function DisabledList() {
  const switchId = useId();
  const [automatic, setAutomatic] = 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={automatic}
            onChange={(event) => setAutomatic(event.currentTarget.checked)}
          />
          <SwitchIndicator />
        </Switch>
        <Label htmlFor={switchId}>Pick the region automatically</Label>
      </div>
      <Listbox aria-label='Server region' disabled={automatic} defaultValue='eu-central' style={{ border: '1px solid var(--separator-opaque)' }}>
        <ListboxOption value='us-east'>US East (Virginia)</ListboxOption>
        <ListboxOption value='eu-central'>EU Central (Frankfurt)</ListboxOption>
        <ListboxOption value='ap-northeast'>Asia Pacific (Tokyo)</ListboxOption>
      </Listbox>
    </div>
  );
}
```

### Ввод текста не находит варианты с насыщенным содержимым

#### Симптом

Набор первых букв имени не переводит к варианту, хотя имя видно.

#### Вероятная причина

Typeahead сверяет начало текста варианта. Без `label` этот текст — всё текстовое содержимое варианта, поэтому инициалы аватара или подпись иконки перед именем оказываются первыми.

#### Как проверить

- посмотрите `textContent` варианта в инструментах разработчика
- проверьте, есть ли у варианта атрибут `data-label`

#### Решение

Передавайте простое имя в `label` каждого `ListboxOption`.

```tsx
import { Avatar, AvatarFallback, Listbox, ListboxOption } from '@vega-ui/react';

const people = [
  { id: 'amara', initials: 'AO', name: 'Amara Okafor', email: 'amara.okafor@northwind-logistics.example' },
  { id: 'boris', initials: 'BP', name: 'Boris Petrov', email: 'boris@northwind-logistics.example' },
  { id: 'chloe', initials: 'CL', name: 'Chloé Lefèvre-Durand', email: 'chloe.lefevre-durand.finance@northwind-logistics.example' },
  { id: 'dmitri', initials: 'DV', name: 'Dmitri Volkov', email: 'dmitri.volkov@northwind-logistics.example' },
];

export default function RichContentWithALabelForTypeahead() {
  return (
    <Listbox aria-label='Assignee' style={{ width: '100%', maxWidth: 300, border: '1px solid var(--separator-opaque)' }}>
      {people.map((person) => (
        <ListboxOption
          key={person.id}
          value={person.id}
          label={person.name}
          style={{ height: 'auto', paddingBlock: 'var(--spacing-5)', gap: 'var(--spacing-6)' }}
        >
          <Avatar size='md' aria-hidden='true' style={{ flexShrink: 0 }}>
            <AvatarFallback>{person.initials}</AvatarFallback>
          </Avatar>
          <span style={{ display: 'flex', flexDirection: 'column', flex: 1, minWidth: 0 }}>
            <span>{person.name}</span>
            <span style={{ fontSize: 'var(--font-size-2)', opacity: 0.7, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
              {person.email}
            </span>
          </span>
        </ListboxOption>
      ))}
    </Listbox>
  );
}
```

### Очистка значения не очищает список

#### Симптом

После того как кнопка сброса ставит значение в `undefined`, какой-то вариант остаётся выбранным.

#### Вероятная причина

`undefined` делает список неконтролируемым, и он показывает собственный внутренний выбор вместо пустого. Одиночный список очищается через `null`, список с `multiple` — пустым массивом.

#### Как проверить

- выведите значение, которое передаётся в `value` после сброса
- проверьте, не записывает ли `onChangeValue` `undefined` в ваше состояние

#### Решение

Храните `null` для пустого одиночного выбора и превращайте `undefined` из `onChangeValue` в `null` через `next ?? null`.

```tsx
import { Button, Listbox, ListboxOption, Text } from '@vega-ui/react';
import { useState } from 'react';

const fonts = [
  { value: 'serif', label: 'Serif', family: 'Georgia, serif' },
  { value: 'sans', label: 'Sans-serif', family: 'system-ui, sans-serif' },
  { value: 'mono', label: 'Monospace', family: 'ui-monospace, monospace' },
];

export default function ValueWithAReset() {
  const [font, setFont] = useState<string | null>('serif');
  const current = fonts.find((item) => item.value === font);

  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-8)', width: '100%', maxWidth: 320 }}>
      <Listbox aria-label='Reading font' value={font} onChangeValue={(next) => setFont(next ?? null)} style={{ border: '1px solid var(--separator-opaque)' }}>
        {fonts.map((item) => (
          <ListboxOption key={item.value} value={item.value}>
            {item.label}
          </ListboxOption>
        ))}
      </Listbox>
      <Text size={3} style={{ fontFamily: current?.family, minHeight: 'calc(var(--line-height-3) * 2)' }}>
        The quick brown fox jumps over the lazy dog.
      </Text>
      <Button size='sm' variant='secondary' appearance='ghost' style={{ alignSelf: 'flex-end' }} disabled={font === null} onClick={() => setFont(null)}>
        Use the site default
      </Button>
    </div>
  );
}
```

### Два варианта подсвечиваются и выбираются вместе

#### Симптом

Выбор одного варианта выбирает и другой, а стрелки подсвечивают оба.

#### Вероятная причина

У вариантов одинаковое `value`. `id` варианта строится из значения, а выбор сравнивает значения, поэтому список их не различает.

#### Как проверить

- изучите варианты и поищите повторяющиеся атрибуты `id`
- проверьте данные, из которых рендерятся варианты, на повторяющиеся ключи

#### Решение

Дайте каждому `ListboxOption` уникальное `value`, например id записи, а видимый текст держите отдельно.

### Список выше, чем позволяет макет

#### Симптом

При большом числе вариантов список сдвигает содержимое под ним далеко вниз по странице.

#### Вероятная причина

По умолчанию у списка нет ограничения высоты, и он растёт вместе с вариантами.

#### Как проверить

- изучите список и убедитесь, что у него нет `height` или `max-height`

#### Решение

Задайте списку `maxHeight`. Он сам прокручивается и держит активный вариант в поле зрения, пока пользователь движется стрелками. Меньший `size` помещает больше строк в то же место.

```tsx
import { Listbox, ListboxOption, Text } from '@vega-ui/react';
import { useState } from 'react';

const builds = Array.from({ length: 300 }, (_, index) => {
  const number = 1480 - index;
  const branch = index % 7 === 0 ? 'release/4.2' : 'main';
  return { value: number, label: `Build ${number}`, branch };
});

export default function ManyOptions() {
  const [build, setBuild] = useState<number | null>(1480);

  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-4)', width: '100%', maxWidth: 320 }}>
      <Listbox
        aria-label='Build to deploy'
        value={build}
        onChangeValue={(next) => setBuild(next ?? null)}
        size='sm'
        style={{ maxHeight: 240, border: '1px solid var(--separator-opaque)' }}
      >
        {builds.map((item) => (
          <ListboxOption key={item.value} value={item.value} label={item.label}>
            {item.label}
            <span style={{ marginInlineStart: 'auto', opacity: 0.7 }}>{item.branch}</span>
          </ListboxOption>
        ))}
      </Listbox>
      <Text size={2} style={{ color: 'var(--label-secondary)', fontVariantNumeric: 'tabular-nums' }}>{build === null ? 'No build selected.' : `Build ${build} will be deployed to staging.`}</Text>
    </div>
  );
}
```
