Паттерны
Список с фильтром
Когда использовать:
- список длинный, но варианты должны оставаться видимыми, пока пользователь сужает выбор
- из одного набора выбирают несколько значений, например метки или теги
Заметки о композиции:
- рендерите
TextFieldнад списком и свяжите его со списком черезaria-controls - фильтруйте отрендеренные
ListboxOptionпо своему состоянию: список читает варианты из DOM, поэтому регистрировать ничего не нужно - храните выбор отдельно от фильтра, чтобы скрытые запросом варианты оставались выбранными
- когда ничего не найдено, скройте список и покажите сообщение с
role='status'вместо пустого блока
Компромиссы:
- фильтр и список — две остановки табуляции, поэтому стрелки работают только после перехода в список по Tab
- списку нужен
maxHeight, иначе он растёт с каждым вариантом, который пропускает фильтр
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
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, как только пользователь выбрал
Компромиссы:
- сообщение озвучивается вместе с именем списка, когда пользователь к нему возвращается
- собственного стиля ошибки у списка нет, поэтому об ошибке сообщает текст
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 их не находит
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из состояния другого элемента управления - сохраняйте значение, пока список отключён, чтобы при выключении настройки вернулся прежний выбор
Компромиссы:
- пользователи видят варианты, которые получат, даже пока не могут их выбрать
- отключённый список выпадает из порядка табуляции, поэтому его нельзя сфокусировать, чтобы прочитать варианты с клавиатуры
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 формы.
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. Он отключает все варианты и убирает список из порядка табуляции.
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.
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после сброса - проверьте, не записывает ли
onChangeValueundefinedв ваше состояние
Решение
Храните null для пустого одиночного выбора и превращайте undefined из onChangeValue в null через next ?? null.
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 помещает больше строк в то же место.
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);