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

Dialog

Dialog — составной компонент для модальных оверлеев с частями корня, триггера и содержимого.
dialog.tsx · 27 строк
import {
  Button,
  Dialog,
  DialogClose,
  DialogContent,
  DialogTrigger,
  Heading,
  Icon,
  IconButton,
  Text,
} from '@vega-ui/react';
import { XIcon } from '@vega-ui/icons';

<Dialog>
  <DialogTrigger asChild><Button variant='secondary' appearance='ghost'>Leave workspace</Button></DialogTrigger>
  <DialogContent aria-labelledby='preview-dialog-title' aria-describedby='preview-dialog-text'>
    <div>
      <Heading id='preview-dialog-title' as='h2' size={4} fontWeight={600}>Leave Northwind Studio?</Heading>
      <DialogClose asChild>
        <IconButton aria-label='Close' variant='secondary' appearance='ghost' style={CLOSE_STYLE}>
          <Icon size='xs'><XIcon strokeWidth={2.5} /></Icon>
        </IconButton>
      </DialogClose>
    </div>
    <Text id='preview-dialog-text' size={3}>You will lose access to 14 projects until an admin invites you again.</Text>
  </DialogContent>
</Dialog>
Поддержка браузеров
Широко доступно
  • Chrome 111
  • Edge 111
  • Firefox 121
  • Safari 15.6
  • Safari на iOS 15.6
Размер в бандле
История версий
Добавлен в v2.0.0, последнее изменение в v3.0.0

Кратко

Dialog — составной компонент для модальных оверлеев с частями корня, триггера и содержимого. DialogClose превращает вашу собственную кнопку в кнопку закрытия; заголовки и раскладка — обычная разметка, которую вы собираете сами. DialogContent рендерит нативный <dialog>, открытый через showModal(), поэтому браузер помещает его в верхний слой (top layer), делает остальную страницу инертной и рисует подложку. Это самый сильный оверлей общего назначения в семействе; выбирайте его, когда модальное прерывание задумано намеренно.

Импорты

import {
  Dialog,
  DialogTrigger,
  DialogContent,
  DialogClose,
  type DialogProps,
} from '@vega-ui/react';

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

  • DialogProps
  • DialogTriggerProps
  • DialogContentProps
  • DialogCloseProps

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

minimal-composition.tsx · 53 строки
import { useId, type CSSProperties } from 'react';
import {
  Button,
  Dialog,
  DialogClose,
  DialogContent,
  DialogTrigger,
  Heading,
  Icon,
  IconButton,
  Text,
} from '@vega-ui/react';
import { XIcon } from '@vega-ui/icons';

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;

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

  return (
    <Dialog>
      <DialogTrigger asChild>
        <Button variant='secondary' appearance='ghost'>Leave workspace</Button>
      </DialogTrigger>
      <DialogContent aria-labelledby={titleId}>
        <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--spacing-8)' }}>
          <Heading id={titleId} as='h2' size={4} fontWeight={600}>Leave Northwind Studio?</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={{ display: 'block', marginBlockStart: 'var(--spacing-4)', color: 'var(--label-secondary)' }}>
          You will lose access to 14 projects until an admin invites you again.
        </Text>
        <form method='dialog' style={{ display: 'flex', justifyContent: 'flex-end', gap: 'var(--spacing-4)', marginBlockStart: 'var(--spacing-12)' }}>
          <Button type='submit' value='cancel' variant='secondary' appearance='ghost'>Stay</Button>
          <Button type='submit' value='leave' style={destructive}>Leave workspace</Button>
        </form>
      </DialogContent>
    </Dialog>
  );
}

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

  • Dialog: корневое состояние и провайдер контекста
  • DialogTrigger: поверхность действия открытия
  • DialogContent: контейнер модального содержимого

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

  • DialogClose: закрывает диалог по клику; оборачивает вашу собственную кнопку через asChild

Ваша собственная разметка

  • заголовок, чей id передаётся в DialogContent как aria-labelledby (или aria-label на DialogContent); у диалога обязательно должно быть имя
  • кнопки действий, отвечающие на диалог («Отмена», «Подтвердить»), внутри <form method="dialog">

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

Типичная композиция:

  1. Dialog
  2. DialogTrigger
  3. DialogContent с вашим заголовком, необязательным DialogClose, основным содержимым и <form method="dialog"> для кнопок действий

Варианты

  • Контролируемое состояние открытия или управление через триггер
  • Минимальный диалог только с содержимым или диалог со строкой шапки и кнопкой закрытия
  • Диалог с фиксированным содержимым или с прокручиваемым либо резиновым содержимым

Миграция с 2.x

DialogHeader, DialogTitle и DialogCloseButton удалены: библиотека оставляет только части с поведением, которое нельзя выразить обычной разметкой. DialogClose — замена кнопки закрытия, отвечающая только за поведение.

  • DialogHeader → ваш собственный элемент раскладки, например flex-строка
  • DialogTitle → Heading с id, на который ссылается aria-labelledby на DialogContent
  • DialogCloseButton → DialogClose asChild вокруг вашей собственной кнопки, например IconButton с aria-label
  • useDialogContext() больше не возвращает contentId и titleId, а DialogTrigger больше не выставляет aria-controls

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

  • Вложенные оверлеи нужно проверять на восстановление фокуса и порядок наложения.
  • Длинное содержимое должно прокручиваться, не скрывая основные действия.
  • Диалог должен быть назван через aria-labelledby или aria-label.
  • Делайте поведение закрытия явным, если в диалоге есть формы или необратимые действия.
  • Если содержимое начинает выглядеть как полноценный сценарий на целую страницу, выдвижная панель (Drawer), шторка (Sheet) или отдельный маршрут могут оказаться понятнее большого диалога.

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

  • Оставлять DialogContent без aria-labelledby или aria-label.
  • Помещать критичное содержимое страницы в диалог без удобной мобильной раскладки.
  • Не проверять поведение диалога внутри других оверлеев.
  • Считать длинное содержимое диалога статичным, не проверив прокрутку и видимость действий.

