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

Option

Option — презентационный примитив выбираемой строки, который переиспользуют спископодобные контролы.
Backlog
In progress
Done
option.tsx · 7 строк
import { Option } from '@vega-ui/react';

<div role='listbox' aria-label='Issue status'>
  <Option>Backlog</Option>
  <Option selected>In progress</Option>
  <Option>Done</Option>
</div>
Поддержка браузеров
Широко доступно
  • Chrome 111
  • Edge 111
  • Firefox 113
  • Safari 15.4
  • Safari на iOS 15.4
Размер в бандле
История версий
Добавлен в v1.0.0, последнее изменение в v3.0.0

Кратко

Option — презентационный примитив выбираемой строки, который переиспользуют спископодобные контролы. Это div с role='option' и состояниями выбора, активности, размера и отключения, но логикой выбора он сам не управляет. Для работающего списка используйте Listbox или Select.

Импорт

import { Option, type OptionProps, type OptionSize } from '@vega-ui/react';

Экспортируемые типы

  • OptionProps
  • OptionSize

Базовое использование

basic-usage.tsx · 12 строк
import { Option } from '@vega-ui/react';

export default function BasicUsage() {
  return (
    <div role='listbox' aria-label='Issue status' style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-3)', width: '100%', maxWidth: 260, padding: 'var(--spacing-4)', borderRadius: 'var(--spacing-8)', background: 'var(--surface-secondary)', border: '1px solid var(--separator-opaque)', boxSizing: 'border-box' }}>
      <Option>Backlog</Option>
      <Option selected>In progress</Option>
      <Option>In review</Option>
      <Option>Done</Option>
    </div>
  );
}

Варианты

  • Размер: xs, sm, md, lg, xl
  • Состояние: выбранный, активный, отключённый
  • Содержимое: простая метка или более богатая строка с иконкой и текстом

Пограничные случаи

  • Option — UI-примитив, а не полноценная система выбора или меню.
  • Отключённый Option не вызывает onClick.
  • Родительские контролы по-прежнему должны согласованно управлять активным вариантом, выбором и навигацией с клавиатуры.

Частые ошибки

  • Использовать Option отдельно и ждать от него управления состоянием выбора.
  • Сочетать размер Option с несовпадающим размером родительского поля.
  • Забывать согласовать selected и видимое активное состояние в родительской системе.

Примеры

Базовый

Базовый: строки внутри именованного списка

Option рендерит строку с role='option', поэтому она всегда находится внутри элемента с role='listbox' и доступным именем. selected только отображает и объявляет состояние, но не управляет им.

basic-usage.tsx · 12 строк
import { Option } from '@vega-ui/react';

export default function BasicUsage() {
  return (
    <div role='listbox' aria-label='Issue status' style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-3)', width: '100%', maxWidth: 260, padding: 'var(--spacing-4)', borderRadius: 'var(--spacing-8)', background: 'var(--surface-secondary)', border: '1px solid var(--separator-opaque)', boxSizing: 'border-box' }}>
      <Option>Backlog</Option>
      <Option selected>In progress</Option>
      <Option>In review</Option>
      <Option>Done</Option>
    </div>
  );
}

Контролируемый/с состоянием

Контролируемый/с состоянием: выбранное состояние от родителя

Собственный список владеет обоими состояниями: selected меняется по клику или Enter, active — по стрелкам. Фокус остаётся на списке, который указывает на активную строку через aria-activedescendant, поэтому каждой строке нужен id.

selected-state-from-parent.tsx · 70 строк
import { Option, Text } from '@vega-ui/react';
import { KeyboardEvent, useId, useState } from 'react';

const statuses = [
  { value: 'backlog', label: 'Backlog' },
  { value: 'progress', label: 'In progress' },
  { value: 'review', label: 'In review' },
  { value: 'done', label: 'Done' },
];

