Модальное окно на странице почти всегда собирается из трёх частей: кнопка, элемент <dialog> и маленький скрипт, который находит одно, находит другое и связывает их обработчиком клика. Скрипт короткий, но у него есть цена: пока JavaScript не загрузился и не выполнился, кнопка ничего не делает, а на странице с десятком модалок таких связок становится десять.

Атрибуты command и commandfor переносят эту связку в разметку. Кнопка сама объявляет, что сделать и с каким элементом, а браузер выполняет действие без единой строки скрипта. Вместе с ними пришло событие command и возможность заводить собственные команды. В спецификации эта пара называется invoker commands — «команды-вызовы». Базовый пример мы уже мельком показывали в статьях о том, что CSS теперь умеет без JavaScript и о том, почему у HTML больше нет версий. Здесь разберём механизм целиком.

Как модалки открывали раньше

Самый старый способ обойтись без скрипта — хак с псевдоклассом :target. Ссылка ведёт на якорь, блок с этим id становится целью перехода, а CSS показывает его, пока он цель:

<a href="#promo">Условия акции</a>

<div id="promo" class="overlay">
  <div class="overlay__box">
    <p>Скидка действует до конца месяца.</p>
    <a href="#">Закрыть</a>
  </div>
</div>

<style>
  .overlay { display: none; }
  .overlay:target { display: grid; }
</style>

Работает, но это имитация, а не модальное окно:

  • каждое открытие добавляет хеш в адрес и запись в историю — кнопка «Назад» снова открывает окно вместо возврата на прошлую страницу;
  • страница прокручивается к якорю;
  • фокус остаётся на ссылке, клавиатурой можно уйти в контент под подложкой, Esc ничего не закрывает;
  • скринридер видит обычный блок, а не диалог.

Поэтому на практике писали скрипт: querySelector для кнопки, querySelector для диалога и addEventListener('click', () => dialog.showModal()). Для поповеров декларативный путь появился раньше — атрибут popovertarget, — но на <dialog> он не действует. Пара command/commandfor закрывает оба случая одним синтаксисом.

Кнопка с двумя атрибутами

<button command="show-modal" commandfor="notify-settings">
  Настроить уведомления
</button>

<dialog id="notify-settings">
  <h2>Уведомления</h2>
  <label><input type="checkbox" checked> Письма о новых статьях</label>
  <button command="close" commandfor="notify-settings">Готово</button>
</dialog>

В commandfor записывается id элемента, которым управляет кнопка, в command — название действия. Всё остальное делает браузер: окно открывается в верхнем слое (top layer) поверх любых z-index, фон становится недоступным для клика и фокуса, Esc закрывает окно, а после закрытия фокус возвращается на кнопку, которая его открыла.

Несколько правил, которые проверяются первыми, когда «ничего не происходит»:

  • инициатором может быть только <button>. У <input type="button"> есть popovertarget, но атрибута commandfor нет;
  • кнопка и цель должны лежать в одном дереве: из обычного DOM нельзя сослаться по id на элемент внутри Shadow DOM, и наоборот;
  • название команды должно совпадать с одним из встроенных значений или начинаться с двух дефисов. На неизвестное значение браузер не реагирует вообще — ни действия, ни события.

И одна ловушка с формами. Кнопка без type внутри <form> по умолчанию отправляет форму. Но если на ней стоят command или commandfor, спецификация перестаёт считать её кнопкой отправки. Поэтому кнопка «Подробнее об условиях» внутри формы оформления заказа откроет модалку и не отправит заказ, даже если type="button" забыли. Обратная сторона: если кнопке нужно и отправить форму, и что-то открыть, одной разметкой это не сделать.

Все встроенные команды

Встроенных команд немного, и все они работают только с тегом <dialog> и элементом с атрибутом popover:

Команда Цель Что вызывает Аналог через popovertarget
show-modal dialog showModal() —
close dialog close() —
request-close dialog requestClose() —
show-popover popover showPopover() popovertargetaction="show"
hide-popover popover hidePopover() popovertargetaction="hide"
toggle-popover popover togglePopover() popovertargetaction="toggle" (по умолчанию)

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

close и значение кнопки

Если у кнопки с командой close есть атрибут value, оно попадает в свойство returnValue диалога. Так одно окно подтверждения с несколькими кнопками сообщает, какую из них нажали, а скрипт читает ответ в обработчике закрытия:

<dialog id="cookie-prompt">
  <p>Разрешить аналитические cookie?</p>
  <button command="close" commandfor="cookie-prompt" value="accept">Разрешить</button>
  <button command="close" commandfor="cookie-prompt" value="reject">Только необходимые</button>