Примеры

Базовый

Базовый: подтверждение удаления файла

Кнопки «Отмена» и «Удалить» лежат в <form method="dialog">, поэтому любая из них закрывает диалог, а её value становится returnValue диалога. Страница читает его в onClose, после анимации закрытия, и только тогда удаляет файл; Escape и кнопка закрытия оставляют returnValue пустым.

confirm-action.tsx · 88 строк
import { useId, useState, type CSSProperties } from 'react';
import {
  Button,
  Dialog,
  DialogClose,
  DialogContent,
  DialogTrigger,
  Heading,
  Icon,
  IconButton,
  Text,
} from '@vega-ui/react';
import { XIcon } from '@vega-ui/icons';

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;

export default function ConfirmAction() {
  const titleId = useId();
  const descriptionId = useId();
  const [deleted, setDeleted] = useState(false);

  return (
    <div
      style={{
        display: 'flex',
        alignItems: 'center',
        justifyContent: 'space-between',
        gap: 'var(--spacing-6)',
        boxSizing: 'border-box',
        width: '100%',
        minHeight: 72,
        padding: 'var(--spacing-8)',
        background: 'var(--surface-primary)',
        border: '1px solid var(--separator-opaque)',
        borderRadius: 'var(--radius-5)',
      }}
    >
      {deleted ? (
        <>
          <Text size={3} role='status' style={{ color: 'var(--label-secondary)' }}>Q3 board deck.pdf moved to trash.</Text>
          <Button size='sm' variant='secondary' appearance='ghost' onClick={() => setDeleted(false)}>Undo</Button>
        </>
      ) : (
        <>
          <div style={{ display: 'grid', minWidth: 0 }}>
            <Text size={3} fontWeight={500}>Q3 board deck.pdf</Text>
            <Text size={2} style={{ color: 'var(--label-secondary)' }}>4.2 MB · Edited yesterday</Text>
          </div>
          <Dialog>
            <DialogTrigger asChild>
              <Button size='sm' variant='secondary' appearance='ghost'>Delete</Button>
            </DialogTrigger>
            <DialogContent
              aria-labelledby={titleId}
              aria-describedby={descriptionId}
              onClose={(event) => setDeleted(event.currentTarget.returnValue === 'delete')}
            >
              <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--spacing-8)' }}>
                <Heading id={titleId} as='h2' size={4} fontWeight={600}>Delete Q3 board deck.pdf?</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 id={descriptionId} size={3} style={{ display: 'block', marginBlockStart: 'var(--spacing-4)', color: 'var(--label-secondary)' }}>
                The file stays in trash for 30 days. Shared links stop working right away.
              </Text>
              <form method='dialog' style={{ display: 'flex', justifyContent: 'flex-end', gap: 'var(--spacing-4)', marginBlockStart: 'var(--spacing-12)' }}>
                <Button type='submit' value='cancel' variant='secondary' appearance='ghost'>Cancel</Button>
                <Button type='submit' value='delete' style={destructive}>Delete file</Button>
              </form>
            </DialogContent>
          </Dialog>
        </>
      )}
    </div>
  );
}

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

Контролируемый/с состоянием: один диалог для многих строк

Каждая строка открывает один и тот же контролируемый диалог со своим счётом. Родитель владеет и open, и выбранной строкой, а нативный <dialog> возвращает фокус на кнопку, которая его открыла.

controlled-open-state.tsx · 94 строки
import { useId, useState, type CSSProperties } from 'react';
import {
  Button,
  Dialog,
  DialogClose,
  DialogContent,
  Heading,
  Icon,
  IconButton,
  Text,
} from '@vega-ui/react';
import { XIcon } from '@vega-ui/icons';

const invoices = [
  { number: 'INV-2041', customer: 'Acme Logistics', amount: '$12,480.00', due: 'Nov 14, 2026' },
  { number: 'INV-2042', customer: 'Blue Harbor Cafe', amount: '$860.50', due: 'Nov 21, 2026' },
  { number: 'INV-2043', customer: 'Kestrel Robotics', amount: '$4,125.00', due: 'Dec 1, 2026' },
];