export default function SelectedStateFromParent() {
  const id = useId();
  const [selected, setSelected] = useState('review');
  const [active, setActive] = useState<string | null>(null);
  const current = statuses.find((status) => status.value === selected);

  const moveActive = (step: number) => {
    const index = statuses.findIndex((status) => status.value === active);
    const next = Math.min(Math.max(index + step, 0), statuses.length - 1);
    setActive(statuses[next].value);
  };

  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
    if (event.key === 'ArrowDown') {
      event.preventDefault();
      moveActive(1);
      return;
    }

    if (event.key === 'ArrowUp') {
      event.preventDefault();
      moveActive(-1);
      return;
    }

    if (event.key !== 'Enter' && event.key !== ' ') return;
    if (active === null) return;

    event.preventDefault();
    setSelected(active);
  };

  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-4)', width: '100%', maxWidth: 260 }}>
      <div
        role='listbox'
        tabIndex={0}
        aria-label='Issue status'
        aria-activedescendant={active === null ? undefined : `${id}-${active}`}
        onKeyDown={onKeyDown}
        onFocus={() => setActive(selected)}
        onBlur={() => setActive(null)}
        style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-3)', padding: 'var(--spacing-4)', borderRadius: 'var(--spacing-8)', background: 'var(--surface-secondary)', border: '1px solid var(--separator-opaque)', boxSizing: 'border-box' }}
      >
        {statuses.map((status) => (
          <Option
            key={status.value}
            id={`${id}-${status.value}`}
            selected={status.value === selected}
            active={status.value === active}
            onClick={() => setSelected(status.value)}
          >
            {status.label}
          </Option>
        ))}
      </div>
      <Text size={2} style={{ color: 'var(--label-secondary)' }}>Status: {current?.label}</Text>
    </div>
  );
}

Форма/интеграция

Форма/интеграция: подсказки для текстового поля

Текстовое поле служит комбобоксом и держит фокус: оно указывает на активную подсказку через aria-activedescendant, а Enter или клик копируют имя в поле, которое форма отправляет как assignee. onMouseDown не даёт строке забрать фокус у поля.

option-in-custom-selector-list.tsx · 99 строк
import { Button, Label, Option, Text, TextField, TextFieldInput } from '@vega-ui/react';
import { FormEvent, KeyboardEvent, useId, useState } from 'react';

const people = ['Amara Okafor', 'Boris Petrov', 'Chloé Lefèvre', 'Dmitri Volkov', 'Elena Rossi', 'Farah Haddad'];

export default function OptionInCustomSelectorList() {
  const id = useId();
  const listId = `${id}-list`;
  const [query, setQuery] = useState('');
  const [active, setActive] = useState(0);
  const [submitted, setSubmitted] = useState('');

  const matches = people.filter((person) => person.toLowerCase().includes(query.trim().toLowerCase()));
  const open = query.trim() !== '' && !people.includes(query);

  const choose = (person: string) => {
    setQuery(person);
    setActive(0);
  };

  const onKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
    if (!open || matches.length === 0) return;

    if (event.key === 'ArrowDown') {
      event.preventDefault();
      setActive(Math.min(active + 1, matches.length - 1));
      return;
    }

    if (event.key === 'ArrowUp') {
      event.preventDefault();
      setActive(Math.max(active - 1, 0));
      return;
    }

    if (event.key !== 'Enter') return;

    event.preventDefault();
    choose(matches[active]);
  };

  const onSubmit = (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    setSubmitted(String(new FormData(event.currentTarget).get('assignee')));
  };

  return (
    <form onSubmit={onSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-3)', width: '100%', maxWidth: 300 }}>
      <Label htmlFor={id}>Assignee</Label>
      <TextField>
        <TextFieldInput
          id={id}
          name='assignee'
          role='combobox'
          autoComplete='off'
          aria-expanded={open}
          aria-controls={listId}
          aria-activedescendant={open && matches.length > 0 ? `${listId}-${active}` : undefined}
          placeholder='Start typing a name'
          value={query}
          onChange={(event) => {
            setQuery(event.currentTarget.value);
            setActive(0);
          }}
          onKeyDown={onKeyDown}
        />
      </TextField>
      <div
        role='listbox'
        id={listId}
        aria-label='Matching people'
        hidden={!open}
        style={{ display: open ? 'flex' : 'none', flexDirection: 'column', gap: 'var(--spacing-3)', padding: 'var(--spacing-4)', borderRadius: 'var(--spacing-8)', background: 'var(--surface-secondary)', border: '1px solid var(--separator-opaque)', boxSizing: 'border-box' }}
      >
        {matches.map((person, index) => (
          <Option
            key={person}
            id={`${listId}-${index}`}
            size='sm'
            active={index === active}
            onMouseDown={(event) => event.preventDefault()}
            onClick={() => choose(person)}
          >
            {person}
          </Option>
        ))}
        {matches.length === 0 && (
          <Text size={2} style={{ padding: 'var(--spacing-4)', color: 'var(--label-secondary)' }}>Nobody matches “{query}”.</Text>
        )}
      </div>
      <Button type='submit' style={{ alignSelf: 'flex-end', marginBlockStart: 'var(--spacing-9)' }}>Assign</Button>
      {submitted && (
        <Text size={2} role='status' style={{ color: 'var(--label-secondary)', marginBlockStart: 'var(--spacing-4)' }}>
          Assigned to {submitted}.
        </Text>
      )}
    </form>
  );
}

