Окна и комбобокс

Контроллер окон поверх <dialog>: открытие атрибутом, стек, блокировка прокрутки под окном, остановка видео, ajax-содержимое, hash. И комбобокс — поле с подсказками из внешнего источника по WAI-ARIA.

GriffinJS widgets/dialog.js widgets/combobox.js

Окна — контроллер: разметка и стили .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 отключает
Ajaxdata-gr-src на окне: фрагмент грузится при первом открытии в [data-gr-content] (или в само окно); состояния loadingopen | error
Hashdata-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>
Окно, панель с hash и окно поверх окна нужен griffinjs.js

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

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

Корзина

В адресе сейчас #ov-cart. Кнопка «Назад» в браузере закроет панель, а ссылка с этим hash откроет её сразу.

Параметры data-gr-dialog

ПараметрПо умолчаниюЧто делает
hashfalseСинхронизация с адресом: #id при открытии, «Назад» закрывает
locktrueБлокировать прокрутку страницы под окном
mediatrueОстанавливать видео, аудио и iframe при закрытии
growfalseПлавный рост и уменьшение высоты: 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: […]}
min1Минимальная длина запроса; короче — список закрыт, запрос не уходит
delay200мс дебаунса: запрос уходит, когда перестали печатать
empty''Подпись пустого ответа (role="status"); пустая — список просто закрывается
navigatefalseВыбор позиции с href — переход по адресу
highlighttrueСовпадение в подписи оборачивается <mark> — через textContent, данные разметкой не становятся
cachetrueВыдача по запросу запоминается на время жизни страницы
multiplefalseМультивыбор: выбранное — теги перед полем, значение — в <select multiple> внутри обёртки (см. ниже)
freefalseСвободные теги, только вместе с 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>
Ключевые слова: подсказки из источника плюс свои по Enter нужен griffinjs.js

Через 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); });
Источник — функция с фильтром по подстроке; наберите «а» нужен griffinjs.js

Клавиатура

КлавишаДействие
По позициям по кругу; на закрытом списке — показать прошлую выдачу
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)));
}
Подсказки карточками: наберите «a» нужен griffinjs.js