export default function ControlledOpenState() {
  const titleId = useId();
  const [open, setOpen] = useState(false);
  const [selected, setSelected] = useState(invoices[0]);

  const openInvoice = (invoice: typeof invoices[number]) => {
    setSelected(invoice);
    setOpen(true);
  };

  return (
    <div style={{ display: 'grid', gap: 'var(--spacing-6)', width: '100%' }}>
      <ul
        style={{
          display: 'grid',
          margin: 0,
          padding: 'var(--spacing-2) var(--spacing-8)',
          listStyle: 'none',
          background: 'var(--surface-primary)',
          border: '1px solid var(--separator-opaque)',
          borderRadius: 'var(--radius-5)',
        }}
      >
        {invoices.map((invoice, index) => (
          <li
            key={invoice.number}
            style={{
              display: 'flex',
              alignItems: 'center',
              justifyContent: 'space-between',
              gap: 'var(--spacing-6)',
              paddingBlock: 'var(--spacing-5)',
              borderBlockStart: index > 0 ? '1px solid var(--separator-opaque)' : undefined,
            }}
          >
            <div style={{ display: 'grid', minWidth: 0 }}>
              <Text size={3} fontWeight={500}>{invoice.customer}</Text>
              <Text size={2} style={{ color: 'var(--label-secondary)', fontVariantNumeric: 'tabular-nums' }}>{invoice.number} · {invoice.amount}</Text>
            </div>
            <Button size='sm' variant='secondary' appearance='ghost' onClick={() => openInvoice(invoice)}>View</Button>
          </li>
        ))}
      </ul>
      <Text size={2} style={{ color: 'var(--label-secondary)' }}>
        Dialog: {open ? `open, showing ${selected.number}` : 'closed'}
      </Text>

      <Dialog open={open} onChangeOpen={setOpen}>
        <DialogContent aria-labelledby={titleId}>
          <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--spacing-8)' }}>
            <Heading id={titleId} as='h2' size={4} fontWeight={600}>{selected.number}</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>
          <dl style={{ display: 'grid', gridTemplateColumns: 'auto 1fr', gap: 'var(--spacing-4) var(--spacing-12)', margin: 'var(--spacing-8) 0 0' }}>
            <dt><Text size={3} style={{ color: 'var(--label-secondary)' }}>Customer</Text></dt>
            <dd style={{ margin: 0 }}><Text size={3}>{selected.customer}</Text></dd>
            <dt><Text size={3} style={{ color: 'var(--label-secondary)' }}>Amount</Text></dt>
            <dd style={{ margin: 0 }}><Text size={3} style={{ fontVariantNumeric: 'tabular-nums' }}>{selected.amount}</Text></dd>
            <dt><Text size={3} style={{ color: 'var(--label-secondary)' }}>Due</Text></dt>
            <dd style={{ margin: 0 }}><Text size={3}>{selected.due}</Text></dd>
          </dl>
        </DialogContent>
      </Dialog>
    </div>
  );
}

Контролируемый/с состоянием: диалог не закрывается, пока есть несохранённые изменения

Каждый запрос на закрытие (Escape, кнопка закрытия, «Отмена») проходит через onChangeOpen(false). Пока в заметке есть несохранённые правки, родитель отвечает на первый запрос предупреждением, а не закрытием; второй запрос отбрасывает черновик.

unsaved-changes-guard.tsx · 112 строк
import { useId, useState, type CSSProperties } from 'react';
import {
  Button,
  Dialog,
  DialogClose,
  DialogContent,
  DialogTrigger,
  Heading,
  HelperText,
  Icon,
  IconButton,
  Label,
  Text,
  TextArea,
} from '@vega-ui/react';
import { XIcon } from '@vega-ui/icons';

export default function UnsavedChangesGuard() {
  const titleId = useId();
  const fieldId = useId();
  const warningId = useId();
  const [open, setOpen] = useState(false);
  const [saved, setSaved] = useState('Call back after the Friday standup.');
  const [draft, setDraft] = useState(saved);
  const [confirming, setConfirming] = useState(false);

  const dirty = draft !== saved;

  const changeOpen = (next: boolean) => {
    if (next) {
      setDraft(saved);
      setConfirming(false);
      setOpen(true);
      return;
    }
    if (dirty && !confirming) {
      setConfirming(true);
      return;
    }
    setOpen(false);
  };

  const save = () => {
    setSaved(draft);
    setOpen(false);
  };

  return (
    <div
      style={{
        display: 'flex',
        alignItems: 'center',
        justifyContent: 'space-between',
        gap: 'var(--spacing-6)',
        boxSizing: 'border-box',
        width: '100%',
        padding: 'var(--spacing-8)',
        background: 'var(--surface-primary)',
        border: '1px solid var(--separator-opaque)',
        borderRadius: 'var(--radius-5)',
      }}
    >
      <div style={{ display: 'grid', minWidth: 0 }}>
        <Text size={2} style={{ color: 'var(--label-secondary)' }}>Note for Maria Chen</Text>
        <Text size={3} style={{ overflowWrap: 'anywhere' }}>{saved}</Text>
      </div>
      <Dialog open={open} onChangeOpen={changeOpen}>
        <DialogTrigger asChild>
          <Button size='sm' variant='secondary' appearance='ghost'>Edit</Button>
        </DialogTrigger>
        <DialogContent aria-labelledby={titleId}>
          <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--spacing-8)' }}>
            <Heading id={titleId} as='h2' size={4} fontWeight={600}>Note for Maria Chen</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>
          <div style={{ display: 'grid', gap: 'var(--spacing-3)', marginBlockStart: 'var(--spacing-8)' }}>
            <Label htmlFor={fieldId}>Note</Label>
            <TextArea
              id={fieldId}
              rows={4}
              fullWidth
              value={draft}
              aria-describedby={confirming ? warningId : undefined}
              onChange={(event) => {
                setDraft(event.currentTarget.value);
                setConfirming(false);
              }}
            />
            {confirming && (
              <HelperText id={warningId} role='status'>You have unsaved changes. Close again to discard them.</HelperText>
            )}
          </div>
          <div style={{ display: 'flex', justifyContent: 'flex-end', gap: 'var(--spacing-4)', marginBlockStart: 'var(--spacing-12)' }}>
            <DialogClose asChild>
              <Button variant='secondary' appearance='ghost'>{confirming ? 'Discard' : 'Cancel'}</Button>
            </DialogClose>
            <Button disabled={!dirty} onClick={save}>Save</Button>
          </div>
        </DialogContent>
      </Dialog>
    </div>
  );
}

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

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