Раскладка/оверлей

Раскладка/оверлей: насыщенное содержимое варианта

Строки с иконкой и подсказкой внутри приподнятой панели. Строка остаётся одной поверхностью выбора; двухстрочному содержимому нужен height: 'auto', потому что высота строки зафиксирована размером.

rich-option-content.tsx · 47 строк
import { Icon, Option, Text } from '@vega-ui/react';
import { CopyIcon, LinkIcon, MailIcon, Share2Icon } from '@vega-ui/icons';

const actions = [
  { value: 'link', label: 'Copy link', hint: 'Anyone with the link can view', icon: LinkIcon },
  { value: 'email', label: 'Send by email', hint: 'Up to 20 recipients', icon: MailIcon },
  { value: 'duplicate', label: 'Duplicate', hint: 'Creates a private copy', icon: CopyIcon },
  { value: 'export', label: 'Export to Drive', hint: 'PDF or DOCX', icon: Share2Icon },
];

export default function RichOptionContent() {
  return (
    <div
      role='listbox'
      aria-label='Share options'
      style={{
        display: 'flex',
        flexDirection: 'column',
        gap: 'var(--spacing-3)',
        padding: 'var(--spacing-4)',
        width: '100%',
        maxWidth: 300,
        boxSizing: 'border-box',
        borderRadius: 'var(--spacing-8)',
        background: 'var(--surface-secondary)',
        border: '1px solid var(--separator-opaque)',
        boxShadow: 'var(--surface-shadow)',
      }}
    >
      {actions.map((action) => (
        <Option
          key={action.value}
          selected={action.value === 'link'}
          style={{ height: 'auto', paddingBlock: 'var(--spacing-5)', gap: 'var(--spacing-8)' }}
        >
          <Icon size='sm'>
            <action.icon />
          </Icon>
          <span style={{ display: 'flex', flexDirection: 'column' }}>
            <Text size={3}>{action.label}</Text>
            <Text size={2} style={{ opacity: 0.75 }}>{action.hint}</Text>
          </span>
        </Option>
      ))}
    </div>
  );
}

Отключённый

Отключённый: недоступные варианты с причиной

Отключённые строки остаются видимыми и игнорируют клики. Причина показана в строке и связана через aria-describedby, поэтому скринридер объявляет, почему тариф нельзя выбрать.

unavailable-plan-hint.tsx · 36 строк
import { Option } from '@vega-ui/react';
import { useId } from 'react';

const plans = [
  { value: 'starter', label: 'Starter', reason: '' },
  { value: 'team', label: 'Team', reason: '' },
  { value: 'legacy', label: 'Legacy Pro', reason: 'No longer available for new workspaces' },
  { value: 'enterprise', label: 'Enterprise', reason: 'Requires a sales contract' },
];

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

  return (
    <div role='listbox' aria-label='Plan' style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-3)', width: '100%', maxWidth: 320, padding: 'var(--spacing-4)', borderRadius: 'var(--spacing-8)', background: 'var(--surface-secondary)', border: '1px solid var(--separator-opaque)', boxSizing: 'border-box' }}>
      {plans.map((plan) => (
        <Option
          key={plan.value}
          selected={plan.value === 'team'}
          disabled={plan.reason !== ''}
          aria-describedby={plan.reason === '' ? undefined : `${id}-${plan.value}`}
          style={plan.reason === '' ? undefined : { height: 'auto', paddingBlock: 'var(--spacing-5)' }}
        >
          <span style={{ display: 'flex', flexDirection: 'column' }}>
            {plan.label}
            {plan.reason !== '' && (
              <span id={`${id}-${plan.value}`} style={{ fontSize: 'var(--font-size-2)' }}>
                {plan.reason}
              </span>
            )}
          </span>
        </Option>
      ))}
    </div>
  );
}

Пограничный случай

Пограничный случай: активная, но не выбранная строка

active и selected независимы: активная строка — там, где клавиатура, выбранная — это значение. Здесь значение — «Newest first», а список указывает на «Oldest first».

