# PageControl

## Кратко

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

## Импорт

```tsx
import {
  PageControl,
  PageControlItem,
  PageControlProgress,
  type PageControlItemProps,
  type PageControlProgressProps,
  type PageControlProps,
} from '@vega-ui/react';
```

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

- `PageControlProps`
- `PageControlItemProps`
- `PageControlProgressProps`

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

`PageControl` рендерит `tablist`, а каждый элемент — `tab`, но видимой подписи нет ни у того, ни у другого: передайте `aria-label` корню и каждому элементу. Для неконтролируемого варианта используйте `defaultActive`, а если слайды живут в состоянии родителя — `active` вместе с `onChangeActive`.

```tsx
import { PageControl, PageControlItem } from '@vega-ui/react';

export default function MinimalComposition() {
  return (
    <PageControl defaultActive={0} aria-label='Slides'>
      <PageControlItem index={0} aria-label='Slide 1 of 3' />
      <PageControlItem index={1} aria-label='Slide 2 of 3' />
      <PageControlItem index={2} aria-label='Slide 3 of 3' />
    </PageControl>
  );
}
```

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

- `PageControl`: корневой координатор активного состояния
- `PageControlItem`: дискретный элемент-точка

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

- `PageControlProgress`: анимированный элемент в виде прогресса

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

1. `PageControl`
2. повторяющиеся `PageControlItem` или `PageControlProgress`

## Варианты

- Размер: `sm`, `md`, `lg`
- Модель состояния: контролируемый или неконтролируемый `active`
- Вид элемента: статичная точка или индикатор прогресса

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

- Паттерн лучше всего работает для коротких последовательностей.
- Элементы прогресса добавляют семантику времени, которая должна соответствовать реальной автопрокрутке или переходу между шагами.
- Активное состояние не должно передаваться только цветом.

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

- Использование `PageControl` для навигации по длинным спискам результатов.
- Активное состояние не синхронизировано с реальной каруселью или шагом онбординга.
- Слишком много элементов для доступного места.

## Примеры

### Базовый

#### Базовый: индикаторы карусели, связанные со слайдом

Активный индекс хранится в состоянии и управляет и точками, и видимым слайдом. У каждой точки есть `id`, `aria-label` с позицией и `aria-controls`, указывающий на панель слайда, — это `tabpanel`, подписанный активной точкой.

```tsx
import { Card, Heading, PageControl, PageControlItem, Text } from '@vega-ui/react';
import { useId, useState } from 'react';

const slides = [
  { title: 'Plan your week', text: 'Drag tasks onto the calendar and see your free time at a glance.' },
  { title: 'Share with your team', text: 'Invite teammates to a board and assign tasks in one click.' },
  { title: 'Stay on track', text: 'Get a summary every Monday with what slipped and what is next.' },
];

export default function CarouselIndicators() {
  const prefix = useId();
  const [active, setActive] = useState(0);
  const slide = slides[active];

  return (
    <Card size='sm' style={{ display: 'grid', gap: 'var(--spacing-10)', justifyItems: 'center', width: '100%' }}>
      <div
        role='tabpanel'
        id={`${prefix}-panel`}
        aria-labelledby={`${prefix}-tab-${active}`}
        style={{ display: 'grid', alignContent: 'start', gap: 'var(--spacing-3)', minHeight: 96, textAlign: 'center' }}
      >
        <Heading as='h3' size={4} fontWeight={600}>{slide.title}</Heading>
        <Text size={2} style={{ color: 'var(--label-secondary)' }}>{slide.text}</Text>
      </div>
      <PageControl active={active} onChangeActive={setActive} aria-label='Feature tour'>
        {slides.map((item, index) => (
          <PageControlItem
            key={item.title}
            index={index}
            id={`${prefix}-tab-${index}`}
            aria-controls={`${prefix}-panel`}
            aria-label={`${item.title}, ${index + 1} of ${slides.length}`}
          />
        ))}
      </PageControl>
    </Card>
  );
}
```

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

#### Контролируемый/с состоянием: внешние кнопки «назад» и «вперёд»

Одно и то же значение `active` меняется из трёх мест: точками, стрелками на клавиатуре внутри точек и кнопками-иконками по бокам. Подпись сверху показывает текущее фото и его позицию, а на краях кнопки отключены.