<form method="dialog"> с полями required и pattern: браузер не пропускает невалидную отправку, а валидная вызывает onSubmit (значения читаются через FormData) и затем закрывает диалог. «Отмена» — это DialogClose вокруг кнопки с type="button", поэтому она не отправляет форму.

form-in-modal.tsx · 100 строк
import { useId, useState, type CSSProperties, type FormEvent } from 'react';
import {
  Button,
  Dialog,
  DialogClose,
  DialogContent,
  DialogTrigger,
  Heading,
  HelperText,
  Icon,
  IconButton,
  Label,
  Text,
  TextField,
  TextFieldInput,
} from '@vega-ui/react';
import { XIcon } from '@vega-ui/icons';

export default function FormInModal() {
  const titleId = useId();
  const nameId = useId();
  const slugId = useId();
  const slugHintId = useId();
  const [project, setProject] = useState({ name: 'Mobile banking app', slug: 'mobile-banking' });

  const onSubmit = (event: FormEvent<HTMLFormElement>) => {
    const data = new FormData(event.currentTarget);
    setProject({ name: String(data.get('name')), slug: String(data.get('slug')) });
  };

  return (
    <div
      style={{
        display: 'flex',
        alignItems: 'center',
        justifyContent: 'space-between',
        gap: 'var(--spacing-6)',
        boxSizing: 'border-box',
        width: '100%',
        padding: 'var(--spacing-8)',
        background: 'var(--surface-primary)',
        border: '1px solid var(--separator-opaque)',
        borderRadius: 'var(--radius-5)',
      }}
    >
      <div style={{ display: 'grid', minWidth: 0 }}>
        <Text size={3} fontWeight={500} style={{ overflowWrap: 'anywhere' }}>{project.name}</Text>
        <Text size={2} style={{ color: 'var(--label-secondary)', overflowWrap: 'anywhere' }}>vega.app/{project.slug}</Text>
      </div>
      <Dialog>
        <DialogTrigger asChild>
          <Button size='sm' variant='secondary' appearance='ghost'>Rename</Button>
        </DialogTrigger>
        <DialogContent aria-labelledby={titleId}>
          <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--spacing-8)' }}>
            <Heading id={titleId} as='h2' size={4} fontWeight={600}>Rename project</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={onSubmit} style={{ display: 'grid', gap: 'var(--spacing-8)', marginBlockStart: 'var(--spacing-8)' }}>
            <div style={{ display: 'grid', gap: 'var(--spacing-3)' }}>
              <Label htmlFor={nameId}>Project name</Label>
              <TextField>
                <TextFieldInput id={nameId} name='name' defaultValue={project.name} required maxLength={60} autoFocus />
              </TextField>
            </div>
            <div style={{ display: 'grid', gap: 'var(--spacing-3)' }}>
              <Label htmlFor={slugId}>URL</Label>
              <TextField>
                <TextFieldInput
                  id={slugId}
                  name='slug'
                  defaultValue={project.slug}
                  required
                  pattern='[a-z0-9-]+'
                  aria-describedby={slugHintId}
                />
              </TextField>
              <HelperText id={slugHintId}>Lowercase letters, digits and hyphens.</HelperText>
            </div>
            <div style={{ display: 'flex', justifyContent: 'flex-end', gap: 'var(--spacing-4)', marginBlockStart: 'var(--spacing-4)' }}>
              <DialogClose asChild>
                <Button type='button' variant='secondary' appearance='ghost'>Cancel</Button>
              </DialogClose>
              <Button type='submit'>Save</Button>
            </div>
          </form>
        </DialogContent>
      </Dialog>
    </div>
  );
}

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

Раскладка/оверлей: ошибка серверной проверки

Если проверка происходит после отправки, используйте обычную форму и закрывайте диалог сами: onSubmit отменяет действие по умолчанию, ждёт проверку и либо показывает ошибку, связанную через aria-describedby и aria-invalid, либо ставит open в false. Отправьте предзаполненный адрес — он уже занят. Правка поля сбрасывает ошибку.

server-validation-error.tsx · 118 строк
import { useId, useState, type CSSProperties, type FormEvent } from 'react';
import {
  Button,
  Dialog,
  DialogClose,
  DialogContent,
  DialogTrigger,
  Heading,
  HelperText,
  Icon,
  IconButton,
  Label,
  Text,
  TextField,
  TextFieldInput,
} from '@vega-ui/react';
import { XIcon } from '@vega-ui/icons';

const takenEmails = ['ada@northwind.dev', 'grace@northwind.dev'];

const checkEmail = (email: string) => new Promise<boolean>((resolve) => {
  setTimeout(() => resolve(!takenEmails.includes(email.toLowerCase())), 600);
});

