Окна и комбобокс
Контроллер окон поверх <dialog>: открытие атрибутом, стек, блокировка прокрутки под окном, остановка видео, ajax-содержимое, hash. И комбобокс — поле с подсказками из внешнего источника по WAI-ARIA.
Окна — контроллер: разметка и стили .gr-modal
и .gr-drawer из пакета ui не меняются, скрипт
добавляет поведение. Комбобокс — новый виджет со своей
разметкой и стилями в griffinjs.css. Подраздел
«С подключённым griffinjs.js» на странице
«Слои поверх страницы» повторяет
главное о контроллере рядом с самими компонентами.
Окна
<dialog> сам даёт верхний слой, ловушку фокуса, Esc,
inert под окном и возврат фокуса; закрытие щелчком по подложке —
data-gr-overlay-close из griffincss-ui.js. Контроллер
не переписывает ничего из этого. Он добавляет то, чего нет:
| Что | Как |
|---|---|
| Открытие атрибутом | data-gr-open="#id" на любой кнопке или ссылке — без onclick; Ctrl/Cmd-щелчок по ссылке не перехватывается |
| Стек | Окно поверх окна: GriffinJS.dialog.top(), stack(), closeAll(); Esc закрывает верхнее — так делает сам <dialog> |
| Прокрутка под окном | Пока стек не пуст, на <html> стоит data-gr-state="locked": CSS слоя прячет переполнение и держит место полосы (scrollbar-gutter); на сенсорном экране жест вне прокручиваемого тела окна отменяется — iOS Safari двигает страницу и под модальным окном |
| Медиа | При закрытии <video>/<audio> — на паузу, <iframe> разгружается и возвращается при следующем открытии; media: false отключает |
| Ajax | data-gr-src на окне: фрагмент грузится при первом открытии в [data-gr-content] (или в само окно); состояния loading → open | error |
| Hash | data-gr-dialog="hash": открытие пишет #id в историю, «Назад» закрывает окно, страница с #id в адресе показывает его сразу |
| Рост | data-gr-dialog="grow": смена высоты окна больше 24 px — плавно, от прежней высоты к новой (element.animate по block-size, темп — --gr-modal-transition или --gr-transition). Открытие и закрытие не трогаются, при prefers-reduced-motion — сразу. Только по атрибуту: ResizeObserver на каждом окне — цена, которую не все просили |
| События | griffin:open и griffin:close на окне, со всплытием |
Рост — для окна, чьё содержимое приходит ajax'ом: плашка в 10rem
со спиннером (состояние loading компонента) не прыгает к форме,
а вырастает до неё. Порог в 24 px отсекает ввод в textarea
с автовысотой: строка текста — не приход содержимого.
<button class="gr-btn" type="button" data-gr-open="#cart">Корзина</button>
<dialog class="gr-drawer gr-drawer-end" id="cart" aria-label="Корзина"
data-gr-dialog="hash" 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">…</div>
</dialog>
Параметры data-gr-dialog
| Параметр | По умолчанию | Что делает |
|---|---|---|
hash | false | Синхронизация с адресом: #id при открытии, «Назад» закрывает |
lock | true | Блокировать прокрутку страницы под окном |
media | true | Останавливать видео, аудио и iframe при закрытии |
grow | false | Плавный рост и уменьшение высоты: ResizeObserver на окне, порог 24 px |
Атрибут data-gr-dialog на окне нужен только ради параметров:
окно, открытое через data-gr-open, контроллер поднимает сам
с умолчаниями. Программно — GriffinJS.dialog.open('#id')
и close('#id').
База без JS — кнопка с onclick="m.showModal()" и
<form method="dialog">, как описано у компонента.
data-gr-open без скрипта не делает ничего: если окно обязано
открываться и без слоя, оставьте onclick — контроллер его
не сломает, стек и блокировка подхватят окно по событиям.
Комбобокс
Поле ввода с подсказками из внешнего источника. База без JS — само поле
в форме поиска: Enter отправляет её, как всегда. Скрипт добавляет
список под полем и то, что описано в WAI-ARIA combobox:
role="combobox" с aria-autocomplete="list",
listbox с option, aria-activedescendant
— фокус остаётся в поле, иначе набирать дальше было бы нечем.
<form action="/search" class="gr-combobox" data-gr-combobox="src: /search/suggest?q={q}; min: 2; delay: 250">
<input class="gr-input" type="search" name="q" placeholder="Поиск">
</form>
Параметр data-gr-combobox | По умолчанию | Что делает |
|---|---|---|
src | — | Адрес с {q}; ответ — JSON: массив строк или объектов {value, label, href, group}, либо {items: […]} |
min | 1 | Минимальная длина запроса; короче — список закрыт, запрос не уходит |
delay | 200 | мс дебаунса: запрос уходит, когда перестали печатать |
empty | '' | Подпись пустого ответа (role="status"); пустая — список просто закрывается |
navigate | false | Выбор позиции с href — переход по адресу |
highlight | true | Совпадение в подписи оборачивается <mark> — через textContent, данные разметкой не становятся |
cache | true | Выдача по запросу запоминается на время жизни страницы |
multiple | false | Мультивыбор: выбранное — теги перед полем, значение — в <select multiple> внутри обёртки (см. ниже) |
free | false | Свободные теги, только вместе с multiple: Enter на непустом поле без активной позиции создаёт тег из текста (см. ниже). Без multiple — предупреждение, флаг пропускается |
remove | Убрать {label} | Подпись кнопки «убрать» у тега, для скринридера |
Мультивыбор
Флаг multiple, а не второй виджет. База без скрипта —
<select multiple> внутри обёртки: он и без слоя
выбирает несколько позиций и отправляет их форме. Со скриптом он уходит
с экрана, выбранное показывается тегами перед полем, выбор позиции
из выдачи включает её в <select> — добавляя
<option>, если такой не было, — и очищает поле;
крестик у тега или Backspace в пустом поле снимает выбор.
Значение всегда в <select>, и о смене он сообщает
событием change, как без скрипта.
<div class="gr-combobox" data-gr-combobox="src: /tags?q={q}; multiple">
<select class="gr-select" name="tags[]" multiple aria-label="Выбранные теги">
<option value="css" selected>CSS</option>
</select>
<input class="gr-input" type="search" placeholder="Добавить тег" aria-label="Добавить тег">
</div>
<select multiple>; наберите «а» нужен griffinjs.jsСвободные теги
Ключевые слова, адреса писем, метки — значения, которых в источнике
может не быть. Флаг free рядом с multiple:
Enter на непустом поле без активной позиции создаёт
<option selected> со значением, равным тексту, и тег
перед полем; поле пустеет. Позиция из выдачи по-прежнему выбирается
Enter, когда она активна. Дубликата по значению нет — текст,
совпавший с существующей позицией, просто выбирает её; снимается
свободный тег как любой другой. Одиночному комбобоксу флаг ни к чему:
там текст поля и есть значение.
Значение уходит на сервер как текст — то, что человек написал, без проверки на стороне страницы. Адрес письма, длина, стоп-слова проверяются там, куда форма отправляется, как и любое текстовое поле.
<div class="gr-combobox" data-gr-combobox="src: /keywords?q={q}; multiple; free">
<select class="gr-select" name="keywords[]" multiple aria-label="Ключевые слова"></select>
<input class="gr-input" type="search" placeholder="Слово и Enter" aria-label="Добавить ключевое слово">
</div>
Через JS источником может быть функция, а позиция — своей разметкой:
GriffinJS.mount(el, 'combobox', {
min: 1,
source: function (query, signal) { // Promise<items>; signal — AbortController
return fetch('/api/suggest?q=' + encodeURIComponent(query), { signal: signal }).then(r => r.json());
},
render: function (item, query) { // узел позиции; null — рисовать по умолчанию
var li = document.createElement('li');
li.className = 'gr-combobox-option';
li.textContent = item.label + ' — ' + item.data.price;
return li;
}
});
el.addEventListener('griffin:select', function (e) { console.log(e.detail.item); });
Клавиатура
| Клавиша | Действие |
|---|---|
| ↓ ↑ | По позициям по кругу; на закрытом списке — показать прошлую выдачу |
| Home End | Первая и последняя позиция |
| Enter | Выбрать позицию; без выбранной — отдаётся форме, как без скрипта |
| Esc | Закрыть список, не стирая запрос |
| Tab | Закрыть и уйти дальше по странице |
| Наведение | Выбирает позицию без подкрутки списка; щелчок — выбор |
Что даёт слой, а что остаётся сайту
Граница проведена по поведению поля. В слое — всё, что одинаково у любого
живого поиска и что обычно переписывают заново на каждом сайте: роли
и aria-activedescendant, дебаунс и минимальная длина, отмена
устаревшего запроса, кеш выдачи, возврат прошлой выдачи по ↓
и по фокусу, наведение как выбор, Esc без стирания запроса,
Enter с передачей форме, подсветка совпадения через
textContent — данные никогда не становятся разметкой.
Сайту остаётся то, что у каждого своё: как выглядит позиция (значок
раздела, цена со скидкой, карточка с описанием) и что происходит
при выборе — это render() и обработчик
griffin:select, которому слой отдаёт позицию с исходными
данными в item.data. Так один и тот же комбобокс служит
и поиску по каталогу с карточками, и подсказке адреса в форме.
Примеры использования
Корзина в панели: открытие из шапки, hash, содержимое с сервера
Кнопка корзины в шапке открывает панель; содержимое приезжает при первом
открытии, адрес получает #cart, и ссылку на корзину можно
отправить. Закрытие панели ставит на паузу видео товара, если оно там было.
<button class="gr-btn gr-btn-ghost" type="button" data-gr-open="#cart">Корзина · 3</button>
<dialog class="gr-drawer gr-drawer-end" id="cart" aria-label="Корзина"
data-gr-dialog="hash" data-gr-src="/cart/panel" 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" data-gr-content></div>
<div class="gr-modal-footer"><a class="gr-btn gr-btn-primary" href="/checkout">Оформить</a></div>
</dialog>
data:-URL), один раз нужен griffinjs.jsЖивой поиск в шапке витрины
Форма поиска остаётся формой: без скрипта Enter ведёт на страницу
результатов. Со скриптом под полем — подсказки по группам, выбор позиции
с адресом ведёт на её страницу (navigate).
<form class="gr-combobox" action="/search" data-gr-combobox="src: /search/suggest?q={q}; min: 2; navigate; empty: Ничего не найдено">
<div class="gr-input-group">
<input class="gr-input" type="search" name="q" placeholder="Поиск по каталогу">
<button class="gr-btn gr-btn-primary" type="submit">Найти</button>
</div>
</form>
Строка подсказки: картинка, категория и цена
Оформление строки слою не принадлежит: его задаёт
render(item, query) → узел. Слой ставит на возвращённый узел
только role="option", aria-selected и
id — клавиатура и aria-activedescendant работают
как на простой строке. Ни одного нового класса: карточка собирается
утилитами внутри .gr-combobox-option — он оставляет строке
свои отступы, минимальную высоту и подсветку выбранной позиции.
Флекс — обёрткой внутри строки, а не на самой строке.
.gr-combobox-option объявлен display: block
в слое griffincss.ui, а .gr-flex живёт в слое
griffincss.core, который идёт раньше, — значит
на одном узле побеждает компонент, и флекс молча не включится. Замер
в трёх движках это и показал: строка вырастала втрое. Поэтому
<li> остаётся строкой компонента, а раскладка карточки
живёт в <span class="gr-flex …"> внутри неё.
Поля позиции слой приводит к своим — value,
label, href, group, — а весь
исходный объект источника кладёт в item.data. Оттуда
render() и берёт цену с картинкой: слою о них знать незачем.
Картинке нужны width и height в разметке:
без них список дёргается при загрузке, а строка подсказки живёт
под пальцем. loading="lazy" — потому что подсказок бывает
двадцать, а увидят пять.
GriffinJS.mount(el, 'combobox', {
source: suggest,
render: function (item, query) {
var li = document.createElement('li');
var row = document.createElement('span');
var body = document.createElement('span');
var name = document.createElement('span');
var kind = document.createElement('span');
var price = document.createElement('span');
var img = document.createElement('img');
li.className = 'gr-combobox-option'; // отступы и подсветка — компонента
row.className = 'gr-flex gr-flex-items-center gr-gap'; // раскладка — внутри строки
img.className = 'gr-radius-2 gr-object-cover';
img.src = item.data.image;
img.width = 40;
img.height = 40;
img.loading = 'lazy';
img.alt = '';
body.className = 'gr-flex gr-flex-col gr-min-w-0';
name.className = 'gr-truncate';
highlight(name, item.label, query); // textContent и <mark>, см. ниже
kind.className = 'gr-text-sm gr-text-secondary gr-truncate';
kind.textContent = item.group;
price.className = 'gr-ms-auto gr-font-medium';
price.textContent = item.data.price;
body.appendChild(name);
body.appendChild(kind);
row.appendChild(img);
row.appendChild(body);
row.appendChild(price);
li.appendChild(row);
return li;
}
});
Подсветка при своём render() — своими руками. Штатная
работает над подписью, которую слой рисует сам; вернули свой узел — рисуете
и её. Способ ровно тот же, что внутри слоя: текст идёт через
textContent, совпадение — отдельным узлом
<mark>. innerHTML из ответа сервера здесь
запрещён по той же причине, по которой его нет в слое: подсказки
приходят из внешнего источника, и разметка в них — чужой скрипт на вашей
странице.
function highlight(target, text, query) {
var at = query ? text.toLowerCase().indexOf(query.toLowerCase()) : -1;
if (at === -1) { target.textContent = text; return; }
var mark = document.createElement('mark');
mark.textContent = text.slice(at, at + query.length);
if (at > 0) target.appendChild(document.createTextNode(text.slice(0, at)));
target.appendChild(mark);
if (at + query.length < text.length) target.appendChild(document.createTextNode(text.slice(at + query.length)));
}