# Switch

## Кратко

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

## Импорт

```tsx
import {
  Switch,
  SwitchIndicator,
  SwitchInput,
  type SwitchProps,
  type SwitchSize,
  type SwitchVariant,
} from '@vega-ui/react';
```

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

- `SwitchProps`
- `SwitchInputProps`
- `SwitchIndicatorProps`
- `SwitchSize`
- `SwitchVariant`

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

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

export default function MinimalComposition() {
  return (
    <div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-6)' }}>
      <Switch>
        <SwitchInput id='wifi' name='wifi' defaultChecked />
        <SwitchIndicator />
      </Switch>
      <Label htmlFor='wifi'>Wi-Fi</Label>
    </div>
  );
}
```

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

- `Switch`: корневая дорожка и поверхность контекста
- `SwitchInput`: нативный чекбокс с `role='switch'` — фокус, клавиатура, состояние и значение в форме
- `SwitchIndicator`: видимый бегунок и состояние дорожки

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

- Нет. Видимый текст размещается вне корня `Switch`.

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

1. `Switch`
2. `SwitchInput`
3. `SwitchIndicator`
4. отдельный `Label` с `htmlFor`, указывающим на `id` у `SwitchInput`, или `aria-label` на `SwitchInput`

## Варианты

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

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

- Переключатель подразумевает бинарное состояние, применяемое сразу, а не отложенный взаимоисключающий выбор.
- Задайте `SwitchInput` атрибут `name`, когда контрол принадлежит нативной форме.
- Метки должны описывать управляемую функцию, а не само действие переключения.
- Если бинарный выбор — лишь одна строка в большом чек-листе или сценарии отправки формы, `Checkbox` может лучше передать намерение.

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

- Переключатель для отложенных выборов в форме.
- Метки вроде «Включить» вместо названия настройки.
- Отсутствие `SwitchInput`, который и является настоящим контролом.

## Примеры

### Базовый

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

Корень `Switch` — это сама дорожка, поэтому видимый заголовок — отдельный `Label`, связанный с `SwitchInput` через `htmlFor`/`id`, а описание подключается через `aria-describedby`. `flex-shrink: 0` не даёт дорожке сжиматься рядом с длинным текстом.

```tsx
import { Label, Switch, SwitchIndicator, SwitchInput, Text } from '@vega-ui/react';

const settings = [
  { id: 'auto-updates', title: 'Automatic updates', description: 'Install security patches overnight.', checked: true },
  { id: 'usage-data', title: 'Share usage data', description: 'Help us improve with anonymous diagnostics.', checked: false },
  { id: 'beta-features', title: 'Beta features', description: 'Try new tools before they are released.', checked: false },
];

export default function SettingsToggle() {
  return (
    <div style={{ display: 'grid', gap: 'var(--spacing-8)', width: '100%' }}>
      {settings.map((setting) => (
        <div key={setting.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--spacing-8)' }}>
          <div style={{ display: 'grid', gap: 'var(--spacing-1)' }}>
            <Label htmlFor={setting.id}>{setting.title}</Label>
            <Text id={`${setting.id}-description`} size={2} style={{ color: 'var(--label-secondary)' }}>
              {setting.description}
            </Text>
          </div>
          <Switch style={{ flexShrink: 0 }}>
            <SwitchInput id={setting.id} defaultChecked={setting.checked} aria-describedby={`${setting.id}-description`} />
            <SwitchIndicator />
          </Switch>
        </div>
      ))}
    </div>
  );
}
```

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

#### Контролируемый/с состоянием: мгновенный эффект

Переключатель применяет изменение сразу. Родитель хранит `checked`, обновляет его из `event.currentTarget.checked`, а строка статуса ниже сразу показывает новое состояние.

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

export default function ParentOwnedSwitchState() {
  const [maintenance, setMaintenance] = useState(false);

  return (
    <div style={{ display: 'grid', gap: 'var(--spacing-6)', width: '100%', maxWidth: 420 }}>
      <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--spacing-8)' }}>
        <Label htmlFor='maintenance-mode'>Maintenance mode</Label>
        <Switch>
          <SwitchInput
            id='maintenance-mode'
            checked={maintenance}
            onChange={(event) => setMaintenance(event.currentTarget.checked)}
          />
          <SwitchIndicator />
        </Switch>
      </div>
      <Text size={2} aria-live='polite' style={{ color: 'var(--label-secondary)' }}>
        {maintenance ? 'Visitors see the maintenance page. Admins can still sign in.' : 'The site is live for everyone.'}
      </Text>
    </div>
  );
}
```

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

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

