Слои поверх страницы

Выпадающая панель, меню, модальное окно, выдвижная панель и подсказка. Верхний слой, фокус, Esc и закрытие по клику мимо — всё от платформы.

griffincss-ui _dropdown.scss _menu.scss _modal.scss _drawer.scss _tooltip.scss

Ни один из пяти модулей не требует библиотечного JS. <details> раскрывает панель, <dialog> держит фокус внутри окна и закрывается по Esc, Popover API закрывает панель по клику мимо, а подсказка появляется по :hover и :focus-within чистым CSS. Скрипт нужен ровно на одно: вызвать showModal() — это одна строка в разметке.

Единственное исключение — закрытие окна щелчком по подложке: этого <dialog> не умеет, и его добавляет опциональный griffincss-ui.js. Он подключён на этой странице, включается атрибутом на самом окне и стоит 2,9 КБ gzip — вместе со всем остальным поведением компонентов.

Выпадающая панель и меню

КлассНазначение
.gr-dropdownОбёртка: <div> с попапом или сам <details>
.gr-dropdown-panelПанель; внутрь кладут .gr-menu или что угодно ещё
.gr-dropdown-end / -upРаскрытие к концу строки и вверх
.gr-dropdown-hoverРаскрытие по наведению; с клавиатуры и на сенсорном экране — по фокусу
.gr-menuСписок действий: поверхность, рамка, тень
.gr-menu-itemПункт — <button> или <a>
.gr-menu-header / .gr-menu-sepЗаголовок группы и разделитель на <hr>
.gr-menu-dangerОпасное действие
.gr-menu-icon / .gr-menu-shortcutСлоты под иконку и сочетание клавиш
На <details> — работает везде
Действия
  • Документ

К концу строки
<details class="gr-dropdown">
  <summary class="gr-btn">Действия</summary>
  <div class="gr-dropdown-panel">
    <ul class="gr-menu">
      <li><button class="gr-menu-item" type="button">Переименовать</button></li>
      <li><hr class="gr-menu-sep"></li>
      <li><button class="gr-menu-item gr-menu-danger" type="button">Удалить</button></li>
    </ul>
  </div>
</details>
На Popover API — закрывается по Esc и по клику мимо
<div class="gr-dropdown">
  <button class="gr-btn" type="button" popovertarget="m">Экспорт</button>
  <div class="gr-dropdown-panel" id="m" popover>
    <ul class="gr-menu">…</ul>
  </div>
</div>
По наведению — .gr-dropdown-hover
<div class="gr-dropdown gr-dropdown-hover">
  <button class="gr-btn gr-btn-ghost" type="button" aria-haspopup="true">Продукты</button>
  <div class="gr-dropdown-panel">
    <ul class="gr-menu">…</ul>
  </div>
</div>

Панель держится, пока указатель на обёртке, и уходит с четвертьсекундной задержкой — соскользнувший мимо курсор не захлопывает меню мгновенно. Зазор между кнопкой и панелью перекрыт невидимым мостиком, иначе наведение прерывалось бы на полпути. Ни одной строки JS: показ — :hover, а на сенсорном экране и с клавиатуры — :focus-within, поэтому кнопка обязана быть <button>.

Наведение годится для навигации, а не для действий. Состояние такого меню разметкой не сообщается: aria-expanded без скрипта не обновить, поэтому кнопке ставят aria-haspopup="true" и не ставят aria-expanded — промолчать лучше, чем солгать об открытости. Закрытия по Esc платформа здесь тоже не даёт: наведение для браузера не «открытое состояние». Меню с «Удалить» и «Выйти» делают на <details> или на попапе, где состояние настоящее.

Два пути, и оба чего-то стоят. <details> работает в любом браузере, но не знает ни Esc, ни клика мимо — закрывать панель придётся тем же нажатием на заголовок. Панель с атрибутом popover получает и то, и другое от браузера даром, но уходит в верхний слой, где положение считается от окна, а не от обёртки: привязать её к кнопке умеет только CSS anchor positioning. Там, где его нет, панель раскрывается листом у нижнего края экрана — положение не то, зато меню доступно и закрывается штатно. Ни один из путей не оставляет пользователя с неработающей кнопкой.

Модальное окно

КлассНазначение
.gr-modalОкно на <dialog>
.gr-modal-header / -titleШапка и заголовок, на который указывает aria-labelledby
.gr-modal-bodyТело; прокручивается только оно
.gr-modal-footerПодвал с кнопками
.gr-modal-sm / -lg / -fullРазмеры
[data-gr-state="loading"]Состояние загрузки на самом <dialog>: кольцо по центру, пока содержимое не пришло. Рядом уместен aria-busy="true"
Окно с длинным телом: прокручивается тело, шапка и подвал стоят

