# SegmentedControl

## Кратко

`SegmentedControl` — [составной компонент](https://vega-ui.dev/ru/docs/glossary/#составной-компонент-compound-component) для коротких, всегда видимых наборов взаимоисключающих вариантов с нативными радиоинпутами на уровне элементов и анимированным индикатором выбранного состояния. Это контрол взаимоисключающего выбора, ставящий видимость на первое место, когда варианты должны сравниваться с одного взгляда.

## Импорты

```tsx
import {
  SegmentedControl,
  SegmentedControlItem,
  SegmentedControlIndicator,
  SegmentedControlItemInput,
  type SegmentedControlProps,
  type SegmentedControlSize,
  type SegmentedControlVariant,
} from '@vega-ui/react';
```

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

- `SegmentedControlProps`
- `SegmentedControlItemProps`
- `SegmentedControlIndicatorProps`
- `SegmentedControlItemInputProps`
- `SegmentedControlSize`
- `SegmentedControlVariant`

## Минимальная композиция

```tsx
import {
  SegmentedControl,
  SegmentedControlIndicator,
  SegmentedControlItem,
  SegmentedControlItemInput,
} from '@vega-ui/react';

export default function MinimalComposition() {
  return (
    <SegmentedControl name='billing-period' defaultValue='monthly' role='radiogroup' aria-label='Billing period'>
      <SegmentedControlItem value='monthly'>
        <SegmentedControlItemInput />
        Monthly
      </SegmentedControlItem>
      <SegmentedControlItem value='yearly'>
        <SegmentedControlItemInput />
        Yearly
      </SegmentedControlItem>
      <SegmentedControlIndicator />
    </SegmentedControl>
  );
}
```

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

- `SegmentedControl`: корень, владеющий выбором и индикатором
- `SegmentedControlItem`: видимый выбираемый сегмент
- `SegmentedControlItemInput`: нативная радиокнопка в каждом сегменте — фокус, стрелки, выбор и значение в форме
- `SegmentedControlIndicator`: поверхность выбранного состояния

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

- Нет

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

1. `SegmentedControl`
2. `SegmentedControlItem`
3. `SegmentedControlItemInput`
4. видимое содержимое элемента
5. `SegmentedControlIndicator`

## Варианты

- Размеры: `xs`, `sm`, `md`, `lg`, `xl`
- Варианты: `primary`, `secondary`
- Состояния: выбранный, невыбранный, отключённый

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

- Скрытые инпуты важны для нативной семантики формы и группы радиокнопок.
- Длинные или сильно различающиеся подписи могут нарушить визуальный баланс.
- Используйте `Select`, когда набор вариантов уже не маленький и не должен быть виден постоянно.
- Если каждому варианту нужны заголовок, описание или содержимое в виде карточки, `SegmentedControl` обычно слишком плотный для этой задачи.

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

- Использовать сегментированный контрол для больших наборов вариантов.
- Пропускать `SegmentedControlItemInput`, который и является настоящей радиокнопкой.
- Допускать слишком большой разброс длины подписей и нарушать баланс сегментов.

## Примеры

### Базовый

#### Базовый: переключатель вида

Каждый сегмент — это `SegmentedControlItem` со скрытой радиокнопкой `SegmentedControlItemInput`, и все они используют `name` корня. Корень сам не задаёт роль, поэтому передайте `role='radiogroup'` и `aria-label`, чтобы дать группе имя.

```tsx
import {
  SegmentedControl,
  SegmentedControlIndicator,
  SegmentedControlItem,
  SegmentedControlItemInput,
} from '@vega-ui/react';

export default function ViewRangeSelector() {
  return (
    <SegmentedControl name='calendar-view' defaultValue='week' role='radiogroup' aria-label='Calendar view'>
      <SegmentedControlItem value='day'>
        <SegmentedControlItemInput />
        Day
      </SegmentedControlItem>
      <SegmentedControlItem value='week'>
        <SegmentedControlItemInput />
        Week
      </SegmentedControlItem>
      <SegmentedControlItem value='month'>
        <SegmentedControlItemInput />
        Month
      </SegmentedControlItem>
      <SegmentedControlItem value='year'>
        <SegmentedControlItemInput />
        Year
      </SegmentedControlItem>
      <SegmentedControlIndicator />
    </SegmentedControl>
  );
}
```

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

#### Контролируемый/с состоянием: диапазон дат, управляющий сводкой

Родитель хранит `value` и обновляет его из `event.currentTarget.value` в `onChange`. Суммы посетителей ниже вычисляются из выбранного диапазона.

```tsx
import { useState } from 'react';
import {
  SegmentedControl,
  SegmentedControlIndicator,
  SegmentedControlItem,
  SegmentedControlItemInput,
  Text,
} from '@vega-ui/react';

const dailyVisitors = [
  1180, 1240, 990, 1430, 1510, 870, 760, 1320, 1290, 1405, 1388, 1460, 910, 820, 1500,
  1530, 1475, 1610, 1580, 960, 880, 1620, 1700, 1655, 1590, 1720, 1010, 940, 1750, 1810,
];
const ranges = [
  { value: '7', label: '7 days' },
  { value: '14', label: '14 days' },
  { value: '30', label: '30 days' },
];

export default function ParentOwnedSelectedSegment() {
  const [range, setRange] = useState('7');

  const days = dailyVisitors.slice(-Number(range));
  const total = days.reduce((sum, visitors) => sum + visitors, 0);
  const average = Math.round(total / days.length);

  return (
    <div style={{ display: 'grid', gap: 'var(--spacing-8)', justifyItems: 'start', width: '100%' }}>
      <SegmentedControl
        name='traffic-range'
        role='radiogroup'
        aria-label='Date range'
        value={range}
        onChange={(event) => setRange(event.currentTarget.value)}
      >
        {ranges.map((item) => (
          <SegmentedControlItem key={item.value} value={item.value}>
            <SegmentedControlItemInput />
            {item.label}
          </SegmentedControlItem>
        ))}
        <SegmentedControlIndicator />
      </SegmentedControl>
      <Text size={3} aria-live='polite' style={{ fontVariantNumeric: 'tabular-nums' }}>
        {total.toLocaleString('en-US')} visitors in the last {range} days · {average.toLocaleString('en-US')} per day
      </Text>
    </div>
  );
}
```

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

#### Форма/интеграция: чтение значения через FormData

Поля — нативные радиокнопки с `name` корня, поэтому `FormData.get('export-format')` возвращает выбранный `value`. `defaultValue` становится `defaultChecked`, поэтому сброс формы возвращает его.

```tsx
import { FormEvent, useState } from 'react';
import {
  Button,
  Label,
  SegmentedControl,
  SegmentedControlIndicator,
  SegmentedControlItem,
  SegmentedControlItemInput,
  Text,
} from '@vega-ui/react';

export default function NativeRadioGroupParticipation() {
  const [result, setResult] = useState('');

  const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    const data = new FormData(event.currentTarget);
    setResult(`format=${data.get('export-format')}`);
  };

  return (
    <form onSubmit={handleSubmit} onReset={() => setResult('')} style={{ display: 'grid', gap: 'var(--spacing-12)', justifyItems: 'start', width: '100%' }}>
      <div style={{ display: 'grid', gap: 'var(--spacing-3)' }}>
        <Label id='export-format-label'>File format</Label>
        <SegmentedControl name='export-format' defaultValue='csv' role='radiogroup' aria-labelledby='export-format-label'>
          <SegmentedControlItem value='csv'>
            <SegmentedControlItemInput />
            CSV
          </SegmentedControlItem>
          <SegmentedControlItem value='xlsx'>
            <SegmentedControlItemInput />
            Excel
          </SegmentedControlItem>
          <SegmentedControlItem value='json'>
            <SegmentedControlItemInput />
            JSON
          </SegmentedControlItem>
          <SegmentedControlIndicator />
        </SegmentedControl>
      </div>
      <div style={{ display: 'grid', gap: 'var(--spacing-6)' }}>
        <div style={{ display: 'flex', gap: 'var(--spacing-4)' }}>
          <Button type='submit'>Export report</Button>
          <Button type='reset' variant='secondary' appearance='ghost'>Reset</Button>
        </div>
        <Text size={2} aria-live='polite' style={{ color: 'var(--label-secondary)' }}>{result}</Text>
      </div>
    </form>
  );
}
```

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

#### Раскладка/оверлей: переключатель внутри диалога

Контрол находится в `form method='dialog'`: применение читает `FormData`, обновляет значение снаружи и закрывает диалог. Содержимое диалога монтируется при открытии заново, поэтому `defaultValue` начинается с применённого значения.

```tsx
import { CSSProperties, FormEvent, useState } from 'react';
import {
  Button,
  Dialog,
  DialogClose,
  DialogContent,
  DialogTrigger,
  Heading,
  Icon,
  IconButton,
  Label,
  SegmentedControl,
  SegmentedControlIndicator,
  SegmentedControlItem,
  SegmentedControlItemInput,
  Text,
} from '@vega-ui/react';
import { XIcon } from '@vega-ui/icons';

const densities = [
  { value: 'compact', label: 'Compact' },
  { value: 'default', label: 'Default' },
  { value: 'comfortable', label: 'Comfortable' },
];

export default function ExclusiveSelectorInsideOverlays() {
  const [density, setDensity] = useState('default');

  const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
    const data = new FormData(event.currentTarget);
    setDensity(String(data.get('density')));
  };

  return (
    <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--spacing-6)', width: '100%' }}>
      <Text size={3}>Row density: <strong>{density}</strong></Text>
      <Dialog>
        <DialogTrigger asChild>
          <Button variant='secondary' appearance='ghost'>Display settings</Button>
        </DialogTrigger>
        <DialogContent aria-labelledby='display-settings-title'>
          <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--spacing-8)' }}>
            <Heading id='display-settings-title' as='h2' size={4} fontWeight={600}>Display settings</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 method='dialog' onSubmit={handleSubmit} style={{ display: 'grid', gap: 'var(--spacing-12)', marginBlockStart: 'var(--spacing-8)' }}>
            <div style={{ display: 'grid', gap: 'var(--spacing-3)' }}>
              <Label id='density-label'>Row density</Label>
              <SegmentedControl name='density' defaultValue={density} role='radiogroup' aria-labelledby='density-label'>
                {densities.map((item) => (
                  <SegmentedControlItem key={item.value} value={item.value}>
                    <SegmentedControlItemInput />
                    {item.label}
                  </SegmentedControlItem>
                ))}
                <SegmentedControlIndicator />
              </SegmentedControl>
            </div>
            <Button type='submit'>Apply</Button>
          </form>
        </DialogContent>
      </Dialog>
    </div>
  );
}
```

### Ошибка

#### Ошибка: обязательный выбор без значения по умолчанию

Без `defaultValue` ничего не выбрано. `required` на полях блокирует отправку; их событие `invalid` показывает `HelperText`, связанный с группой через `aria-describedby`, помечает поля `aria-invalid` и переводит фокус на первый сегмент. Выбор размера убирает ошибку.

```tsx
import { FormEvent, useState } from 'react';
import {
  Button,
  HelperText,
  Label,
  SegmentedControl,
  SegmentedControlIndicator,
  SegmentedControlItem,
  SegmentedControlItemInput,
} from '@vega-ui/react';

const sizes = ['XS', 'S', 'M', 'L', 'XL'];

export default function RequiredVisibleChoiceWithoutASelection() {
  const [invalid, setInvalid] = useState(false);
  const [added, setAdded] = useState('');

  const handleInvalid = (event: FormEvent<HTMLInputElement>) => {
    event.preventDefault();
    if (event.currentTarget.value === sizes[0]) {
      event.currentTarget.focus();
    }
    setInvalid(true);
  };

  const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    const data = new FormData(event.currentTarget);
    setAdded(`Size ${data.get('size')} added to the cart.`);
  };

  return (
    <form onSubmit={handleSubmit} style={{ display: 'grid', gap: 'var(--spacing-12)', justifyItems: 'start', width: '100%' }}>
      <div style={{ display: 'grid', gap: 'var(--spacing-3)' }}>
        <Label id='size-label'>Size</Label>
        <SegmentedControl
          name='size'
          role='radiogroup'
          aria-labelledby='size-label'
          aria-describedby={invalid ? 'size-error' : undefined}
          onChange={() => setInvalid(false)}
        >
          {sizes.map((size) => (
            <SegmentedControlItem key={size} value={size}>
              <SegmentedControlItemInput
                required
                aria-invalid={invalid}
                onInvalid={handleInvalid}
              />
              {size}
            </SegmentedControlItem>
          ))}
          <SegmentedControlIndicator />
        </SegmentedControl>
        {invalid && <HelperText id='size-error' error>Choose a size to add the jacket to the cart.</HelperText>}
      </div>
      <div style={{ display: 'grid', gap: 'var(--spacing-6)', justifyItems: 'start' }}>
        <Button type='submit'>Add to cart</Button>
        {added && <HelperText aria-live='polite'>{added}</HelperText>}
      </div>
    </form>
  );
}
```

### Отключённый

#### Отключённый: заблокированная группа и один недоступный сегмент

`disabled` на корне отключает все поля и оставляет выбранный сегмент видимым. Для одного сегмента `disabled` на `SegmentedControlItem` меняет только внешний вид; чтобы его нельзя было выбрать, поставьте `disabled` и на его `SegmentedControlItemInput`.

```tsx
import {
  HelperText,
  Label,
  SegmentedControl,
  SegmentedControlIndicator,
  SegmentedControlItem,
  SegmentedControlItemInput,
} from '@vega-ui/react';

export default function SelectedDisabledControl() {
  return (
    <div style={{ display: 'grid', gap: 'var(--spacing-12)', justifyItems: 'start', width: '100%' }}>
      <div style={{ display: 'grid', gap: 'var(--spacing-3)' }}>
        <Label id='billing-label'>Billing</Label>
        <SegmentedControl
          name='billing'
          defaultValue='yearly'
          disabled
          role='radiogroup'
          aria-labelledby='billing-label'
          aria-describedby='billing-note'
        >
          <SegmentedControlItem value='monthly'>
            <SegmentedControlItemInput />
            Monthly
          </SegmentedControlItem>
          <SegmentedControlItem value='yearly'>
            <SegmentedControlItemInput />
            Yearly
          </SegmentedControlItem>
          <SegmentedControlIndicator />
        </SegmentedControl>
        <HelperText id='billing-note'>Locked until your contract renews on March 1.</HelperText>
      </div>
      <div style={{ display: 'grid', gap: 'var(--spacing-3)' }}>
        <Label id='quality-label'>Video quality</Label>
        <SegmentedControl name='quality' defaultValue='1080p' role='radiogroup' aria-labelledby='quality-label'>
          <SegmentedControlItem value='720p'>
            <SegmentedControlItemInput />
            720p
          </SegmentedControlItem>
          <SegmentedControlItem value='1080p'>
            <SegmentedControlItemInput />
            1080p
          </SegmentedControlItem>
          <SegmentedControlItem value='4k' disabled>
            <SegmentedControlItemInput disabled aria-describedby='quality-note' />
            4K
          </SegmentedControlItem>
          <SegmentedControlIndicator />
        </SegmentedControl>
        <HelperText id='quality-note'>4K is available on the Premium plan.</HelperText>
      </div>
    </div>
  );
}
```

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

#### Пограничный случай: сегменты из одних иконок

У иконок нет текста, поэтому каждый `SegmentedControlItemInput` получает `aria-label`, а `title` на сегменте показывает то же имя при наведении мышью.

```tsx
import {
  Icon,
  SegmentedControl,
  SegmentedControlIndicator,
  SegmentedControlItem,
  SegmentedControlItemInput,
} from '@vega-ui/react';
import { MonitorIcon, MoonIcon, SunIcon } from '@vega-ui/icons';

export default function IconOnlyModeSelector() {
  return (
    <SegmentedControl name='appearance' defaultValue='system' size='sm' role='radiogroup' aria-label='Appearance'>
      <SegmentedControlItem value='light' title='Light'>
        <SegmentedControlItemInput aria-label='Light' />
        <Icon size='sm'><SunIcon /></Icon>
      </SegmentedControlItem>
      <SegmentedControlItem value='system' title='System'>
        <SegmentedControlItemInput aria-label='System' />
        <Icon size='sm'><MonitorIcon /></Icon>
      </SegmentedControlItem>
      <SegmentedControlItem value='dark' title='Dark'>
        <SegmentedControlItemInput aria-label='Dark' />
        <Icon size='sm'><MoonIcon /></Icon>
      </SegmentedControlItem>
      <SegmentedControlIndicator />
    </SegmentedControl>
  );
}
```

#### Пограничный случай: числовые значения

Значения сегментов могут быть числами, но `event.currentTarget.value` — всегда строка, поэтому перед сохранением приведите её через `Number`. Число колонок сетки следует за выбором.

```tsx
import { useState } from 'react';
import {
  Label,
  SegmentedControl,
  SegmentedControlIndicator,
  SegmentedControlItem,
  SegmentedControlItemInput,
} from '@vega-ui/react';

const columnOptions = [1, 2, 3, 4];
const photos = ['Harbor', 'Dunes', 'Glacier', 'Canyon', 'Forest', 'Delta', 'Reef', 'Ridge'];

export default function NumericValues() {
  const [columns, setColumns] = useState(3);

  return (
    <div style={{ display: 'grid', gap: 'var(--spacing-8)', width: '100%' }}>
      <div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-6)' }}>
        <Label id='columns-label'>Columns</Label>
        <SegmentedControl
          name='columns'
          size='sm'
          role='radiogroup'
          aria-labelledby='columns-label'
          value={columns}
          onChange={(event) => setColumns(Number(event.currentTarget.value))}
        >
          {columnOptions.map((option) => (
            <SegmentedControlItem key={option} value={option}>
              <SegmentedControlItemInput />
              {option}
            </SegmentedControlItem>
          ))}
          <SegmentedControlIndicator />
        </SegmentedControl>
      </div>
      <div style={{ display: 'grid', gridTemplateColumns: `repeat(${columns}, 1fr)`, gap: 'var(--spacing-4)' }}>
        {photos.map((photo) => (
          <div
            key={photo}
            style={{
              display: 'grid',
              placeItems: 'center',
              height: 64,
              borderRadius: 'var(--radius-2)',
              background: 'var(--fills-tertiary)',
              color: 'var(--label-secondary)',
            }}
          >
            {photo}
          </div>
        ))}
      </div>
    </div>
  );
}
```

## Анатомия

### Обзор

`SegmentedControl` — [составной компонент](https://vega-ui.dev/ru/docs/glossary/#составной-компонент-compound-component) для всегда видимых взаимоисключающих вариантов с общим индикатором, нативными радиоинпутами на уровне элементов и индикатором выбранного состояния, привязанным через CSS-якоря.

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

#### `SegmentedControl`

Обязателен. Отвечает за `name` группы, контролируемое `value` или начальное `defaultValue`, размер, вариант и отключённое состояние.

#### `SegmentedControlItem`

Обязателен. Рендерит один выбираемый сегмент и его видимое содержимое.

#### `SegmentedControlIndicator`

Обязателен для анимированной поверхности выбранного состояния.

#### `SegmentedControlItemInput`

Обязателен в каждом сегменте. Визуально скрытая нативная радиокнопка: от неё берутся фокус, стрелки, выбор, стиль выбранного сегмента и значение в форме.

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

Необязательных частей нет. Дайте группе имя через `role='radiogroup'` и `aria-label` или `aria-labelledby` на корне.

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

1. `SegmentedControl`
2. `SegmentedControlItem`
3. `SegmentedControlItemInput`
4. видимое содержимое элемента
5. `SegmentedControlIndicator`

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

Группа с текстовыми сегментами, получающая имя от видимой подписи через `aria-labelledby`, и группа из одних иконок, где каждый `SegmentedControlItemInput` получает собственный `aria-label`.

```tsx
import {
  Icon,
  Label,
  SegmentedControl,
  SegmentedControlIndicator,
  SegmentedControlItem,
  SegmentedControlItemInput,
} from '@vega-ui/react';
import { LayoutGridIcon, ListIcon } from '@vega-ui/icons';

export default function ValidCompositionPatterns() {
  return (
    <div style={{ display: 'grid', gap: 'var(--spacing-8)', justifyItems: 'start' }}>
      <div style={{ display: 'grid', gap: 'var(--spacing-3)' }}>
        <Label id='orders-filter-label'>Orders</Label>
        <SegmentedControl name='orders-filter' defaultValue='open' role='radiogroup' aria-labelledby='orders-filter-label'>
          <SegmentedControlItem value='open'>
            <SegmentedControlItemInput />
            Open
          </SegmentedControlItem>
          <SegmentedControlItem value='shipped'>
            <SegmentedControlItemInput />
            Shipped
          </SegmentedControlItem>
          <SegmentedControlItem value='returned'>
            <SegmentedControlItemInput />
            Returned
          </SegmentedControlItem>
          <SegmentedControlIndicator />
        </SegmentedControl>
      </div>
      <SegmentedControl name='orders-layout' defaultValue='list' size='sm' role='radiogroup' aria-label='Layout'>
        <SegmentedControlItem value='list'>
          <SegmentedControlItemInput aria-label='List' />
          <Icon size='sm'><ListIcon /></Icon>
        </SegmentedControlItem>
        <SegmentedControlItem value='grid'>
          <SegmentedControlItemInput aria-label='Grid' />
          <Icon size='sm'><LayoutGridIcon /></Icon>
        </SegmentedControlItem>
        <SegmentedControlIndicator />
      </SegmentedControl>
    </div>
  );
}
```

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

#### Элементы без общего корня

Это ломает контекст выбранного состояния и позиционирование индикатора.

#### Скрытые инпуты пропущены в сценариях с формами

Контрол может выглядеть правильно, но терять нативную семантику группы радиокнопок.

#### Большие или сильно разнородные наборы вариантов

Сегментированный контрол предназначен для коротких, всегда видимых взаимоисключающих вариантов, а не для длинных меню.