Переключатель отправляется как чекбокс: `name` присутствует со значением `on`, когда он включён, и отсутствует, когда выключен, поэтому читайте его через `FormData.has`. Сброс возвращает `defaultChecked`.

```tsx
import { FormEvent, useState } from 'react';
import { Button, Fieldset, FieldsetLegend, Label, Switch, SwitchIndicator, SwitchInput, Text } from '@vega-ui/react';

const privacy = [
  { name: 'profile-public', title: 'Public profile', checked: true },
  { name: 'show-email', title: 'Show email address', checked: false },
  { name: 'search-indexing', title: 'Allow search engines to index my profile', checked: false },
];

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

  const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    const data = new FormData(event.currentTarget);
    const entries = privacy.map((item) => `${item.name}=${data.has(item.name) ? 'on' : 'off'}`);
    setResult(entries.join(', '));
  };

  return (
    <form onSubmit={handleSubmit} onReset={() => setResult('')} style={{ display: 'grid', gap: 'var(--spacing-12)', width: '100%' }}>
      <Fieldset>
        <FieldsetLegend style={{ marginBlockEnd: 'var(--spacing-5)' }}>Privacy</FieldsetLegend>
        <div style={{ display: 'grid', gap: 'var(--spacing-6)' }}>
          {privacy.map((item) => (
            <div key={item.name} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--spacing-8)' }}>
              <Label htmlFor={item.name}>{item.title}</Label>
              <Switch style={{ flexShrink: 0 }}>
                <SwitchInput id={item.name} name={item.name} defaultChecked={item.checked} />
                <SwitchIndicator />
              </Switch>
            </div>
          ))}
        </div>
      </Fieldset>
      <div style={{ display: 'grid', gap: 'var(--spacing-6)' }}>
        <div style={{ display: 'flex', gap: 'var(--spacing-4)' }}>
          <Button type='submit'>Save</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>
  );
}
```

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

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

Короткий список настроек в `Sheet`, который получает имя от заголовка через `aria-labelledby`. Переключатели применяются сразу, поэтому шторке не нужна кнопка отправки — только кнопка закрытия.

```tsx
import { CSSProperties } from 'react';
import {
  Button,
  Heading,
  Icon,
  IconButton,
  Label,
  Sheet,
  SheetClose,
  SheetContent,
  SheetHandle,
  SheetTrigger,
  Switch,
  SwitchIndicator,
  SwitchInput,
} from '@vega-ui/react';
import { XIcon } from '@vega-ui/icons';

const options = [
  { id: 'sheet-autoplay', title: 'Autoplay next episode', checked: true },
  { id: 'sheet-subtitles', title: 'Always show subtitles', checked: false },
  { id: 'sheet-data-saver', title: 'Data saver on mobile networks', checked: true },
];

export default function OverlayToggleList() {
  return (
    <Sheet>
      <SheetTrigger asChild>
        <Button>Playback settings</Button>
      </SheetTrigger>
      <SheetContent aria-labelledby='playback-settings-title'>
        <SheetHandle aria-label='Resize sheet' />
        <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--spacing-8)' }}>
          <Heading id='playback-settings-title' size={4} fontWeight={600} as='h2'>Playback settings</Heading>
          <SheetClose 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>
          </SheetClose>
        </div>
        <div style={{ display: 'grid', gap: 'var(--spacing-8)' }}>
          {options.map((option) => (
            <div key={option.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--spacing-8)' }}>
              <Label htmlFor={option.id}>{option.title}</Label>
              <Switch style={{ flexShrink: 0 }}>
                <SwitchInput id={option.id} name={option.id} defaultChecked={option.checked} />
                <SwitchIndicator />
              </Switch>
            </div>
          ))}
        </div>
      </SheetContent>
    </Sheet>
  );
}
```

### Ошибка

#### Ошибка: правило для нескольких переключателей

Выключение обоих каналов нарушает продуктовое правило. Ошибка появляется сразу, оба поля получают `aria-invalid` и `aria-describedby` со ссылкой на неё, а включение любого канала убирает ошибку. Собственного невалидного стиля у `Switch` нет, поэтому состояние передаёт сообщение.

