Оверлеи
Модалка
Окно, которое забирает фокус и гасит фон, потому что без ответа продолжать
нельзя. Стилизуется нативный <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>
<span class="inst-dialog-sub">Артефакты уйдут вместе с ним</span>
<button class="inst-btn inst-btn--sm inst-btn--ghost inst-btn--icon inst-dialog-close" type="submit" aria-label="Закрыть">
<svg class="inst-icon" aria-hidden="true"><use href="#i-close"/></svg>
</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">. Открытие — одна строка
скрипта; закрытие, подложка и возврат фокуса скрипта не требуют.
onclick в примере выше — сокращение ради одного файла. В приложении
обработчик вешается кодом; способ его повесить библиотеке безразличен.
Декларативные command и commandfor пока прогрессивны и в контракт библиотеки не
берутся.
Доступность
| Фокус | 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 у крестика |
Устройство
| Часть | Работа |
|---|---|
inst-dialog-head |
Шапка: заголовок и крестик. Отделена волоском |
inst-dialog-title |
Заголовок, --text-md |
inst-dialog-close |
Крестик. Прижимается к дальнему краю шапки |
inst-dialog-body |
Тело. Единственная прокручиваемая часть |
inst-dialog-foot |
Подвал с действиями |
inst-dialog-foot--start |
Действия у ближнего края. Умолчание — дальний |
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 |
Платформа |
| Возврат фокуса на открывшую кнопку | Платформа |
| Блокировка прокрутки фона | Библиотека, правилом html:has(dialog:modal) |
Декларативные command и commandfor избавили бы и от этой строки, но пока
поддержаны не везде и в контракт библиотеки не берутся.
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-sub | Подпись под заголовком: чего диалог хочет, если из заголовка это не следует целиком |
| модификатор | |
inst-dialog-foot--start | Действия у ближнего края. Умолчание — дальний |
inst-dialog--scroll | Тело прокручивается: возвращает линейки под шапкой и над подвалом |
| Имя | Значение | Откуда |
|---|---|---|
| токен | ||
--surface-overlay | light-dark(var(--n-0), var(--n-11)) | база +1: тема |
--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 | база +1: экран |
--radius-lg | 12px | база +4: масштаб |
--pad-card | var(--space-6) | база +14: плотность · масштаб |
--space-3 | 6px | одно на все ячейки |
--space-5 | 12px | одно на все ячейки |
--space-8 | 32px | одно на все ячейки |
--gap-inline | var(--space-4) | база +14: плотность · масштаб |
--text-md | 1rem | база +4: масштаб |
--text-xs | 0.78125rem | база +4: масштаб |
--weight-medium | 600 | одно на все ячейки |
--text-muted | light-dark(var(--n-8), var(--n-6)) | одно на все ячейки |
Почему кит устроен именно так — конституция · Открытый код под MIT, github.com/keshon/instrument