# Popover

## Кратко

`Popover` — [составной компонент](https://vega-ui.dev/ru/docs/glossary/#составной-компонент-compound-component) для немодального плавающего содержимого, привязанного к триггеру. `PopoverContent` — нативный элемент `popover="auto"`, который открывается с триггера через `popovertarget`, поэтому браузер показывает его в верхнем слое (top layer) и закрывает по клику снаружи или `Escape`, а `overflow` никогда его не обрезает и модальный диалог не перекрывает. Он занимает место между подсказками уровня тултипа и полноценными модальными оверлеями.

## Импорт

```tsx
import {
  Popover,
  PopoverTrigger,
  PopoverContent,
  type PopoverProps,
} from '@vega-ui/react';
```

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

- `PopoverProps`
- `PopoverTriggerProps`
- `PopoverContentProps`

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

```tsx
import { useId } from 'react';
import {
  Button,
  Heading,
  Popover,
  PopoverContent,
  PopoverTrigger,
  Text,
} from '@vega-ui/react';

export default function MinimalComposition() {
  const titleId = useId();

  return (
    <Popover role='dialog'>
      <PopoverTrigger asChild>
        <Button variant='secondary' appearance='ghost'>Storage · 82% used</Button>
      </PopoverTrigger>
      <PopoverContent aria-labelledby={titleId}>
        <div style={{ display: 'grid', gap: 'var(--spacing-2)', maxWidth: 280 }}>
          <Heading id={titleId} as='h3' size={5} fontWeight={600}>16.4 GB of 20 GB used</Heading>
          <Text size={2} style={{ color: 'var(--label-secondary)' }}>Videos take 11.2 GB. Old exports are removed after 90 days.</Text>
        </div>
      </PopoverContent>
    </Popover>
  );
}
```

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

- `Popover`: корневое состояние и провайдер контекста
- `PopoverTrigger`: поверхность триггера
- `PopoverContent`: плавающее содержимое

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

Нет. Для затемнённой подложки используйте `Dialog` или `Sheet`.

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

1. `Popover`
2. `PopoverTrigger`
3. `PopoverContent`

## Варианты

- Состояние открытия через триггер или [контролируемое](https://vega-ui.dev/ru/docs/glossary/#контролируемый-controlled)
- Лёгкий информационный поповер или более насыщенное собственное содержимое

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

- Используйте `Dialog` или `Sheet`, когда взаимодействие должно быть модальным или нужна затемнённая подложка.
- Плавающее содержимое нужно тестировать внутри прокручиваемых контейнеров и оверлеев.
- Содержимое триггера должно ясно показывать, открыт ли поповер.
- Если в содержимом начинают накапливаться разделы формы, подтверждения или большие группы действий, правильной абстракцией обычно уже не будет поповер.

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

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

## Примеры

### Базовый

#### Базовый: меню аккаунта

`role='dialog'` на корне даёт содержимому `role="dialog"`, а триггеру `aria-haspopup="dialog"`; `aria-label` задаёт содержимому имя. Стили раскладки ставятся на внутренний элемент: `display` на самом `PopoverContent` перебил бы скрытие закрытого поповера браузером.

```tsx
import {
  Button,
  Popover,
  PopoverContent,
  PopoverTrigger,
  Separator,
  Text,
} from '@vega-ui/react';

export default function AnchoredUserMenu() {
  return (
    <Popover role='dialog' placement='bottom-end'>
      <PopoverTrigger asChild>
        <Button variant='secondary' appearance='ghost'>Maria Chen</Button>
      </PopoverTrigger>
      <PopoverContent aria-label='Account' style={{ padding: 'var(--spacing-2)' }}>
        <div style={{ display: 'grid', gap: 'var(--spacing-1)', minWidth: 220 }}>
          <div style={{ display: 'grid', paddingInline: 'var(--spacing-6)', paddingBlock: 'var(--spacing-4)' }}>
            <Text size={3} fontWeight={600}>Maria Chen</Text>
            <Text size={2} style={{ color: 'var(--label-secondary)' }}>maria@northwind.dev</Text>
          </div>
          <Separator />
          <Button asChild variant='secondary' appearance='transparent' style={{ justifyContent: 'flex-start' }}>
            <a href='#profile'>Profile</a>
          </Button>
          <Button asChild variant='secondary' appearance='transparent' style={{ justifyContent: 'flex-start' }}>
            <a href='#billing'>Billing</a>
          </Button>
          <Separator />
          <Button variant='secondary' appearance='transparent' style={{ justifyContent: 'flex-start' }}>Sign out</Button>
        </div>
      </PopoverContent>
    </Popover>
  );
}
```

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

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

`onChangeOpen` сообщает о каждом открытии и закрытии — с триггера, по клику снаружи или `Escape`. Открытие сбрасывает счётчик непрочитанных, а `aria-label` триггера передаёт счётчик скринридеру.

```tsx
import { useId, useState } from 'react';
import {
  Badge,
  Heading,
  Icon,
  IconButton,
  Popover,
  PopoverContent,
  PopoverTrigger,
  Text,
} from '@vega-ui/react';
import { BellIcon } from '@vega-ui/icons';

const notifications = [
  { id: 1, text: 'Tom Okafor mentioned you in Pricing table', time: '4 min ago' },
  { id: 2, text: 'Export of Q3 invoices is ready', time: '1 h ago' },
  { id: 3, text: 'Lea Novak shared Homepage hero with you', time: 'Yesterday' },
];

export default function ControlledOpenState() {
  const titleId = useId();
  const [open, setOpen] = useState(false);
  const [unread, setUnread] = useState(notifications.length);

  const changeOpen = (next: boolean) => {
    setOpen(next);
    if (next) setUnread(0);
  };

  return (
    <div
      style={{
        display: 'flex',
        alignItems: 'center',
        gap: 'var(--spacing-6)',
        boxSizing: 'border-box',
        width: '100%',
        padding: 'var(--spacing-4)',
        paddingInlineEnd: 'var(--spacing-8)',
        background: 'var(--surface-primary)',
        border: '1px solid var(--separator-opaque)',
        borderRadius: 'var(--radius-5)',
      }}
    >
      <Popover role='dialog' placement='bottom-start' open={open} onChangeOpen={changeOpen}>
        <PopoverTrigger asChild>
          <IconButton aria-label={unread > 0 ? `Notifications, ${unread} unread` : 'Notifications'} variant='secondary' appearance='ghost'>
            <Icon><BellIcon /></Icon>
          </IconButton>
        </PopoverTrigger>
        <PopoverContent aria-labelledby={titleId}>
          <div style={{ display: 'grid', gap: 'var(--spacing-8)', width: 280 }}>
            <Heading id={titleId} as='h3' size={5} fontWeight={600}>Notifications</Heading>
            {notifications.map((item) => (
              <div key={item.id} style={{ display: 'grid', gap: 'var(--spacing-1)' }}>
                <Text size={3}>{item.text}</Text>
                <Text size={2} style={{ color: 'var(--label-secondary)' }}>{item.time}</Text>
              </div>
            ))}
          </div>
        </PopoverContent>
      </Popover>
      {unread > 0 && <Badge variant='info' size='sm'>{unread} new</Badge>}
      <Text size={2} style={{ marginInlineStart: 'auto', color: 'var(--label-secondary)' }}>Popover is {open ? 'open' : 'closed'}</Text>
    </div>
  );
}
```

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

Поповер не закрывается сам, когда нажата кнопка внутри. Контролируйте `open` и закрывайте его в обработчике пункта, который заодно запоминает выбранное действие.

```tsx
import { useState } from 'react';
import {
  Button,
  Popover,
  PopoverContent,
  PopoverTrigger,
  Text,
} from '@vega-ui/react';

const actions = ['Duplicate', 'Move to folder', 'Download as PDF', 'Archive'];

export default function MenuClosesOnSelect() {
  const [open, setOpen] = useState(false);
  const [last, setLast] = useState('');

  const run = (action: string) => {
    setLast(action);
    setOpen(false);
  };

  return (
    <div style={{ display: 'grid', justifyItems: 'center', gap: 'var(--spacing-6)', width: '100%' }}>
      <Popover placement='bottom-start' open={open} onChangeOpen={setOpen}>
        <PopoverTrigger asChild>
          <Button variant='secondary' appearance='ghost'>Brand guidelines.fig</Button>
        </PopoverTrigger>
        <PopoverContent style={{ padding: 'var(--spacing-2)' }}>
          <div role='group' aria-label='File actions' style={{ display: 'grid', minWidth: 200 }}>
            {actions.map((action) => (
              <Button
                key={action}
                variant='secondary'
                appearance='transparent'
                style={{ justifyContent: 'flex-start' }}
                onClick={() => run(action)}
              >
                {action}
              </Button>
            ))}
          </div>
        </PopoverContent>
      </Popover>
      <Text size={2} style={{ minHeight: '1lh', color: 'var(--label-secondary)' }}>{last ? `Last action: ${last}` : ''}</Text>
    </div>
  );
}
```

#### Контролируемый/с состоянием: лёгкое подтверждение удаления

У каждого комментария свой контролируемый поповер с кнопками «Keep» и «Delete». `autoFocus` при открытии ставит фокус на безопасный ответ, а `aria-labelledby` даёт содержимому имя по его вопросу.

```tsx
import { useId, useState, type CSSProperties } from 'react';
import {
  Button,
  Popover,
  PopoverContent,
  PopoverTrigger,
  Text,
} from '@vega-ui/react';

const destructive = {
  '--button-background-color': 'var(--color-red-accent-500)',
  '--button-background-color-hover': 'var(--color-red-accent-600)',
  '--button-background-color-active': 'var(--color-red-accent-700)',
} as CSSProperties;

const initialComments = [
  { id: 1, author: 'Tom Okafor', text: 'Can we try the darker blue for the header?' },
  { id: 2, author: 'Lea Novak', text: 'Typo in the second paragraph: “recieve”.' },
];

export default function DestructiveConfirmInAPopover() {
  const [comments, setComments] = useState(initialComments);

  return (
    <div
      style={{
        display: 'grid',
        boxSizing: 'border-box',
        width: '100%',
        minHeight: 160,
        alignContent: 'start',
        paddingInline: 'var(--spacing-8)',
        paddingBlock: 'var(--spacing-2)',
        background: 'var(--surface-primary)',
        border: '1px solid var(--separator-opaque)',
        borderRadius: 'var(--radius-5)',
      }}
    >
      {comments.map((comment, index) => (
        <div
          key={comment.id}
          style={{
            display: 'flex',
            alignItems: 'flex-start',
            justifyContent: 'space-between',
            gap: 'var(--spacing-6)',
            paddingBlock: 'var(--spacing-6)',
            borderBlockStart: index > 0 ? '1px solid var(--separator-opaque)' : undefined,
          }}
        >
          <div style={{ display: 'grid', gap: 'var(--spacing-1)', minWidth: 0 }}>
            <Text size={3} fontWeight={500}>{comment.author}</Text>
            <Text size={3} style={{ color: 'var(--label-secondary)' }}>{comment.text}</Text>
          </div>
          <DeleteComment onDelete={() => setComments(comments.filter((item) => item.id !== comment.id))} />
        </div>
      ))}
      {comments.length === 0 && <Text size={3} style={{ paddingBlock: 'var(--spacing-6)', color: 'var(--label-secondary)' }}>No comments yet.</Text>}
    </div>
  );
}

function DeleteComment({ onDelete }: { onDelete: () => void }) {
  const textId = useId();
  const [open, setOpen] = useState(false);

  return (
    <Popover role='dialog' placement='bottom-end' open={open} onChangeOpen={setOpen}>
      <PopoverTrigger asChild>
        <Button size='sm' variant='secondary' appearance='ghost' style={{ flexShrink: 0 }}>Delete</Button>
      </PopoverTrigger>
      <PopoverContent aria-labelledby={textId}>
        <div style={{ display: 'grid', gap: 'var(--spacing-8)', maxWidth: 240 }}>
          <Text id={textId} size={3}>Delete this comment? It cannot be restored.</Text>
          <div style={{ display: 'flex', justifyContent: 'flex-end', gap: 'var(--spacing-4)' }}>
            <Button size='sm' variant='secondary' appearance='ghost' autoFocus onClick={() => setOpen(false)}>Keep</Button>
            <Button size='sm' style={destructive} onClick={onDelete}>Delete</Button>
          </div>
        </div>
      </PopoverContent>
    </Popover>
  );
}
```

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

#### Форма/интеграция: форма переименования

Форма проверяется нативно (`required`, `pattern`, `maxLength`), читает новое имя через `FormData` и закрывает поповер, ставя `open` в `false`. Круглая кнопка закрытия — обычный `IconButton`, который ставит `open` в `false`: отдельной части для закрытия у поповера нет.

```tsx
import { useId, useState, type CSSProperties, type FormEvent } from 'react';
import {
  Button,
  Heading,
  Icon,
  IconButton,
  Label,
  Popover,
  PopoverContent,
  PopoverTrigger,
  TextField,
  TextFieldInput,
} from '@vega-ui/react';
import { XIcon } from '@vega-ui/icons';

export default function InlineRenameFlow() {
  const titleId = useId();
  const fieldId = useId();
  const [open, setOpen] = useState(false);
  const [name, setName] = useState('Q4 launch plan');

  const onSubmit = (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    setName(String(new FormData(event.currentTarget).get('name')).trim());
    setOpen(false);
  };

  return (
    <Popover role='dialog' placement='bottom-start' open={open} onChangeOpen={setOpen}>
      <PopoverTrigger asChild>
        <Button variant='secondary' appearance='ghost'>{name}</Button>
      </PopoverTrigger>
      <PopoverContent aria-labelledby={titleId}>
        <form onSubmit={onSubmit} style={{ display: 'grid', width: 260 }}>
          <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--spacing-8)' }}>
            <Heading id={titleId} as='h3' size={5} fontWeight={600}>Rename document</Heading>
            <IconButton
              aria-label='Close'
              variant='secondary'
              appearance='ghost'
              style={{ flexShrink: 0, '--icon-button-size': '24px', '--icon-button-br-ratio': 0.5 } as CSSProperties}
              onClick={() => setOpen(false)}
            >
              <Icon size='xs'><XIcon strokeWidth={2.5} /></Icon>
            </IconButton>
          </div>
          <div style={{ display: 'grid', gap: 'var(--spacing-3)', marginBlockStart: 'var(--spacing-6)' }}>
            <Label htmlFor={fieldId}>Name</Label>
            <TextField size='sm'>
              <TextFieldInput id={fieldId} name='name' defaultValue={name} required pattern='.*\S.*' maxLength={80} autoFocus />
            </TextField>
          </div>
          <Button type='submit' size='sm' style={{ justifySelf: 'end', marginBlockStart: 'var(--spacing-8)' }}>Rename</Button>
        </form>
      </PopoverContent>
    </Popover>
  );
}
```

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

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

У каждой строки свой поповер. Содержимое рендерится в верхнем слое, поэтому `overflow: auto` списка его не обрезает, а при прокрутке списка оно следует за триггером и переворачивается, если снизу нет места.

```tsx
import {
  Icon,
  IconButton,
  Popover,
  PopoverContent,
  PopoverTrigger,
  Text,
} from '@vega-ui/react';
import { InfoIcon } from '@vega-ui/icons';

const names = ['Maria Chen', 'Tom Okafor', 'Lea Novak', 'Sam Rivera', 'Ana Silva'];

const members = Array.from({ length: 30 }, (_, index) => ({
  id: index,
  name: names[index % names.length],
  role: index % 4 === 0 ? 'Admin' : 'Editor',
  lastSeen: `${index + 2} h ago`,
}));

export default function ScrollContainer() {
  return (
    <ul
      tabIndex={0}
      aria-label='Team members'
      style={{
        boxSizing: 'border-box',
        width: '100%',
        height: 220,
        overflow: 'auto',
        overscrollBehavior: 'contain',
        margin: 0,
        paddingBlock: 'var(--spacing-2)',
        paddingInline: 0,
        listStyle: 'none',
        background: 'var(--surface-primary)',
        border: '1px solid var(--separator-opaque)',
        borderRadius: 'var(--radius-5)',
      }}
    >
      {members.map((member) => (
        <li key={member.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--spacing-6)', paddingInline: 'var(--spacing-8)', paddingBlock: 'var(--spacing-3)' }}>
          <Text size={3}>{member.name}</Text>
          <Popover role='dialog' placement='bottom-end'>
            <PopoverTrigger asChild>
              <IconButton aria-label={`Details for ${member.name}`} size='sm' variant='secondary' appearance='ghost'>
                <Icon size='xs'><InfoIcon /></Icon>
              </IconButton>
            </PopoverTrigger>
            <PopoverContent aria-label={`${member.name} details`}>
              <div style={{ display: 'grid', gap: 'var(--spacing-1)', minWidth: 180 }}>
                <Text size={3} fontWeight={600}>{member.name}</Text>
                <Text size={2} style={{ color: 'var(--label-secondary)' }}>{member.role} · last seen {member.lastSeen}</Text>
              </div>
            </PopoverContent>
          </Popover>
        </li>
      ))}
    </ul>
  );
}
```

### Ошибка

#### Ошибка: невалидный адрес ссылки

Форма использует `noValidate` и проверяет адрес при отправке: невалидный оставляет поповер открытым и показывает ошибку, связанную через `aria-describedby` и `aria-invalid`, валидный вставляется и закрывает его. Отправьте предзаполненное значение, чтобы увидеть ошибку.

```tsx
import { useId, useState, type FormEvent } from 'react';
import {
  Button,
  HelperText,
  Icon,
  IconButton,
  Label,
  Popover,
  PopoverContent,
  PopoverTrigger,
  Text,
  TextField,
  TextFieldInput,
} from '@vega-ui/react';
import { LinkIcon } from '@vega-ui/icons';

const parseUrl = (value: string) => {
  const withProtocol = /^[a-z]+:\/\//i.test(value) ? value : `https://${value}`;

  try {
    const url = new URL(withProtocol);
    if (!url.hostname.includes('.')) return null;
    return url.href;
  } catch {
    return null;
  }
};

export default function LinkError() {
  const fieldId = useId();
  const errorId = useId();
  const [open, setOpen] = useState(false);
  const [error, setError] = useState('');
  const [href, setHref] = useState('');

  const changeOpen = (next: boolean) => {
    setError('');
    setOpen(next);
  };

  const onSubmit = (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    const url = parseUrl(String(new FormData(event.currentTarget).get('url')).trim());

    if (!url) {
      setError('Enter a full address, for example northwind.dev/pricing.');
      return;
    }
    setHref(url);
    setOpen(false);
  };

  return (
    <div
      style={{
        display: 'flex',
        alignItems: 'center',
        gap: 'var(--spacing-6)',
        boxSizing: 'border-box',
        width: '100%',
        padding: 'var(--spacing-4)',
        paddingInlineEnd: 'var(--spacing-8)',
        background: 'var(--surface-primary)',
        border: '1px solid var(--separator-opaque)',
        borderRadius: 'var(--radius-5)',
      }}
    >
      <Popover role='dialog' placement='bottom-start' open={open} onChangeOpen={changeOpen}>
        <PopoverTrigger asChild>
          <IconButton aria-label='Insert link' variant='secondary' appearance='ghost' style={{ flexShrink: 0 }}>
            <Icon><LinkIcon /></Icon>
          </IconButton>
        </PopoverTrigger>
        <PopoverContent aria-label='Insert link'>
          <form noValidate onSubmit={onSubmit} style={{ display: 'grid', gap: 'var(--spacing-3)', width: 280 }}>
            <Label htmlFor={fieldId}>Link address</Label>
            <TextField size='sm' error={error !== ''}>
              <TextFieldInput
                id={fieldId}
                name='url'
                inputMode='url'
                defaultValue='northwind'
                autoFocus
                aria-invalid={error !== ''}
                aria-describedby={error ? errorId : undefined}
                onChange={() => setError('')}
              />
            </TextField>
            {error && <HelperText id={errorId} error size='sm'>{error}</HelperText>}
            <Button type='submit' size='sm' style={{ justifySelf: 'end', marginBlockStart: 'var(--spacing-5)' }}>Insert</Button>
          </form>
        </PopoverContent>
      </Popover>
      <Text size={2} style={{ minWidth: 0, color: 'var(--label-secondary)', overflowWrap: 'anywhere' }}>{href ? `Linked to ${href}` : 'No link yet'}</Text>
    </div>
  );
}
```

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

#### Отключённый: триггер отключён, пока ничего не выбрано

Триггер «Bulk actions» отключён, пока не выбран ни один файл, и в это время подсказка связана с ним через `aria-describedby`. Отключённая кнопка не может открыть свой поповер через `popovertarget`.

```tsx
import { useId, useState } from 'react';
import {
  Button,
  Checkbox,
  CheckboxCheckedIcon,
  CheckboxIndeterminateIcon,
  CheckboxIndicator,
  CheckboxInput,
  HelperText,
  Popover,
  PopoverContent,
  PopoverTrigger,
  Text,
} from '@vega-ui/react';

const actions = ['Download', 'Move to archive', 'Share'];

const files = ['Contract – Kestrel Robotics.pdf', 'Invoice INV-2041.pdf', 'Brand guidelines.fig'];

export default function DisabledTrigger() {
  const hintId = useId();
  const [selected, setSelected] = useState<string[]>([]);
  const [open, setOpen] = useState(false);
  const [status, setStatus] = useState('');

  const toggle = (file: string, checked: boolean) => {
    if (checked) {
      setSelected([...selected, file]);
      return;
    }
    setSelected(selected.filter((item) => item !== file));
  };

  const run = (action: string) => {
    setStatus(`${action}: ${selected.length} file(s)`);
    setOpen(false);
  };

  const none = selected.length === 0;

  return (
    <div style={{ display: 'grid', gap: 'var(--spacing-6)', width: '100%' }}>
      <div
        style={{
          display: 'grid',
          gap: 'var(--spacing-6)',
          padding: 'var(--spacing-8)',
          background: 'var(--surface-primary)',
          border: '1px solid var(--separator-opaque)',
          borderRadius: 'var(--radius-5)',
        }}
      >
        {files.map((file) => (
          <Checkbox key={file} style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-4)', minWidth: 0 }}>
            <CheckboxInput checked={selected.includes(file)} onChange={(event) => toggle(file, event.currentTarget.checked)} />
            <CheckboxIndicator>
              <CheckboxCheckedIcon />
              <CheckboxIndeterminateIcon />
            </CheckboxIndicator>
            <span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{file}</span>
          </Checkbox>
        ))}
      </div>
      <div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-6)' }}>
        <Popover placement='bottom-start' open={open} onChangeOpen={setOpen}>
          <PopoverTrigger asChild>
            <Button variant='secondary' appearance='ghost' disabled={none} aria-describedby={none ? hintId : undefined} style={{ flexShrink: 0, fontVariantNumeric: 'tabular-nums' }}>
              {none ? 'Bulk actions' : `Bulk actions (${selected.length})`}
            </Button>
          </PopoverTrigger>
          <PopoverContent style={{ padding: 'var(--spacing-2)' }}>
            <div role='group' aria-label='Bulk actions' style={{ display: 'grid', minWidth: 180 }}>
              {actions.map((action) => (
                <Button key={action} variant='secondary' appearance='transparent' style={{ justifyContent: 'flex-start' }} onClick={() => run(action)}>
                  {action}
                </Button>
              ))}
            </div>
          </PopoverContent>
        </Popover>
        {none && <HelperText id={hintId}>Select at least one file.</HelperText>}
        {!none && <Text size={2} role='status' style={{ color: 'var(--label-secondary)' }}>{status}</Text>}
      </div>
    </div>
  );
}
```

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

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

Выбор метки открывается изнутри поповера фильтров. Вложенные элементы `popover="auto"` складываются в стек нативно: выбор метки закрывает только внутренний, а клик снаружи — оба.

```tsx
import { useState } from 'react';
import {
  Button,
  Popover,
  PopoverContent,
  PopoverTrigger,
  Text,
} from '@vega-ui/react';