```tsx
import { useState } from 'react';
import { Fieldset, FieldsetLegend, HelperText, Label, Switch, SwitchIndicator, SwitchInput } from '@vega-ui/react';

export default function AtLeastOneChannel() {
  const [email, setEmail] = useState(true);
  const [push, setPush] = useState(false);

  const invalid = !email && !push;
  const describedBy = invalid ? 'channels-error' : undefined;

  return (
    <Fieldset style={{ width: '100%', maxWidth: 420 }}>
      <FieldsetLegend style={{ marginBlockEnd: 'var(--spacing-5)' }}>Incident notifications</FieldsetLegend>
      <div style={{ display: 'grid', gap: 'var(--spacing-6)' }}>
        <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--spacing-8)' }}>
          <Label htmlFor='channel-email'>Email</Label>
          <Switch>
            <SwitchInput
              id='channel-email'
              checked={email}
              aria-invalid={invalid}
              aria-describedby={describedBy}
              onChange={(event) => setEmail(event.currentTarget.checked)}
            />
            <SwitchIndicator />
          </Switch>
        </div>
        <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--spacing-8)' }}>
          <Label htmlFor='channel-push'>Push notifications</Label>
          <Switch>
            <SwitchInput
              id='channel-push'
              checked={push}
              aria-invalid={invalid}
              aria-describedby={describedBy}
              onChange={(event) => setPush(event.currentTarget.checked)}
            />
            <SwitchIndicator />
          </Switch>
        </div>
      </div>
      {invalid && <HelperText id='channels-error' error>Keep at least one channel on, or nobody will be paged during an incident.</HelperText>}
    </Fieldset>
  );
}
```

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

#### Отключённый: главный переключатель отключает группу

Выключение главного переключателя ставит `disabled` на `Fieldset`, и все переключатели внутри нативно наследуют это. Переключатель security alerts отключён всегда и объясняет причину через `aria-describedby`.

```tsx
import { useState } from 'react';
import { Fieldset, FieldsetLegend, HelperText, Label, Switch, SwitchIndicator, SwitchInput } from '@vega-ui/react';

const kinds = [
  { id: 'notify-comments', title: 'Comments', checked: true },
  { id: 'notify-mentions', title: 'Mentions', checked: true },
  { id: 'notify-reviews', title: 'Review requests', checked: false },
];

export default function DisabledSwitches() {
  const [enabled, setEnabled] = useState(true);

  return (
    <div style={{ display: 'grid', gap: 'var(--spacing-8)', width: '100%', maxWidth: 420 }}>
      <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--spacing-8)' }}>
        <Label htmlFor='notify-all'>Desktop notifications</Label>
        <Switch>
          <SwitchInput id='notify-all' checked={enabled} onChange={(event) => setEnabled(event.currentTarget.checked)} />
          <SwitchIndicator />
        </Switch>
      </div>
      <Fieldset disabled={!enabled}>
        <FieldsetLegend style={{ marginBlockEnd: 'var(--spacing-5)' }}>Notify me about</FieldsetLegend>
        <div style={{ display: 'grid', gap: 'var(--spacing-6)' }}>
          {kinds.map((kind) => (
            <div key={kind.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--spacing-8)' }}>
              <Label htmlFor={kind.id}>{kind.title}</Label>
              <Switch>
                <SwitchInput id={kind.id} name={kind.id} defaultChecked={kind.checked} />
                <SwitchIndicator />
              </Switch>
            </div>
          ))}
          <div style={{ display: 'grid', gap: 'var(--spacing-2)' }}>
            <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--spacing-8)' }}>
              <Label htmlFor='notify-security'>Security alerts</Label>
              <Switch>
                <SwitchInput id='notify-security' defaultChecked disabled aria-describedby='notify-security-note' />
                <SwitchIndicator />
              </Switch>
            </div>
            <HelperText id='notify-security-note'>Always on for workspace owners.</HelperText>
          </div>
        </div>
      </Fieldset>
    </div>
  );
}
```

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

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

Переключатель меняется оптимистично и ждёт ответа. Во время сохранения он `aria-busy` и `aria-disabled` вместо `disabled`, чтобы фокус не терялся; при ошибке значение откатывается, а причина объявляется через `aria-live`. Включение двухфакторной аутентификации нарочно завершается ошибкой.

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

const settings = [
  { id: 'weekly-digest', title: 'Weekly email digest' },
  { id: 'two-factor', title: 'Require two-factor authentication' },
];

const saveSetting = (id: string, value: boolean) => new Promise<void>((resolve, reject) => {
  setTimeout(() => {
    if (id === 'two-factor' && value) {
      reject(new Error('Add a verified phone number first.'));
      return;
    }
    resolve();
  }, 1200);
});

