Перейти к содержимому
Плотность
Тема

Оверлеи

Модалка

JS Открытие — одна строка: dlg.showModal(). Закрытие, подложка, блокировка прокрутки фона и возврат фокуса скрипта не требуют

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

Компонент
Удалить прогон #4127?

Будут удалены лог, артефакты и диф. Ссылки на прогон в отчётах перестанут открываться.

Необратимо: восстановление из резервной копии занимает до суток.
Escape тоже закрывает
Разметка
<button class="inst-btn inst-btn--danger" type="button"
        onclick="document.getElementById('dlg-confirm').showModal()">Удалить прогон</button>

<dialog class="inst-dialog" id="dlg-confirm">
  <form method="dialog">
    <div class="inst-dialog-head">
      <span class="inst-dialog-title">Удалить прогон #4127?</span>
      <button class="inst-btn inst-btn--sm inst-btn--ghost inst-dialog-close" type="submit" aria-label="Закрыть">✕</button>
    </div>
    <div class="inst-dialog-body inst-stack">
      <p>Будут удалены лог, артефакты и диф. Ссылки на прогон в отчётах перестанут открываться.</p>
      <div class="inst-note" data-tone="warn">Необратимо: восстановление из резервной копии занимает до суток.</div>
    </div>
    <div class="inst-dialog-foot">
      <span class="inst-dialog-foot-note">Escape тоже закрывает</span>
      <button class="inst-btn" type="submit">Отмена</button>
      <button class="inst-btn inst-btn--danger" type="submit">Удалить</button>
    </div>
  </form>
</dialog>

Использование

Всё содержимое обёрнуто в <form method="dialog">. Открытие — одна строка скрипта; закрытие, подложка и возврат фокуса скрипта не требуют.

<dialog class="inst-dialog" id="dlg">
  <form method="dialog">
    <div class="inst-dialog-head">
      <span class="inst-dialog-title">Заголовок</span>
      <button class="inst-btn inst-btn--sm inst-btn--ghost inst-dialog-close" type="submit" aria-label="Закрыть">✕</button>
    </div>
    <div class="inst-dialog-body">…</div>
    <div class="inst-dialog-foot inst-dialog-foot--end">
      <button class="inst-btn" type="submit">Закрыть</button>
    </div>
  </form>
</dialog>

onclick в примере выше — сокращение ради одного файла. В приложении обработчик вешается кодом; кит на способ его повесить не смотрит. Декларативные command и commandfor пока прогрессивны и в контракт кита не берутся.

Когда использовать

Используйте Возьмите другое
Подтверждение разрушительного действия Сообщение без вопросабаннер уровня страницы: модалка требует нажатия там, где хватило бы прочтения
Короткая форма, ради которой не стоит уходить со страницы Длинная форма или подробности, которые держат открытымишторка: она не отрывает от контекста
Выбор, без которого дальнейшая работа бессмысленна Список действий над объектомменю в поповере
Согласование, требующее явного «да» Согласование в потоке очереди агентовблок согласования

Устройство

Часть Работа
inst-dialog-head Шапка: заголовок и крестик. Отделена волоском
inst-dialog-title Заголовок, --text-md
inst-dialog-close Крестик. Прижимается к дальнему краю шапки
inst-dialog-body Тело. Единственная прокручиваемая часть
inst-dialog-foot Подвал с действиями
inst-dialog-foot--end Действия у дальнего края
inst-dialog-foot-note Пояснение у ближнего края, отжимающее кнопки к дальнему

Шапка и подвал не сжимаются (flex: none), прокручивается только тело. Из-за этого заголовок и кнопки остаются на месте на любой длине содержимого, а модалка не вырастает выше 80dvh.

Свойство Значение
ширина min(34rem, 100vw - var(--space-8))
максимальная высота min(80dvh, 100dvh - var(--space-8))

Раскладка задаётся только открытому: .inst-dialog[open] { display: flex }. Безусловный display: flex показал бы модалку всегда — у <dialog> закрытое состояние это display: none.

Модалка — второй и последний носитель тени в ките. Первый — поповер.

Поведение

Закрытие без скрипта

Любая кнопка type="submit" внутри <form method="dialog"> закрывает модалку и передаёт своё значение в dialog.returnValueобработчик клика не нужен ни на одной из них, включая крестик и «Отмена».

Способ закрытия Что нужно
Кнопка внутри <form method="dialog"> type="submit"
Escape Ничего. Платформа
Клик по подложке closedby="any" на <dialog>

Блокировка прокрутки фона