```tsx
import { ChevronLeft, ChevronRight } from '@vega-ui/icons';
import { Icon, IconButton, PageControl, PageControlItem, Text } from '@vega-ui/react';
import { useState } from 'react';

const photos = ['Lisbon tram', 'Sintra palace', 'Cascais beach', 'Porto bridge', 'Douro valley'];

export default function ControlledActiveIndex() {
  const [active, setActive] = useState(0);

  const isFirst = active === 0;
  const isLast = active === photos.length - 1;

  return (
    <div style={{ display: 'grid', gap: 'var(--spacing-8)', justifyItems: 'center', width: '100%' }}>
      <Text size={3} fontWeight={500} aria-live='polite' style={{ fontVariantNumeric: 'tabular-nums' }}>
        {photos[active]} · {active + 1} / {photos.length}
      </Text>
      <div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-8)' }}>
        <IconButton aria-label='Previous photo' variant='secondary' appearance='ghost' size='sm' disabled={isFirst} onClick={() => setActive(active - 1)}>
          <Icon><ChevronLeft /></Icon>
        </IconButton>
        <PageControl active={active} onChangeActive={setActive} aria-label='Photos'>
          {photos.map((photo, index) => (
            <PageControlItem key={photo} index={index} aria-label={photo} />
          ))}
        </PageControl>
        <IconButton aria-label='Next photo' variant='secondary' appearance='ghost' size='sm' disabled={isLast} onClick={() => setActive(active + 1)}>
          <Icon><ChevronRight /></Icon>
        </IconButton>
      </div>
    </div>
  );
}
```

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

#### Форма/интеграция: точки шагов над многошаговой формой

Одна `<form>` и скрытый `<fieldset>` на каждый шаг. Continue проверяет только текущий шаг через `reportValidity`, ещё не пройденные шаги отключены (`disabled`), и `onChangeActive` их тоже игнорирует, так что стрелками нельзя перескочить вперёд. Итоговая отправка читает все шаги через `FormData`.

```tsx
import { Button, HelperText, Label, PageControl, PageControlItem, Text, TextField, TextFieldInput } from '@vega-ui/react';
import { FormEvent, MouseEvent, useId, useState } from 'react';

const steps = ['Account', 'Workspace', 'Invite'];

export default function StepIndicatorAboveFormPages() {
  const id = useId();
  const [step, setStep] = useState(0);
  const [reached, setReached] = useState(0);
  const [result, setResult] = useState('');

  const goTo = (index: number) => {
    if (index > reached) return;
    setStep(index);
  };

  const next = (event: MouseEvent<HTMLButtonElement>) => {
    const fieldset = event.currentTarget.form?.elements.namedItem(`step-${step}`);
    if (!(fieldset instanceof HTMLFieldSetElement)) return;

    const fields = Array.from(fieldset.elements) as HTMLInputElement[];
    const valid = fields.every((field) => field.reportValidity());
    if (!valid) return;

    setStep(step + 1);
    setReached(Math.max(reached, step + 1));
  };

  const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    const data = new FormData(event.currentTarget);
    setResult(`Workspace “${data.get('workspace')}” created for ${data.get('email')}.`);
  };

  return (
    <form onSubmit={handleSubmit} style={{ display: 'grid', gap: 'var(--spacing-12)', width: '100%' }}>
      <div style={{ display: 'grid', gap: 'var(--spacing-3)', justifyItems: 'center' }}>
        <PageControl active={step} onChangeActive={goTo} aria-label='Sign-up steps'>
          {steps.map((label, index) => (
            <PageControlItem
              key={label}
              index={index}
              disabled={index > reached}
              aria-label={`Step ${index + 1}: ${label}`}
            />
          ))}
        </PageControl>
        <Text size={2} style={{ color: 'var(--label-secondary)' }}>Step {step + 1} of {steps.length}: {steps[step]}</Text>
      </div>

      <fieldset name='step-0' hidden={step !== 0} style={{ margin: 0, padding: 0, border: 'none' }}>
        <div style={{ display: 'grid', gap: 'var(--spacing-3)' }}>
          <Label htmlFor={`${id}-email`}>Work email</Label>
          <TextField>
            <TextFieldInput id={`${id}-email`} name='email' type='email' autoComplete='email' required />
          </TextField>
        </div>
      </fieldset>

      <fieldset name='step-1' hidden={step !== 1} style={{ margin: 0, padding: 0, border: 'none' }}>
        <div style={{ display: 'grid', gap: 'var(--spacing-3)' }}>
          <Label htmlFor={`${id}-workspace`}>Workspace name</Label>
          <TextField>
            <TextFieldInput id={`${id}-workspace`} name='workspace' required minLength={3} />
          </TextField>
        </div>
      </fieldset>

      <fieldset name='step-2' hidden={step !== 2} style={{ margin: 0, padding: 0, border: 'none' }}>
        <div style={{ display: 'grid', gap: 'var(--spacing-3)' }}>
          <Label htmlFor={`${id}-invite`}>Invite a teammate (optional)</Label>
          <TextField>
            <TextFieldInput id={`${id}-invite`} name='invite' type='email' />
          </TextField>
        </div>
      </fieldset>

      <div style={{ display: 'flex', justifyContent: 'space-between' }}>
        <Button type='button' variant='secondary' appearance='ghost' disabled={step === 0} onClick={() => setStep(step - 1)}>
          Back
        </Button>
        {step < steps.length - 1 && <Button type='button' onClick={next}>Continue</Button>}
        {step === steps.length - 1 && <Button type='submit'>Create workspace</Button>}
      </div>
      {result && <HelperText role='status'>{result}</HelperText>}
    </form>
  );
}
```

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