export default function ServerValidationError() {
  const titleId = useId();
  const emailId = useId();
  const errorId = useId();
  const [open, setOpen] = useState(false);
  const [email, setEmail] = useState('linus@northwind.dev');
  const [error, setError] = useState('');
  const [pending, setPending] = useState(false);

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

  const onSubmit = async (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    const next = String(new FormData(event.currentTarget).get('email'));

    setPending(true);
    const available = await checkEmail(next);
    setPending(false);

    if (!available) {
      setError(`${next} already belongs to another account.`);
      return;
    }
    setEmail(next);
    setOpen(false);
  };

  return (
    <div
      style={{
        display: 'flex',
        alignItems: 'center',
        justifyContent: 'space-between',
        gap: 'var(--spacing-6)',
        boxSizing: 'border-box',
        width: '100%',
        padding: 'var(--spacing-8)',
        background: 'var(--surface-primary)',
        border: '1px solid var(--separator-opaque)',
        borderRadius: 'var(--radius-5)',
      }}
    >
      <div style={{ display: 'grid', minWidth: 0 }}>
        <Text size={2} style={{ color: 'var(--label-secondary)' }}>Sign-in email</Text>
        <Text size={3} fontWeight={500} style={{ overflowWrap: 'anywhere' }}>{email}</Text>
      </div>
      <Dialog open={open} onChangeOpen={changeOpen}>
        <DialogTrigger asChild>
          <Button size='sm' variant='secondary' appearance='ghost'>Change</Button>
        </DialogTrigger>
        <DialogContent aria-labelledby={titleId}>
          <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--spacing-8)' }}>
            <Heading id={titleId} as='h2' size={4} fontWeight={600}>Change sign-in email</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 onSubmit={onSubmit} style={{ display: 'grid', gap: 'var(--spacing-3)', marginBlockStart: 'var(--spacing-8)' }}>
            <Label htmlFor={emailId}>New email</Label>
            <TextField error={error !== ''}>
              <TextFieldInput
                id={emailId}
                name='email'
                type='email'
                defaultValue='grace@northwind.dev'
                required
                aria-invalid={error !== ''}
                aria-describedby={error ? errorId : undefined}
                onChange={() => setError('')}
              />
            </TextField>
            {error && <HelperText id={errorId} error>{error}</HelperText>}
            <div style={{ display: 'flex', justifyContent: 'flex-end', gap: 'var(--spacing-4)', marginBlockStart: 'var(--spacing-9)' }}>
              <DialogClose asChild>
                <Button type='button' variant='secondary' appearance='ghost'>Cancel</Button>
              </DialogClose>
              <Button type='submit' disabled={pending} style={{ minWidth: '11ch' }}>{pending ? 'Checking…' : 'Save email'}</Button>
            </div>
          </form>
        </DialogContent>
      </Dialog>
    </div>
  );
}

Ошибка

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

Кнопка «Удалить» отключена, пока поле не совпадёт с именем рабочего пространства, поэтому ни клик, ни Enter в поле не подтвердят действие случайно. «Отмена» не является кнопкой отправки, так что Enter её тоже не нажмёт.

type-to-confirm.tsx · 77 строк
import { useId, useState, type CSSProperties } from 'react';
import {
  Button,
  Dialog,
  DialogClose,
  DialogContent,
  DialogTrigger,
  Heading,
  Icon,
  IconButton,
  Label,
  Text,
  TextField,
  TextFieldInput,
} from '@vega-ui/react';
import { XIcon } from '@vega-ui/icons';

const workspace = 'northwind-studio';

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;

export default function TypeToConfirm() {
  const titleId = useId();
  const descriptionId = useId();
  const fieldId = useId();
  const [typed, setTyped] = useState('');

  const confirmed = typed === workspace;

  return (
    <Dialog onChangeOpen={() => setTyped('')}>
      <DialogTrigger asChild>
        <Button variant='secondary' appearance='ghost'>Delete workspace</Button>
      </DialogTrigger>
      <DialogContent aria-labelledby={titleId} aria-describedby={descriptionId}>
        <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--spacing-8)' }}>
          <Heading id={titleId} as='h2' size={4} fontWeight={600}>Delete {workspace}?</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 id={descriptionId} size={3} style={{ display: 'block', marginBlockStart: 'var(--spacing-4)', color: 'var(--label-secondary)' }}>
          This permanently deletes 14 projects, 312 files and the billing history. Members lose access immediately.
        </Text>
        <form method='dialog' style={{ display: 'grid', gap: 'var(--spacing-3)', marginBlockStart: 'var(--spacing-10)' }}>
          <Label htmlFor={fieldId}>Type <b>{workspace}</b> to confirm</Label>
          <TextField>
            <TextFieldInput
              id={fieldId}
              value={typed}
              autoComplete='off'
              spellCheck={false}
              onChange={(event) => setTyped(event.currentTarget.value)}
            />
          </TextField>
          <div style={{ display: 'flex', justifyContent: 'flex-end', gap: 'var(--spacing-4)', marginBlockStart: 'var(--spacing-9)' }}>
            <DialogClose asChild>
              <Button type='button' variant='secondary' appearance='ghost'>Cancel</Button>
            </DialogClose>
            <Button type='submit' value='delete' disabled={!confirmed} style={confirmed ? destructive : undefined}>Delete workspace</Button>
          </div>
        </form>
      </DialogContent>
    </Dialog>
  );
}

Отключённый

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

Прокручивается только список: у него max-height, overflow: auto и overscroll-behavior: contain, а заголовок и кнопка «Готово» остаются на месте. Прокручиваемый список фокусируемый и имеет имя, поэтому его можно прокрутить и с клавиатуры.