</dialog>
const prompt = document.getElementById('cookie-prompt');

prompt.addEventListener('close', () => {
  // 'accept', 'reject' или '', если окно закрыли через Esc
  console.log('Ответ:', prompt.returnValue);
});

Раньше то же самое давала только форма с method="dialog" вокруг кнопок. Теперь обёртка-форма не обязательна.

request-close: закрыть, но с правом отказаться

Команда close закрывает окно безоговорочно. request-close работает так же, как нажатие Esc: сначала на диалоге срабатывает событие cancel, и если его обработчик вызвал preventDefault(), окно остаётся открытым. Это ровно то, что нужно для формы с несохранёнными изменениями:

<dialog id="edit-profile">
  <form>
    <label>Имя <input name="name" value="Мария"></label>
  </form>
  <button command="request-close" commandfor="edit-profile">Отмена</button>
</dialog>
const editor = document.getElementById('edit-profile');
let isDirty = false;

editor.querySelector('form').addEventListener('input', () => {
  isDirty = true;
});

editor.addEventListener('cancel', (event) => {
  if (isDirty && !confirm('Изменения не сохранены. Всё равно закрыть?')) {
    event.preventDefault();
  }
});

Одна проверка закрывает оба пути: и кнопку «Отмена», и Esc. Если окно уже закрыто, команда ничего не делает.

Команды для поповеров

С поповерами commandfor ничего нового по поведению не даёт — три команды один в один повторяют значения popovertargetaction:

<button command="toggle-popover" commandfor="help-tip">Что это?</button>

<div id="help-tip" popover>
  Промокод вводится один раз и привязывается к аккаунту.
  <button command="hide-popover" commandfor="help-tip">Понятно</button>
</div>

Смысл брать новые атрибуты и здесь — в единообразии: в проекте один способ связать кнопку с целью вместо двух, и с поповерами работает то же событие command, о котором ниже. Старый popovertarget при этом никуда не делся, переписывать работающий код ради замены не нужно.

Событие command

Перед тем как выполнить команду, браузер отправляет на цель (не на кнопку) событие command типа CommandEvent. У него два собственных свойства:

  • event.command — строка из атрибута command;
  • event.source — кнопка, которая вызвала команду.

Второе свойство и есть главная польза события. Открыть или закрыть диалог браузер отследит и сам — для этого у <dialog>, как и у поповеров, есть события toggle и beforetoggle. А вот кто открыл окно, знает только command. Типичный случай — одно окно подтверждения на целую таблицу:

<ul>
  <li>report-june.pdf
    <button command="show-modal" commandfor="confirm-delete" data-file="report-june.pdf">Удалить</button>
  </li>
  <li>avatar.png
    <button command="show-modal" commandfor="confirm-delete" data-file="avatar.png">Удалить</button>
  </li>
</ul>

<dialog id="confirm-delete">
  <p>Удалить файл <strong class="file-name"></strong>?</p>
  <button command="close" commandfor="confirm-delete" value="yes">Удалить</button>
  <button command="close" commandfor="confirm-delete">Отмена</button>
</dialog>
const confirmDialog = document.getElementById('confirm-delete');

confirmDialog.addEventListener('command', (event) => {
  if (event.command === 'show-modal') {
    confirmDialog.querySelector('.file-name').textContent = event.source.dataset.file;
  }
});

Открытие окна по-прежнему описано в разметке, а скрипт только подставляет имя файла. Если он не успел загрузиться, окно всё равно откроется — просто без имени.

Ещё два свойства события, которые стоит знать:

  • оно отменяемое: event.preventDefault() в обработчике не даст выполниться встроенному действию. Так можно, например, не открывать окно, пока пользователь не авторизован;
  • оно не всплывает, поэтому слушать его на document бесполезно — обработчик вешается на саму цель.

Настройка из JavaScript

Оба атрибута доступны как свойства кнопки. command отражает строку, а commandForElement принимает сам элемент, а не его id:

const button = document.createElement('button');
button.textContent = 'Открыть корзину';
button.command = 'show-modal';
button.commandForElement = document.getElementById('cart');

// То же самое через атрибут — здесь уже нужен id
button.setAttribute('commandfor', 'cart');

Это удобно, когда кнопки создаются динамически: ссылку на цель можно передать напрямую, не придумывая ей уникальный id.

Свои команды через два дефиса

Кроме встроенных команд, атрибут принимает произвольные имена, начинающиеся с -- — по аналогии с кастомными свойствами в CSS. Встроенного действия у таких команд нет: браузер только отправляет событие command на цель, а что делать, решает обработчик. Зато цель может быть любым элементом, не только диалогом или поповером.