#### Раскладка/оверлей: диалог «Что нового»

Точки и кнопка Next делят подвал диалога; на последнем шаге Next превращается в кнопку закрытия. При повторном открытии диалог снова начинается с первого обновления.

```tsx
import {
  Button,
  Dialog,
  DialogClose,
  DialogContent,
  DialogTrigger,
  Heading,
  Icon,
  IconButton,
  PageControl,
  PageControlItem,
  Text,
} from '@vega-ui/react';
import { XIcon } from '@vega-ui/icons';
import { CSSProperties, useId, useState } from 'react';

const updates = [
  { title: 'Dark mode everywhere', text: 'Every screen now follows your system theme, including exported PDFs.' },
  { title: 'Faster search', text: 'Results appear as you type and include archived projects.' },
  { title: 'Keyboard shortcuts', text: 'Press ? anywhere to see the full list of shortcuts.' },
];

export default function WhatsNewDialog() {
  const prefix = useId();
  const [active, setActive] = useState(0);
  const isLast = active === updates.length - 1;

  return (
    <Dialog onChangeOpen={(open) => open && setActive(0)}>
      <DialogTrigger asChild>
        <Button variant='secondary' appearance='ghost'>What’s new</Button>
      </DialogTrigger>
      <DialogContent aria-labelledby={`${prefix}-title`} style={{ display: 'grid', gap: 'var(--spacing-10)' }}>
        <div
          role='tabpanel'
          id={`${prefix}-panel`}
          aria-labelledby={`${prefix}-tab-${active}`}
          style={{ display: 'grid', alignContent: 'start', gap: 'var(--spacing-3)', minHeight: 88 }}
        >
          <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--spacing-8)' }}>
            <Heading id={`${prefix}-title`} as='h2' size={4} fontWeight={600}>{updates[active].title}</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>
          <Text size={3} style={{ color: 'var(--label-secondary)' }}>{updates[active].text}</Text>
        </div>
        <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
          <PageControl active={active} onChangeActive={setActive} aria-label='Updates'>
            {updates.map((update, index) => (
              <PageControlItem
                key={update.title}
                index={index}
                id={`${prefix}-tab-${index}`}
                aria-controls={`${prefix}-panel`}
                aria-label={update.title}
              />
            ))}
          </PageControl>
          {isLast ? (
            <DialogClose asChild>
              <Button size='sm'>Got it</Button>
            </DialogClose>
          ) : (
            <Button size='sm' onClick={() => setActive(active + 1)}>Next</Button>
          )}
        </div>
      </DialogContent>
    </Dialog>
  );
}
```

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

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

`PageControlProgress` анимирует активную точку `duration` миллисекунд, а событие `animationend` переключает на следующую историю. Автопрокрутку должно быть можно остановить, поэтому Pause заменяет элементы с таймером обычными точками `PageControlItem` — таймер останавливается, а заголовок становится вежливой живой областью только на время паузы.

```tsx
import { Button, PageControl, PageControlItem, PageControlProgress, Text } from '@vega-ui/react';
import { useState } from 'react';

const stories = ['New arrivals', 'Autumn sale', 'Gift cards', 'Store opening'];

export default function TimedProgressIndicators() {
  const [active, setActive] = useState(0);
  const [paused, setPaused] = useState(false);

  const advance = () => setActive((active + 1) % stories.length);

  return (
    <div style={{ display: 'grid', gap: 'var(--spacing-8)', justifyItems: 'center', width: '100%' }}>
      <Text size={4} fontWeight={600} aria-live={paused ? 'polite' : 'off'}>{stories[active]}</Text>
      <PageControl active={active} onChangeActive={setActive} aria-label='Stories'>
        {stories.map((story, index) => {
          if (paused) {
            return <PageControlItem key={story} index={index} aria-label={story} />;
          }

          return (
            <PageControlProgress
              key={story}
              index={index}
              duration={4000}
              aria-label={story}
              onAnimationEnd={index === active ? advance : undefined}
            />
          );
        })}
      </PageControl>
      <Button size='sm' variant='secondary' appearance='ghost' aria-pressed={paused} onClick={() => setPaused(!paused)}>
        {paused ? 'Resume autoplay' : 'Pause autoplay'}
      </Button>
    </div>
  );
}
```

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