scrollable-content-dialog.tsx · 76 строк
import { useId, type CSSProperties } from 'react';
import {
  Button,
  Dialog,
  DialogClose,
  DialogContent,
  DialogTrigger,
  Heading,
  Icon,
  IconButton,
  Text,
} from '@vega-ui/react';
import { XIcon } from '@vega-ui/icons';

const people = ['Ada Lovelace', 'Grace Hopper', 'Linus Torvalds', 'Margaret Hamilton', 'Alan Kay'];
const actions = ['changed the plan to Pro', 'invited a member', 'exported invoices', 'rotated the API key', 'updated billing details'];

const events = Array.from({ length: 40 }, (_, index) => {
  const minutes = 18 * 60 + 55 - index * 7;
  const time = `${Math.floor(minutes / 60)}:${String(minutes % 60).padStart(2, '0')}`;

  return { id: index, time, text: `${people[index % people.length]} ${actions[(index * 3) % actions.length]}` };
});

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

  return (
    <Dialog>
      <DialogTrigger asChild>
        <Button variant='secondary' appearance='ghost'>Activity log</Button>
      </DialogTrigger>
      <DialogContent aria-labelledby={titleId}>
        <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--spacing-8)' }}>
          <Heading id={titleId} as='h2' size={4} fontWeight={600}>Activity log</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>
        <ol
          tabIndex={0}
          aria-label='Events, newest first'
          style={{
            display: 'grid',
            gap: 'var(--spacing-6)',
            maxHeight: '50vh',
            overflow: 'auto',
            overscrollBehavior: 'contain',
            margin: 'var(--spacing-8) 0 0',
            paddingBlock: 'var(--spacing-6)',
            paddingInline: 0,
            borderBlock: '1px solid var(--separator-opaque)',
            listStyle: 'none',
          }}
        >
          {events.map((event) => (
            <li key={event.id} style={{ display: 'flex', alignItems: 'baseline', gap: 'var(--spacing-6)' }}>
              <Text size={2} style={{ flexShrink: 0, minWidth: '5ch', color: 'var(--label-secondary)', fontVariantNumeric: 'tabular-nums' }}>{event.time}</Text>
              <Text size={3}>{event.text}</Text>
            </li>
          ))}
        </ol>
        <form method='dialog' style={{ display: 'flex', justifyContent: 'flex-end', marginBlockStart: 'var(--spacing-12)' }}>
          <Button type='submit'>Done</Button>
        </form>
      </DialogContent>
    </Dialog>
  );
}

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

Пункт меню в одном обработчике закрывает поповер и открывает контролируемый диалог. К закрытию диалога пункта меню уже нет в DOM, поэтому onClose возвращает фокус на триггер меню.

opened-from-popover-menu.tsx · 88 строк
import { useId, useRef, useState, type CSSProperties } from 'react';
import {
  Button,
  Dialog,
  DialogClose,
  DialogContent,
  Heading,
  Icon,
  IconButton,
  Popover,
  PopoverContent,
  PopoverTrigger,
  Text,
} from '@vega-ui/react';
import { EllipsisIcon, XIcon } from '@vega-ui/icons';

export default function OpenedFromPopoverMenu() {
  const titleId = useId();
  const menuTrigger = useRef<HTMLButtonElement>(null);
  const [menuOpen, setMenuOpen] = useState(false);
  const [dialogOpen, setDialogOpen] = useState(false);
  const [archived, setArchived] = useState(false);

  const openArchiveDialog = () => {
    setMenuOpen(false);
    setDialogOpen(true);
  };

  return (
    <div
      style={{
        display: 'flex',
        alignItems: 'center',
        justifyContent: 'space-between',
        gap: 'var(--spacing-6)',
        boxSizing: 'border-box',
        width: '100%',
        padding: 'var(--spacing-8)',
        background: 'var(--surface-primary)',
        border: '1px solid var(--separator-opaque)',
        borderRadius: 'var(--radius-5)',
      }}
    >
      <div style={{ display: 'grid', minWidth: 0 }}>
        <Text size={3} fontWeight={500}>Website redesign</Text>
        <Text size={2} style={{ color: 'var(--label-secondary)' }}>{archived ? 'Archived · read-only' : '12 tasks · updated today'}</Text>
      </div>
      <Popover placement='bottom-end' open={menuOpen} onChangeOpen={setMenuOpen}>
        <PopoverTrigger asChild ref={menuTrigger}>
          <IconButton aria-label='Project actions' variant='secondary' appearance='ghost'>
            <Icon><EllipsisIcon /></Icon>
          </IconButton>
        </PopoverTrigger>
        <PopoverContent style={{ padding: 'var(--spacing-2)' }}>
          <div style={{ display: 'grid', minWidth: 160 }}>
            <Button appearance='transparent' variant='secondary' style={{ justifyContent: 'flex-start' }} onClick={() => setMenuOpen(false)}>Duplicate</Button>
            <Button appearance='transparent' variant='secondary' style={{ justifyContent: 'flex-start' }} onClick={openArchiveDialog} disabled={archived}>Archive…</Button>
          </div>
        </PopoverContent>
      </Popover>

      <Dialog open={dialogOpen} onChangeOpen={setDialogOpen}>
        <DialogContent aria-labelledby={titleId} onClose={() => menuTrigger.current?.focus()}>
          <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--spacing-8)' }}>
            <Heading id={titleId} as='h2' size={4} fontWeight={600}>Archive Website redesign?</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={{ display: 'block', marginBlockStart: 'var(--spacing-4)', color: 'var(--label-secondary)' }}>
            Archived projects are read-only and hidden from the sidebar.
          </Text>
          <form method='dialog' style={{ display: 'flex', justifyContent: 'flex-end', gap: 'var(--spacing-4)', marginBlockStart: 'var(--spacing-12)' }}>
            <Button type='submit' value='cancel' variant='secondary' appearance='ghost'>Cancel</Button>
            <Button type='submit' value='archive' onClick={() => setArchived(true)}>Archive</Button>
          </form>
        </DialogContent>
      </Dialog>
    </div>
  );
}

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