Пример — счётчик количества товара, где три кнопки управляют одним полем:

<button command="--decrement" commandfor="qty">−</button>
<output id="qty">1</output>
<button command="--increment" commandfor="qty">+</button>
<button command="--reset" commandfor="qty">Сбросить</button>
const qty = document.getElementById('qty');

qty.addEventListener('command', (event) => {
  let value = Number(qty.value);

  if (event.command === '--increment') value = Math.min(value + 1, 10);
  if (event.command === '--decrement') value = Math.max(value - 1, 1);
  if (event.command === '--reset') value = 1;

  qty.value = value;
});

Обработчик один и висит на цели, а не по одному на каждой кнопке. Новая кнопка с той же командой заработает без правки скрипта, а в разметке сразу видно, какая кнопка чем управляет. Проверять в обработчике нужно полное имя вместе с дефисами: '--increment', а не 'increment'. А команда без дефисов, которой нет среди встроенных, просто не сработает: браузер сочтёт её неизвестной и событие не отправит.

Поповер рядом с кнопкой без anchor-name

По умолчанию поповер появляется по центру экрана, а чтобы прижать его к кнопке, нужна привязка к якорю: anchor-name на кнопке и ссылка на это имя в стилях поповера. Но если поповер открыт кнопкой с commandfor (или popovertarget), эта кнопка сама становится его неявным якорем. Имя заводить не нужно — достаточно указать, с какой стороны от якоря встать:

<button command="toggle-popover" commandfor="share-menu">Поделиться</button>

<div id="share-menu" popover>
  <a href="https://example.com/share?to=mail">По почте</a>
  <a href="https://example.com/share?to=link">Скопировать ссылку</a>
</div>
#share-menu {
  inset: auto;             /* снимаем браузерное inset: 0 */
  margin: 6px 0 0;
  position-area: bottom span-right;
}

Значение bottom span-right ставит меню под кнопкой, выровненным по её левому краю, с разрастанием вправо. Когда на странице десяток таких кнопок, это избавляет от десятка уникальных имён вида --share-btn-7.

Где команды заканчиваются

Встроенные команды покрывают только диалоги и поповеры. В обсуждениях спецификации звучали команды для <details>, <select>, <video>, полноэкранного режима и выбора файла, но в стандарт они не вошли. Для них по-прежнему нужен скрипт — или своя ---команда с обработчиком, если хочется сохранить единый стиль разметки.

Ещё один частый запрос — закрыть модалку по клику на подложку — решает не команда, а отдельный атрибут диалога closedby. Значение closedby="any" разрешает закрытие кликом снаружи и Esc, closedby="closerequest" — только Esc, closedby="none" — только кнопками внутри:

<dialog id="gallery-photo" closedby="any">
  <img src="photo-large.jpg" alt="Вид на старый город с холма">
</dialog>

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

Поддержка браузерами
chrome
Chrome
134
firefox
Firefox
141
edge
Edge
134
safari
Safari
 
opera
Opera
119

Поддержка и запасной план

Атрибуты command и commandfor вместе с событием command работают во всех основных движках. Команда request-close появилась в Chrome чуть позже остальных, с версии 139, а неявный якорь для поповеров в Firefox доступен с версии 147.

Поддержка браузерами
chrome
Chrome
135
firefox
Firefox
144
edge
Edge
135
safari
Safari
26.2
opera
Opera
120

В браузере постарше кнопка с этими атрибутами молча ничего не делает. Если среди аудитории заметная доля таких браузеров, выручит полифил invokers-polyfill. Он сам проверяет, есть ли у браузера встроенная поддержка, и в новых браузерах ничего не делает, так что достаточно импорта:

import 'invokers-polyfill';

Проверить поддержку вручную, чтобы, например, показать старый вариант интерфейса, можно по наличию свойства у кнопки:

const hasInvokers = 'command' in HTMLButtonElement.prototype;

Для действий, без которых сценарий ломается, — оформление заказа, удаление данных — полифил или обычный обработчик клика обязательны. Для второстепенного, вроде подсказки «Что это?», достаточно того, что в новых браузерах всё работает, а в старых кнопка просто не отвечает.

Итого

Пара command и commandfor делает для диалогов то, что popovertarget когда-то сделал для поповеров: связь «кнопка → окно» описывается в разметке и работает до загрузки скриптов. JavaScript остаётся для того, что разметке не выразить: подставить данные через event.source, прочитать returnValue, отменить закрытие в cancel или обработать свою ---команду. Хорошее правило для нового кода: всё, что кнопка просто открывает и закрывает, оформлять атрибутами, а скрипт писать только под логику.