const labels = ['Bug', 'Feature', 'Design', 'Docs'];

export default function NestedPopover() {
  const [label, setLabel] = useState('Bug');
  const [labelOpen, setLabelOpen] = useState(false);

  const choose = (next: string) => {
    setLabel(next);
    setLabelOpen(false);
  };

  return (
    <Popover role='dialog' placement='bottom-start'>
      <PopoverTrigger asChild>
        <Button variant='secondary' appearance='ghost'>Filters · {label}</Button>
      </PopoverTrigger>
      <PopoverContent aria-label='Filters'>
        <div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-6)' }}>
          <Text size={3}>Label</Text>
          <Popover placement='right-start' open={labelOpen} onChangeOpen={setLabelOpen}>
            <PopoverTrigger asChild>
              <Button size='sm' variant='secondary' appearance='ghost'>{label}</Button>
            </PopoverTrigger>
            <PopoverContent style={{ padding: 'var(--spacing-2)' }}>
              <div role='group' aria-label='Labels' style={{ display: 'grid', minWidth: 120 }}>
                {labels.map((item) => (
                  <Button
                    key={item}
                    size='sm'
                    variant='secondary'
                    appearance={item === label ? 'ghost' : 'transparent'}
                    aria-pressed={item === label}
                    style={{ justifyContent: 'flex-start' }}
                    onClick={() => choose(item)}
                  >
                    {item}
                  </Button>
                ))}
              </div>
            </PopoverContent>
          </Popover>
        </div>
      </PopoverContent>
    </Popover>
  );
}
```

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

У `PopoverContent` ширина `max-content`, поэтому длинный URL растянул бы его на всю свою длину. `max-width` на внутреннем элементе и `overflow-wrap: anywhere` удерживают его в пределах экрана.

```tsx
import {
  Button,
  Popover,
  PopoverContent,
  PopoverTrigger,
  Text,
} from '@vega-ui/react';