Тридцать точек — это шум, поэтому рендерится только окно из семи вокруг активного элемента, и каждая точка сохраняет свой настоящий `index`. Окно сдвигается вместе с выбором, поэтому стрелками можно дойти до любого фото, а подпись показывает настоящую позицию.

```tsx
import { PageControl, PageControlItem, Text } from '@vega-ui/react';
import { useState } from 'react';

const total = 30;
const windowSize = 7;

export default function ManyPagesWindow() {
  const [active, setActive] = useState(0);

  const half = Math.floor(windowSize / 2);
  const first = Math.min(Math.max(active - half, 0), total - windowSize);
  const visible = Array.from({ length: windowSize }, (_, offset) => first + offset);

  return (
    <div style={{ display: 'grid', gap: 'var(--spacing-6)', justifyItems: 'center', width: '100%' }}>
      <Text size={3} fontWeight={500} style={{ fontVariantNumeric: 'tabular-nums' }}>Photo {active + 1} of {total}</Text>
      <PageControl active={active} onChangeActive={setActive} aria-label={`Photos, ${total} in total`}>
        {visible.map((index) => (
          <PageControlItem
            key={index}
            index={index}
            aria-label={`Photo ${index + 1} of ${total}`}
          />
        ))}
      </PageControl>
      <Text size={1} style={{ color: 'var(--label-secondary)' }}>Use the arrow keys to move through all {total} photos.</Text>
    </div>
  );
}
```

## Анатомия

### Обзор

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

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

#### `PageControl`

Обязательная. Отвечает за активный индекс и логику взаимодействия.

#### `PageControlItem`

Обязательная для обычных индикаторов-точек.

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

#### `PageControlProgress`

Необязательный элемент в виде прогресса с анимированной заливкой и длительностью.

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

1. `PageControl`
2. повторяющиеся `PageControlItem` или `PageControlProgress`

### Допустимые паттерны композиции

```tsx
import { Card, Heading, PageControl, PageControlItem, Text } from '@vega-ui/react';
import { useId, useState } from 'react';

const slides = [
  { title: 'Plan your week', text: 'Drag tasks onto the calendar and see your free time at a glance.' },
  { title: 'Share with your team', text: 'Invite teammates to a board and assign tasks in one click.' },
  { title: 'Stay on track', text: 'Get a summary every Monday with what slipped and what is next.' },
];

export default function CarouselIndicators() {
  const prefix = useId();
  const [active, setActive] = useState(0);
  const slide = slides[active];

  return (
    <Card size='sm' style={{ display: 'grid', gap: 'var(--spacing-10)', justifyItems: 'center', width: '100%' }}>
      <div
        role='tabpanel'
        id={`${prefix}-panel`}
        aria-labelledby={`${prefix}-tab-${active}`}
        style={{ display: 'grid', alignContent: 'start', gap: 'var(--spacing-3)', minHeight: 96, textAlign: 'center' }}
      >
        <Heading as='h3' size={4} fontWeight={600}>{slide.title}</Heading>
        <Text size={2} style={{ color: 'var(--label-secondary)' }}>{slide.text}</Text>
      </div>
      <PageControl active={active} onChangeActive={setActive} aria-label='Feature tour'>
        {slides.map((item, index) => (
          <PageControlItem
            key={item.title}
            index={index}
            id={`${prefix}-tab-${index}`}
            aria-controls={`${prefix}-panel`}
            aria-label={`${item.title}, ${index + 1} of ${slides.length}`}
          />
        ))}
      </PageControl>
    </Card>
  );
}
```

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

#### Элементы с повторяющимися индексами

Активное состояние и перемещаемый фокус становятся неоднозначными.

#### Элементы прогресса без соответствующей логики перехода между шагами

Анимация обещает переход по таймеру, который родительский сценарий может на самом деле не выполнять.

#### Очень длинные последовательности

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