Пограничный случай: вложенный шаг подтверждения

Диалог участника открывает диалог подтверждения из своего содержимого. Escape закрывает только верхний; подтверждение закрывает оба, потому что обработчик ещё и ставит внешнему open значение false.

nested-confirm-step.tsx · 101 строка
import { useId, useState, type CSSProperties } from 'react';
import {
  Button,
  Dialog,
  DialogClose,
  DialogContent,
  DialogTrigger,
  Heading,
  Icon,
  IconButton,
  Text,
} from '@vega-ui/react';
import { XIcon } from '@vega-ui/icons';

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;

export default function NestedConfirmStep() {
  const memberTitleId = useId();
  const confirmTitleId = useId();
  const [memberOpen, setMemberOpen] = useState(false);
  const [removed, setRemoved] = useState(false);

  const remove = () => {
    setRemoved(true);
    setMemberOpen(false);
  };

  return (
    <div
      style={{
        display: 'flex',
        alignItems: 'center',
        justifyContent: 'space-between',
        gap: 'var(--spacing-6)',
        boxSizing: 'border-box',
        width: '100%',
        padding: 'var(--spacing-8)',
        background: 'var(--surface-primary)',
        border: '1px solid var(--separator-opaque)',
        borderRadius: 'var(--radius-5)',
      }}
    >
      <div style={{ display: 'grid', minWidth: 0 }}>
        <Text size={3} fontWeight={500}>Grace Hopper</Text>
        <Text size={2} role='status' style={{ color: 'var(--label-secondary)' }}>
          {removed ? 'Removed from the team' : 'Admin · grace@northwind.dev'}
        </Text>
      </div>
      <div style={{ display: 'flex', gap: 'var(--spacing-4)' }}>
        {removed && <Button size='sm' variant='secondary' appearance='ghost' onClick={() => setRemoved(false)}>Undo</Button>}
        <Dialog open={memberOpen} onChangeOpen={setMemberOpen}>
          <DialogTrigger asChild>
            <Button size='sm' variant='secondary' appearance='ghost'>Manage</Button>
          </DialogTrigger>
          <DialogContent aria-labelledby={memberTitleId}>
            <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--spacing-8)' }}>
              <Heading id={memberTitleId} as='h2' size={4} fontWeight={600}>Grace Hopper</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={{ display: 'block', marginBlockStart: 'var(--spacing-4)', color: 'var(--label-secondary)' }}>
              grace@northwind.dev · Admin since March 2024
            </Text>
            <div style={{ display: 'flex', justifyContent: 'space-between', gap: 'var(--spacing-4)', marginBlockStart: 'var(--spacing-12)' }}>
              <Dialog>
                <DialogTrigger asChild>
                  <Button variant='secondary' appearance='ghost'>Remove from team…</Button>
                </DialogTrigger>
                <DialogContent aria-labelledby={confirmTitleId}>
                  <Heading id={confirmTitleId} as='h2' size={4} fontWeight={600}>Remove Grace Hopper?</Heading>
                  <Text size={3} style={{ display: 'block', marginBlockStart: 'var(--spacing-4)', color: 'var(--label-secondary)' }}>
                    She loses access to all team projects. Her files stay in the workspace.
                  </Text>
                  <form method='dialog' style={{ display: 'flex', justifyContent: 'flex-end', gap: 'var(--spacing-4)', marginBlockStart: 'var(--spacing-12)' }}>
                    <Button type='submit' value='cancel' variant='secondary' appearance='ghost'>Keep</Button>
                    <Button type='submit' value='remove' style={destructive} onClick={remove}>Remove</Button>
                  </form>
                </DialogContent>
              </Dialog>
              <DialogClose asChild>
                <Button>Done</Button>
              </DialogClose>
            </div>
          </DialogContent>
        </Dialog>
      </div>
    </div>
  );
}

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

С closedBy='none' ни Escape, ни клик по подложке не закрывают диалог, поэтому кнопки закрытия тоже нет: выйти можно только одним из двух ответов. autoFocus ставит фокус на безопасный ответ, а выбранный returnValue показан под триггером.

blocking-dialog.tsx · 41 строка
import { useId, useState } from 'react';
import {
  Button,
  Dialog,
  DialogContent,
  DialogTrigger,
  Heading,
  Text,
} from '@vega-ui/react';