const webhook = 'https://hooks.northwind.dev/integrations/v2/workspaces/8f3a9c1e-42b7-4d0a-9e61-b5c7d2f40a18/events?signature=sha256';

export default function LongContent() {
  return (
    <Popover role='dialog'>
      <PopoverTrigger asChild>
        <Button variant='secondary' appearance='ghost'>Webhook URL</Button>
      </PopoverTrigger>
      <PopoverContent aria-label='Webhook URL'>
        <div style={{ display: 'grid', gap: 'var(--spacing-2)', maxWidth: 'min(320px, calc(100vw - 48px))' }}>
          <Text size={2} style={{ color: 'var(--label-secondary)' }}>Events are sent to</Text>
          <Text size={3} style={{ overflowWrap: 'anywhere', fontFamily: 'monospace' }}>{webhook}</Text>
        </div>
      </PopoverContent>
    </Popover>
  );
}
```

## Анатомия

### Обзор

`Popover` управляет состоянием открытия и координирует плавающий слой между триггером и содержимым. По умолчанию он немодальный, поэтому контракт композиции касается привязки и контекстного содержимого, а не изоляции.

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

#### `Popover`

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

#### `PopoverTrigger`

Обязательна при открытии через триггер. Переключает поповер через нативный `popovertarget`, поэтому должна быть кнопкой; с `asChild` дочерний элемент должен рендерить `<button>`.

#### `PopoverContent`

Обязательна. Рендерит плавающую поверхность содержимого как нативный элемент `popover="auto"` в верхнем слое (top layer), поэтому отдельная часть-портал не нужна. Она рендерится всегда и скрыта, пока поповер закрыт; её дочерние элементы рендерятся, только пока он открыт.

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

Нет. Для затемнённой подложки используйте `Dialog` или `Sheet`.

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

1. `Popover`
2. `PopoverTrigger`
3. `PopoverContent`

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

```tsx
import { useId } from 'react';
import {
  Button,
  Heading,
  Popover,
  PopoverContent,
  PopoverTrigger,
  Text,
} from '@vega-ui/react';

export default function MinimalComposition() {
  const titleId = useId();

  return (
    <Popover role='dialog'>
      <PopoverTrigger asChild>
        <Button variant='secondary' appearance='ghost'>Storage · 82% used</Button>
      </PopoverTrigger>
      <PopoverContent aria-labelledby={titleId}>
        <div style={{ display: 'grid', gap: 'var(--spacing-2)', maxWidth: 280 }}>
          <Heading id={titleId} as='h3' size={5} fontWeight={600}>16.4 GB of 20 GB used</Heading>
          <Text size={2} style={{ color: 'var(--label-secondary)' }}>Videos take 11.2 GB. Old exports are removed after 90 days.</Text>
        </div>
      </PopoverContent>
    </Popover>
  );
}
```

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

#### Критическая модальная задача внутри `PopoverContent`

Если сценарий блокирует продвижение или требует строгой ловушки фокуса, используйте `Dialog`, `Drawer` или `Sheet`.

#### Крупная раскладка, похожая на страницу, внутри поповера

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

#### Триггер и содержимое, разнесённые между несвязанными владельцами состояния

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