export default function AsyncToggleWithPending() {
  const [values, setValues] = useState<Record<string, boolean>>({ 'weekly-digest': false, 'two-factor': false });
  const [pending, setPending] = useState<Record<string, boolean>>({});
  const [status, setStatus] = useState<Record<string, string>>({});

  const handleChange = async (id: string, next: boolean) => {
    if (pending[id]) return;
    const previous = values[id];
    setValues((current) => ({ ...current, [id]: next }));
    setPending((current) => ({ ...current, [id]: true }));
    setStatus((current) => ({ ...current, [id]: 'Saving…' }));
    try {
      await saveSetting(id, next);
      setStatus((current) => ({ ...current, [id]: 'Saved.' }));
    } catch (error) {
      setValues((current) => ({ ...current, [id]: previous }));
      setStatus((current) => ({ ...current, [id]: `Not saved: ${(error as Error).message}` }));
    }
    setPending((current) => ({ ...current, [id]: false }));
  };

  return (
    <div style={{ display: 'grid', gap: 'var(--spacing-8)', width: '100%' }}>
      {settings.map((setting) => (
        <div key={setting.id} style={{ display: 'grid', gap: 'var(--spacing-2)' }}>
          <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--spacing-8)' }}>
            <Label htmlFor={setting.id}>{setting.title}</Label>
            <Switch style={{ flexShrink: 0 }}>
              <SwitchInput
                id={setting.id}
                checked={values[setting.id]}
                aria-disabled={pending[setting.id]}
                aria-busy={pending[setting.id]}
                aria-describedby={`${setting.id}-status`}
                onChange={(event) => handleChange(setting.id, event.currentTarget.checked)}
              />
              <SwitchIndicator />
            </Switch>
          </div>
          <Text id={`${setting.id}-status`} size={2} aria-live='polite' style={{ color: 'var(--label-secondary)', minBlockSize: '1lh' }}>
            {status[setting.id]}
          </Text>
        </div>
      ))}
    </div>
  );
}
```

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

Строки настроек внутри `dir='rtl'`. Раскладка строки зеркалится через flexbox; проверьте, что при включении бегунок движется к концу дорожки по направлению текста.

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

const settings = [
  { id: 'rtl-dark-mode', title: 'الوضع الداكن', checked: true },
  { id: 'rtl-notifications', title: 'الإشعارات', checked: false },
];

export default function RightToLeft() {
  return (
    <div dir='rtl' lang='ar' style={{ display: 'grid', gap: 'var(--spacing-8)', width: '100%' }}>
      {settings.map((setting) => (
        <div key={setting.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--spacing-8)' }}>
          <Label htmlFor={setting.id}>{setting.title}</Label>
          <Switch>
            <SwitchInput id={setting.id} defaultChecked={setting.checked} />
            <SwitchIndicator />
          </Switch>
        </div>
      ))}
    </div>
  );
}
```

## Анатомия

### Обзор

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

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

#### `Switch`

Обязательная. Владеет контекстом размера и варианта переключателя.

#### `SwitchIndicator`

Обязательная. Рендерит видимые бегунок и дорожку переключателя.

#### `SwitchInput`

Обязательная. Визуально скрытый нативный чекбокс с `role='switch'`: от него берутся фокус, клавиатура, отмеченное состояние и значение в форме.

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

Необязательных частей у `Switch` нет. Корень `Switch` — это сама дорожка, поэтому видимый текст размещается вне его и связывается через `htmlFor`.

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

1. `Switch`
2. `SwitchInput`
3. `SwitchIndicator`
4. отдельный `Label` с `htmlFor`, указывающим на `id` у `SwitchInput`, или `aria-label` на `SwitchInput`

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

Давайте переключателю имя через отдельный `Label`, у которого `htmlFor` совпадает с `id` у `SwitchInput`, или, если видимого текста нет, через `aria-label` на `SwitchInput`. Не оборачивайте `Switch` в ещё один `<label>`: он сам им является.

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

export default function ValidCompositionPatterns() {
  return (
    <div style={{ display: 'grid', gap: 'var(--spacing-8)' }}>
      <div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-6)' }}>
        <Switch>
          <SwitchInput id='bluetooth' name='bluetooth' />
          <SwitchIndicator />
        </Switch>
        <Label htmlFor='bluetooth'>Bluetooth</Label>
      </div>
      <Switch>
        <SwitchInput name='airplane-mode' aria-label='Airplane mode' />
        <SwitchIndicator />
      </Switch>
    </div>
  );
}
```

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

#### Переключатель как отложенный выбор при отправке

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

#### Нет `SwitchInput`

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

#### Метка описывает действие, а не состояние

Метки должны описывать управляемую функцию, а не «Включить» или «Выключить».