export default function BlockingDialog() {
  const titleId = useId();
  const descriptionId = useId();
  const [choice, setChoice] = useState('');

  return (
    <div style={{ display: 'grid', gap: 'var(--spacing-6)', justifyItems: 'center', width: '100%' }}>
      <Dialog>
        <DialogTrigger asChild>
          <Button variant='secondary' appearance='ghost'>Simulate session timeout</Button>
        </DialogTrigger>
        <DialogContent
          closedBy='none'
          aria-labelledby={titleId}
          aria-describedby={descriptionId}
          onClose={(event) => setChoice(event.currentTarget.returnValue)}
        >
          <Heading id={titleId} as='h2' size={4} fontWeight={600}>Your session is about to expire</Heading>
          <Text id={descriptionId} size={3} style={{ display: 'block', marginBlockStart: 'var(--spacing-4)', color: 'var(--label-secondary)' }}>
            You have been inactive for 25 minutes. Unsaved changes in the editor will be lost when you sign out.
          </Text>
          <form method='dialog' style={{ display: 'flex', justifyContent: 'flex-end', gap: 'var(--spacing-4)', marginBlockStart: 'var(--spacing-12)' }}>
            <Button type='submit' value='sign-out' variant='secondary' appearance='ghost'>Sign out</Button>
            <Button type='submit' value='stay' autoFocus>Stay signed in</Button>
          </form>
        </DialogContent>
      </Dialog>
      <Text size={2} style={{ color: 'var(--label-secondary)', minHeight: '1lh' }}>{choice ? `returnValue: ${choice}` : ''}</Text>
    </div>
  );
}

Анатомия

Обзор

Dialog — составной компонент. Корень отвечает за состояние открытия, управление фокусом, поведение закрытия и модальную координацию всех экспортируемых дочерних частей.

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

Dialog

Корневой провайдер состояния диалога.

  • Обязателен: да
  • Отвечает за: состояние открытия, закрытие, жизненный цикл фокуса
  • Должен оборачивать все остальные части Dialog*

DialogTrigger

Действие пользователя, открывающее диалог.

  • Обязателен: в обычных продуктовых сценариях фактически да
  • Типичное использование: asChild с Button
  • Должен оставаться внутри Dialog

DialogContent

Основная модальная поверхность.

  • Обязателен: да
  • Отвечает за: нативный модальный <dialog>, его ::backdrop, переходы открытия и закрытия
  • Рендерится в document.body, поэтому отдельная часть-портал не нужна

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

DialogClose

Закрывает диалог по клику.

  • Обязателен: нет
  • Рендерит <button type="button"> без стилей, иконки и метки; с asChild рендерит вашу собственную кнопку
  • Вызывает onChangeOpen(false) до вашего onClick, поэтому проигрывается переход закрытия, а контролирующий родитель может оставить диалог открытым
  • Дайте кнопке закрытия из одной иконки aria-label

Ваша собственная разметка

Библиотека не поставляет части для шапки и заголовка. У них нет поведения, которое нельзя выразить обычной разметкой, поэтому вы собираете их сами.

Заголовок

Задаёт имя диалога.

  • Обязателен: имя обязательно — либо заголовок, на который ссылается aria-labelledby на DialogContent, либо aria-label на DialogContent
  • Типичное использование: Heading с id

Кнопки действий

Кнопки, которые отвечают на диалог, например «Отмена» и «Подтвердить».

  • Поместите их в <form method="dialog">; её отправка закрывает диалог через onChangeOpen(false), а value кнопки отправки становится returnValue диалога
  • Для отдельной кнопки закрытия, например крестика в шапке, используйте DialogClose

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

Типичная композиция:

  1. Dialog
  2. DialogTrigger
  3. DialogContent
  4. Ваш заголовок, необязательный DialogClose, основное содержимое и <form method="dialog"> с кнопками действий

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

minimal-composition.tsx · 53 строки
import { useId, type CSSProperties } from 'react';
import {
  Button,
  Dialog,
  DialogClose,
  DialogContent,
  DialogTrigger,
  Heading,
  Icon,
  IconButton,
  Text,
} from '@vega-ui/react';
import { XIcon } from '@vega-ui/icons';

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;

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

  return (
    <Dialog>
      <DialogTrigger asChild>
        <Button variant='secondary' appearance='ghost'>Leave workspace</Button>
      </DialogTrigger>
      <DialogContent aria-labelledby={titleId}>
        <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--spacing-8)' }}>
          <Heading id={titleId} as='h2' size={4} fontWeight={600}>Leave Northwind Studio?</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={{ display: 'block', marginBlockStart: 'var(--spacing-4)', color: 'var(--label-secondary)' }}>
          You will lose access to 14 projects until an admin invites you again.
        </Text>
        <form method='dialog' style={{ display: 'flex', justifyContent: 'flex-end', gap: 'var(--spacing-4)', marginBlockStart: 'var(--spacing-12)' }}>
          <Button type='submit' value='cancel' variant='secondary' appearance='ghost'>Stay</Button>
          <Button type='submit' value='leave' style={destructive}>Leave workspace</Button>
        </form>
      </DialogContent>
    </Dialog>
  );
}

Это надёжная композиция по умолчанию для модальных сценариев в продакшене.

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

DialogContent вне Dialog

Дочерние части зависят от контекста корня. Если рендерить их вне Dialog, поведение ломается.

Содержимое без имени

DialogContent не задаёт себе имя сам. Без aria-labelledby, указывающего на ваш заголовок, или без aria-label скринридеры объявят безымянный диалог.

Вложенные диалоги без повторной проверки фокуса

Вложенные стеки оверлеев допустимы, но их нужно рассматривать как пограничный случай интеграции, а не считать, что они работают, только потому что компонент прошёл изолированное тестирование.