showModal() делает фон инертным для кликов, но не останавливает прокрутку: колесо продолжает крутить страницу, и пользователь теряет место, к которому вернётся. Кит закрывает это одним правилом:

html:has(dialog:modal) { overflow: hidden; }

Селектор :modal, а не [open], и разница здесь принципиальная. Атрибут open стоит и на немодальном show(), у которого вся работа в том, чтобы не блокировать страницу. Пока правило смотрело на атрибут, немодальная панель замораживала прокрутку всего документа — и делала это с любым <dialog> на странице, даже без единого класса кита.

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

Плата известна: если прокручивается сам документ, в момент открытия исчезает полоса прокрутки и содержимое сдвигается вбок на её ширину. scrollbar-gutter здесь не спасает — резерв места действует при overflow: auto/scroll, а тут hidden.

/* Если у вас прокручивается именно документ */
html { scrollbar-gutter: stable; }

В приложении на оболочке документ не прокручивается вообще, поэтому сдвига нет и платить не за что. Кит не ставит scrollbar-gutter сам: на нескроллящемся экране это была бы пустая полоса в 10px у правого края.

JS

Открытие — одна строка. Всё остальное берёт на себя платформа.

document.getElementById('dlg').showModal();
Что Кто делает
Открытие Приложение: showModal()
Закрытие <form method="dialog"> внутри — без скрипта
Подложка, верхний слой, Escape Платформа
Возврат фокуса на открывшую кнопку Платформа
Блокировка прокрутки фона Кит, через ::backdrop

Декларативные command и commandfor избавили бы и от этой строки, но пока поддержаны не везде и в контракт кита не берутся.

Правила

Так Нативный dialog и showModal

Модальность, ловушка фокуса, Escape и возврат фокуса — от платформы. Ни одна из этих четырёх вещей не пишется руками.

Не так div с z-index

div поверх подложки не даёт ни ловушки фокуса, ни инертного фона, ни Escape. Он выглядит модалкой и не является ею.

Так Разрушительное действие подписано глаголом

«Удалить», а не «ОК». Кнопка в подвале — последнее, что читают перед необратимым.

Не так Модалка ради сообщения

Если вопроса нет, нужен баннер: модалка требует нажатия там, где хватило бы прочтения.

Доступность

Фокус showModal() уводит фокус внутрь, держит его там и возвращает на вызвавшую кнопку при закрытии. Ничего из этого писать не надо
Клавиатура Escape закрывает. Tab не выходит за пределы модалки
Фон инертен Содержимое под подложкой недоступно ни мыши, ни скринридеру — это делает платформа
Прокрутка фона Останавливается правилом html:has(dialog:modal). Иначе пользователь теряет место
Заголовок inst-dialog-title оформляет, но не объявляет. Если модалке нужно доступное имя, свяжите её aria-labelledby с заголовком
Подложка --scrim — 0.32 в светлой теме и 0.58 в тёмной: фон читается как выключенный, но не исчезает
Печать Модалка на листе не печатается
Обязательная разметка Нативный <dialog>, showModal() вместо show(), <form method="dialog"> внутри, type="submit" у закрывающих кнопок, aria-label у крестика

API

ИмяЗначениеЧто делает
класс
inst-dialogБазовый. Ставится на <dialog>
inst-dialog-headШапка
inst-dialog-titleЗаголовок
inst-dialog-closeКрестик у дальнего края шапки
inst-dialog-bodyПрокручиваемое тело
inst-dialog-footПодвал с действиями
inst-dialog-foot-noteПояснение, отжимающее кнопки к дальнему краю
inst-sheetДругая раскладка того же <dialog> — [шторка](./sheet.md)
модификатор
inst-dialog-foot--endДействия у дальнего края
токен
--surface-overlaylight-dark(var(--n-0), var(--n-11))
--shadow-modal0 16px 40px -8px var(--shadow-color-far), 0 4px 10px -4px var(--shadow-color-near)
--scrimlight-dark(oklch(0 0 0 / 0.32), oklch(0 0 0 / 0.58))
--borderlight-dark(oklch(0 0 0 / 0.12), oklch(1 0 0 / 0.11))
--border-subtlelight-dark(oklch(0 0 0 / 0.07), oklch(1 0 0 / 0.06))
--hairline1px
--radius-lg11px
--pad-cardvar(--space-6)
--space-36px
--space-512px
--space-832px
--gap-inlinevar(--space-4)
--text-md0.9375rem
--text-xs0.75rem
--weight-medium500
--text-mutedlight-dark(var(--n-8), var(--n-6))

Почему кит устроен именно так — конституция · Открытый код под MIT, github.com/keshon/instrument