Перейти к содержимому
v2.5.0
English
Русский
Документация

Listbox

Listbox — всегда видимый список, в котором выбирают один или несколько вариантов, как в нативном select с size.

Паттерны

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

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

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

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

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

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

  • фильтр и список — две остановки табуляции, поэтому стрелки работают только после перехода в список по Tab
  • списку нужен maxHeight, иначе он растёт с каждым вариантом, который пропускает фильтр
filtered-options.tsx · 47 строк
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']);

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

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

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

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

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

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

  • форма читает то же состояние, что показывает список, поэтому они не расходятся
  • каждому списку в форме нужна такая обвязка; для компактного поля, которое отправляется само, используйте Select с SelectHiddenSelect
listbox-in-form.tsx · 49 строк
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();

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

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

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

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

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

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

  • сообщение озвучивается вместе с именем списка, когда пользователь к нему возвращается
  • собственного стиля ошибки у списка нет, поэтому об ошибке сообщает текст
explain-an-empty-required-choice.tsx · 51 строка
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);

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

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

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

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

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

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

  • заголовки облегчают просмотр длинных списков
  • заголовки — не варианты, поэтому стрелки их пропускают, а typeahead их не находит
groups-and-a-scrolling-list.tsx · 57 строк
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' },
    ],

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

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

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

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

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

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

  • пользователи видят варианты, которые получат, даже пока не могут их выбрать
  • отключённый список выпадает из порядка табуляции, поэтому его нельзя сфокусировать, чтобы прочитать варианты с клавиатуры
disabled-list.tsx · 28 строк
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

Сравнение

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

Выбирайте 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
  • значение должно отправляться нативно без дополнительной обвязки — используйте Radio или Checkbox
  • варианту нужны собственные кнопки или ссылки внутри
  • список содержит действия, а не значения

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

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

Симптом

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

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

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

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

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

Решение

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

listbox-in-form.tsx · 49 строк
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();

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

Симптом

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

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

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

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

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

Решение

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

disabled-list.tsx · 28 строк
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

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

Симптом

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

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

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

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

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

Решение

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

rich-content-with-a-label-for-typeahead.tsx · 33 строки
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)' }}>

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

Симптом

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

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

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

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

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

Решение

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

value-with-a-reset.tsx · 31 строка
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);

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

Симптом

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

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

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

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

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

Решение

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

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

Симптом

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

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

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

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

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

Решение

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

many-options.tsx · 32 строки
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);