Условия использования

Библиотека распространяется по лицензии MIT. Это значит, что её можно использовать в коммерческих проектах, изменять и распространять — при сохранении уведомления об авторских правах.

Компоненты собраны на токенах ядра: ни одного литерального цвета и ни одной литеральной высоты. Поэтому тёмная тема и режим для слабовидящих доходят до них тем же механизмом, что и до остальной страницы.

Открытое окно удерживает фокус внутри себя, закрывается по Esc и возвращает фокус на кнопку, которая его вызвала. Ни одна из этих трёх вещей не написана в библиотеке — всё делает <dialog>.

Страница под окном становится инертной: щелчок мимо не попадает по ссылкам, а Tab не уводит в разметку за подложкой.

Подложка красится токеном --gr-overlay. Он одинаково тёмен в обеих темах, потому что подложка затемняет страницу, а не перекрашивает её.

Удалить черновик?

Действие необратимо: восстановить черновик после удаления нельзя.

<button type="button" onclick="m.showModal()">Открыть</button>

<dialog class="gr-modal" id="m" aria-labelledby="m-title">
  <div class="gr-modal-header">
    <h2 class="gr-modal-title" id="m-title">Заголовок</h2>
  </div>
  <div class="gr-modal-body">…</div>
  <div class="gr-modal-footer">
    <form method="dialog"><button class="gr-btn">Закрыть</button></form>
  </div>
</dialog>

Пустое окно: содержимое приходит позже

Типичный сценарий витрины: окно открывается сразу, а форму обратного звонка, быстрый заказ или выбор города в него кладёт ответ сервера. До ответа <dialog> пуст — и раньше рисовался полоской высотой в рамку, которая через полсекунды рывком раскрывалась в окно. Теперь у окна минимальная высота 10rem: пустое окно — плашка, а состояние data-gr-state="loading" рисует в её центре кольцо тем же keyframe, что у .gr-spinner. Ставит и снимает состояние тот же скрипт, что грузит содержимое; программе чтения с экрана о том же говорит aria-busy="true".

const m = document.getElementById('callback');
m.dataset.grState = 'loading';
m.setAttribute('aria-busy', 'true');
m.showModal();

const html = await (await fetch('/callback-form')).text();
m.innerHTML = html;
delete m.dataset.grState;
m.removeAttribute('aria-busy');
Пустое окно в состоянии загрузки — и то же окно с содержимым через две секунды

Плавный рост высоты с приходом содержимого — не работа CSS: высота окна auto и до, и после, а переход между двумя auto не анимируется. Это делает слой griffinjs.js по атрибуту data-gr-dialog="grow" на окне — см. подраздел «С подключённым griffinjs.js».

Закрытие щелчком по подложке

Единственное, чего <dialog> не умеет сам. Включается атрибутом на самом окне — не глобальной настройкой:

<dialog class="gr-modal" data-gr-overlay-close>…</dialog>

<script src="griffincss-ui/dist/griffincss-ui.js"></script>
ЧтоКак
data-gr-overlay-closeАтрибут на <dialog>: окно или панель закрывается щелчком мимо
dialog.returnValueПосле такого закрытия равно "overlay" — обработчик отличит щелчок мимо от кнопки «Сохранить»
Griffincss.ui.start() / .destroy()Ручной запуск и снятие обработчиков; автостарт отключается data-auto="false" на теге <script>

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

Атрибут — решение автора, а не умолчание. У окна с наполовину заполненной формой случайный щелчок мимо стоит потерянных данных, поэтому поведение включается там, где оно уместно: у просмотрщика картинки, у меню на телефоне, у окна с одной кнопкой «Понятно».

Выделение текста, начатое внутри окна и законченное за его краем, окном не считается щелчком по подложке — рантайм сверяет обе половины щелчка. Не закрывают окно и клавиатурный Enter на кнопке, и щелчок по неотрисованному окну.

Закрытие вообще без скрипта. Кнопка внутри <form method="dialog"> закрывает окно сама — это поведение формы, а не обработчик. Именно так закрываются оба окна на этой странице: ни одной строки JS на закрытие не написано, скрипт вызывает только showModal().

Выдвижная панель

КлассНазначение
.gr-drawerТот же <dialog>, прижатый к краю экрана
.gr-drawer-start / -endК началу и к концу строки
.gr-drawer-top / -bottomСверху и снизу; высота по содержимому, но не больше половины экрана
.gr-drawer-sm / -lgШирина: 15rem / 28rem
Четыре стороны. Пояса — те же, что у модалки
Разделы
Фильтры

Панель конца строки: в разметке справа налево она выедет с левого края — стороны заданы логическими свойствами.

Верхняя панель: высота по содержимому, но не больше половины экрана.

