Оверлеи
Модалка
Окно, которое забирает фокус и гасит фон, потому что без ответа продолжать
нельзя. Стилизуется нативный <dialog>, поэтому модальность настоящая, а не
нарисованная.
Разметка
<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-overlay | light-dark(var(--n-0), var(--n-11)) | |
--shadow-modal | 0 16px 40px -8px var(--shadow-color-far), 0 4px 10px -4px var(--shadow-color-near) | |
--scrim | light-dark(oklch(0 0 0 / 0.32), oklch(0 0 0 / 0.58)) | |
--border | light-dark(oklch(0 0 0 / 0.12), oklch(1 0 0 / 0.11)) | |
--border-subtle | light-dark(oklch(0 0 0 / 0.07), oklch(1 0 0 / 0.06)) | |
--hairline | 1px | |
--radius-lg | 11px | |
--pad-card | var(--space-6) | |
--space-3 | 6px | |
--space-5 | 12px | |
--space-8 | 32px | |
--gap-inline | var(--space-4) | |
--text-md | 0.9375rem | |
--text-xs | 0.75rem | |
--weight-medium | 500 | |
--text-muted | light-dark(var(--n-8), var(--n-6)) | |
Почему кит устроен именно так — конституция · Открытый код под MIT, github.com/keshon/instrument