active-but-not-selected-row.tsx · 21 строка
import { Option } from '@vega-ui/react';
import { useId } from 'react';

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

  return (
    <div
      role='listbox'
      tabIndex={0}
      aria-label='Sort by'
      aria-activedescendant={`${id}-oldest`}
      style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-3)', width: '100%', maxWidth: 260, padding: 'var(--spacing-4)', borderRadius: 'var(--spacing-8)', background: 'var(--surface-secondary)', border: '1px solid var(--separator-opaque)', boxSizing: 'border-box' }}
    >
      <Option id={`${id}-newest`} selected>Newest first</Option>
      <Option id={`${id}-oldest`} active>Oldest first</Option>
      <Option id={`${id}-popular`}>Most popular</Option>
      <Option id={`${id}-price`} disabled>Price, low to high</Option>
    </div>
  );
}

Пограничный случай: длинные подписи в узком списке

У строк фиксированная высота, поэтому длинный текст нужно обрезать внутри строки. Полный путь остаётся доступен в title.

long-label.tsx · 20 строк
import { Option } from '@vega-ui/react';

const folders = [
  'Inbox',
  'Projects / 2026 / Northwind logistics / Contracts and signed amendments',
  'Receipts',
  'Archive / Customer support / Escalations that were resolved after the SLA deadline',
];

export default function LongLabel() {
  return (
    <div role='listbox' aria-label='Move to folder' style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-3)', width: '100%', maxWidth: 260, padding: 'var(--spacing-4)', borderRadius: 'var(--spacing-8)', background: 'var(--surface-secondary)', border: '1px solid var(--separator-opaque)', boxSizing: 'border-box' }}>
      {folders.map((folder) => (
        <Option key={folder} title={folder} selected={folder === 'Receipts'}>
          <span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{folder}</span>
        </Option>
      ))}
    </div>
  );
}

Анатомия

Обзор

Option — примитив из одной части, который рендерит div с role='option'.

Обязательные части

Option

Обязательная. Владеет всей поверхностью строки варианта, размерами и презентационными состояниями.

Необязательные части

Экспортируемых дочерних частей нет.

Порядок композиции

  1. Option
  2. метка и необязательное богатое содержимое

Корректные паттерны композиции

rich-option-content.tsx · 47 строк
import { Icon, Option, Text } from '@vega-ui/react';
import { CopyIcon, LinkIcon, MailIcon, Share2Icon } from '@vega-ui/icons';

const actions = [
  { value: 'link', label: 'Copy link', hint: 'Anyone with the link can view', icon: LinkIcon },
  { value: 'email', label: 'Send by email', hint: 'Up to 20 recipients', icon: MailIcon },
  { value: 'duplicate', label: 'Duplicate', hint: 'Creates a private copy', icon: CopyIcon },
  { value: 'export', label: 'Export to Drive', hint: 'PDF or DOCX', icon: Share2Icon },
];

export default function RichOptionContent() {
  return (
    <div
      role='listbox'
      aria-label='Share options'
      style={{
        display: 'flex',
        flexDirection: 'column',
        gap: 'var(--spacing-3)',
        padding: 'var(--spacing-4)',
        width: '100%',
        maxWidth: 300,
        boxSizing: 'border-box',
        borderRadius: 'var(--spacing-8)',
        background: 'var(--surface-secondary)',
        border: '1px solid var(--separator-opaque)',
        boxShadow: 'var(--surface-shadow)',
      }}
    >
      {actions.map((action) => (
        <Option
          key={action.value}
          selected={action.value === 'link'}
          style={{ height: 'auto', paddingBlock: 'var(--spacing-5)', gap: 'var(--spacing-8)' }}
        >
          <Icon size='sm'>
            <action.icon />
          </Icon>
          <span style={{ display: 'flex', flexDirection: 'column' }}>
            <Text size={3}>{action.label}</Text>
            <Text size={2} style={{ opacity: 0.75 }}>{action.hint}</Text>
          </span>
        </Option>
      ))}
    </div>
  );
}

Каждый Option находится внутри элемента с role='listbox' и доступным именем. Содержимое строки может включать иконку и две строки текста, но строка остаётся одной поверхностью выбора; строкам больше чем в одну линию нужен height: 'auto'.

Некорректные паттерны композиции

Option как самостоятельный селектор без родительского состояния

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

Сложные вложенные интерактивные элементы внутри одного варианта

Строка варианта должна оставаться одной цельной поверхностью выбора.