Нижняя панель — привычный на телефоне лист действий.

<dialog class="gr-drawer gr-drawer-end" aria-label="Фильтры">
  <div class="gr-modal-header">…</div>
  <div class="gr-modal-body">…</div>
</dialog>

Пояса панель не заводит свои: .gr-modal-header, -body и -footer работают и здесь. Дублировать их незачем — от модалки панель отличается положением и размерами, а не устройством.

Подсказка

КлассНазначение
.gr-tooltip-anchorОбёртка вокруг элемента и подсказки
.gr-tooltipСама подсказка; по умолчанию сверху
.gr-tooltip-bottom / -start / -endДругая сторона
Наведите указатель или дойдите до кнопки клавишей Tab
Ctrl + S Подсказка под элементом К концу строки
<span class="gr-tooltip-anchor">
  <button class="gr-btn" type="button" aria-describedby="t1">Сохранить</button>
  <span class="gr-tooltip" id="t1" role="tooltip">Ctrl + S</span>
</span>

aria-describedby обязателен. Без него подсказку получит только тот, кто её видит: программе чтения с экрана достанется кнопка «Сохранить» без единого слова о сочетании клавиш. И прячется подсказка не display: none, а прозрачностью с visibility: убранная через display, она исчезла бы из дерева доступности вместе со связью, на которую ссылается кнопка.

Примеры использования

Слои собираются из уже показанных частей: меню — из дропдауна и аватара, корзина — из панели и медиа-объектов. Оба рецепта живые.

Меню пользователя в шапке

По клику на аватар — панель к концу строки: шапка-группа с именем и почтой (.gr-menu-header двумя строками), пункты, разделитель и «Выйти» в .gr-menu-danger. Всё на <details> — здесь есть опасное действие, поэтому раскрытие по наведению не годится: нужно настоящее состояние.

<details class="gr-dropdown gr-dropdown-end">
  <summary class="gr-btn gr-btn-ghost gr-btn-icon" aria-label="Меню пользователя">
    <span class="gr-avatar gr-avatar-sm">АК</span>
  </summary>
  <div class="gr-dropdown-panel">
    <ul class="gr-menu">
      <li><div class="gr-menu-header">Анна Ковалёва<br>
        <span class="gr-text-xs gr-text-ink-secondary gr-font-normal">anna@example.ru</span></div></li>
      <li><a class="gr-menu-item" href="/profile">Профиль</a></li>
      <li><a class="gr-menu-item" href="/settings">Настройки</a></li>
      <li><hr class="gr-menu-sep"></li>
      <li><button class="gr-menu-item gr-menu-danger" type="button">Выйти</button></li>
    </ul>
  </div>
</details>
Кликните по аватару: группа-шапка, пункты, опасный «Выйти»
АК

Корзина в выдвижной панели

«Товар добавлен» — и справа выезжает корзина: .gr-drawer .gr-drawer-end. Внутри позиции — медиа-объекты со стопкой «название + цена» и кнопкой-крестиком, в подвале — .gr-flex-between с итогом и кнопка оформления на всю ширину. Контекст страницы не теряется — панель поверх, щелчок мимо закрывает её (data-gr-overlay-close).

<dialog class="gr-drawer gr-drawer-end" id="cart" aria-label="Корзина" data-gr-overlay-close>
  <div class="gr-modal-header">
    <strong>Корзина</strong>
    <form method="dialog"><button class="gr-btn gr-btn-ghost gr-btn-icon" aria-label="Закрыть">✕</button></form>
  </div>
  <div class="gr-modal-body gr-flex gr-flex-col gr-gap">
    <div class="gr-flex gr-gap-sm gr-flex-items-start">
      <span class="gr-avatar gr-avatar-square">🪑</span>
      <div class="gr-min-w-0 gr-flex-1">
        <p class="gr-m-0 gr-text-sm gr-font-medium">Кресло «Дюна»</p>
        <p class="gr-m-0 gr-text-xs gr-text-ink-secondary">1 × 12 400 ₽</p>
      </div>
      <button class="gr-btn gr-btn-icon gr-btn-ghost gr-btn-sm">✕<span class="gr-sr-only">Убрать</span></button>
    </div>
  </div>
  <div class="gr-modal-footer gr-flex gr-flex-col gr-gap-sm">
    <div class="gr-flex gr-flex-between gr-w-full">
      <span>Итого</span><span class="gr-font-bold">18 300 ₽</span>
    </div>
    <button class="gr-btn gr-btn-primary gr-btn-block">Оформить заказ</button>
  </div>
</dialog>
Живой пример: позиции — медиа-объекты, итог — в подвале
Корзина
🪑

Кресло «Дюна»

1 × 12 400 ₽

💡

Торшер «Свет»

1 × 5 900 ₽

С подключённым griffinjs.js

Всё выше работает без скрипта. Этот подраздел — о том, что добавляет опциональный слой GriffinJS к тем же компонентам: разметка и классы не меняются, скрипт читает data-gr-* и пишет только aria-* и data-gr-state. Каждый пример здесь помечен бейджем «нужен griffinjs.js»; без скрипта он остаётся обычным компонентом из основной части страницы.

Дропдаун: роли меню, клавиатура, закрытие, позиция

data-gr-dropdown на обёртке любой из трёх форм — <details>, popover, наведение. Контроллер ставит роли menu/menuitem/separator, честный aria-expanded, ведёт клавиатуру по схеме WAI-ARIA menu button ( открывает и переходит к первому пункту, Home End по пунктам, набор по первой букве, Esc закрывает и возвращает фокус) и добавляет <details> то, чего у него нет: закрытие по Esc и щелчку мимо. Popover-панель контроллер кладёт под кнопку сам — по getBoundingClientRect, одинаково во всех браузерах, без зависимости от CSS anchor positioning; при нехватке места снизу переворачивает вверх, при прокрутке — догоняет кнопку. Закрытие <details> идёт с анимацией темы: обёртка получает data-gr-state="closing" и держит open до конца перехода панели (рецепт — на странице мегаменю).

<details class="gr-dropdown" data-gr-dropdown>…</details>
<div class="gr-dropdown" data-gr-dropdown="align: end">
  <button popovertarget="m">…</button><div id="m" popover>…</div>
</div>
<div class="gr-dropdown gr-dropdown-hover" data-gr-dropdown="hover; delay: 100">…</div>
Три формы с контроллером: попробуйте , буквы, Esc нужен griffinjs.js
Действия

Параметр data-gr-dropdownПо умолчаниюЧто делает
rolemenuРоли списка; none — панель с формой или произвольным содержимым, ролей и aria-haspopup нет
hoverfalseОткрывать по наведению мыши и пера с задержкой delay, закрывать через hide после ухода
delay / hide100 / 300мс задержки намерения и ухода
side / align / gapbottom / start / 4Только для popover-панели: сторона, выравнивание вдоль неё, зазор в px

С контроллером у варианта по наведению aria-expanded наконец правдив, а Esc закрывает панель — два ограничения из предупреждения выше сняты. Третье остаётся решением автора: наведение для навигации, не для действий.

Окно и панель: открытие атрибутом, стек, прокрутка, медиа, ajax, hash

Контроллер окон ничего не переписывает у <dialog>: верхний слой, фокус, Esc и inert остаются платформе, щелчок по подложке — атрибуту data-gr-overlay-close выше. Он добавляет data-gr-open="#id" на кнопке вместо onclick, стек окон, блокировку прокрутки страницы под окном (в том числе на iOS), паузу видео при закрытии, содержимое с сервера по data-gr-src, синхронизацию с адресом (data-gr-dialog="hash") и плавный рост окна с приходом содержимого (data-gr-dialog="grow"). Подробно — на странице GriffinJS → «Окна и комбобокс».

<button class="gr-btn" type="button" data-gr-open="#cart">Корзина</button>
<dialog class="gr-drawer gr-drawer-end" id="cart" data-gr-dialog="hash" data-gr-overlay-close>…</dialog>
Окно и панель поверх него; страница под ними не прокручивается нужен griffinjs.js

Окно

Открыто через data-gr-open. Кнопка ниже — панель поверх окна.

Панель

В адресе — #gjs-drawer; «Назад» закрывает панель.

Подсказка в верхнем слое

Обычной подсказке скрипт не нужен. Он нужен подсказке внутри таблицы с прокруткой или блока с overflow: hidden — её обрезает край предка. data-gr-tooltip на обёртке переводит подсказку в popover="manual", показывает по наведению с задержкой (мышь и перо) и по фокусу сразу, прячет по уходу, потере фокуса и Esc, а положение у якоря считает сам. Без скрипта та же разметка — обычная подсказка из раздела выше; aria-describedby работает в обоих случаях.

<span class="gr-tooltip-anchor" data-gr-tooltip="side: bottom">
  <button class="gr-btn" type="button" aria-describedby="t9">В таблице</button>
  <span class="gr-tooltip" id="t9" role="tooltip">Не обрежется</span>
</span>
Блок с overflow: hidden высотой в одну кнопку нужен griffinjs.js
В верхнем слое, поверх края блока side: bottom side: end
Параметр data-gr-tooltipПо умолчаниюЧто делает
delay150мс до показа по наведению; по фокусу — сразу
side / align / gaptop / center / 6Сторона, выравнивание, зазор в px; при нехватке места сторона переворачивается