Spec-Zone.ru › htmx

hx-live

Добавление реактивных привязок к HTML

Расширение hx-live связывает состояние DOM со встроенными выражениями.

Ментальная модель

Can HTML provide the behavior?
├─ yes → use HTML
└─ no
   Can CSS derive the presentation?
   ├─ yes → use HTML + CSS
   └─ no  → use hx-live

Установка

<script src="https://cdn.jsdelivr.net/npm/htmx.org@4.0.0/dist/htmx.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/htmx.org@4.0.0/dist/ext/hx-live.min.js"></script>

Использование

Привязка атрибута

Добавьте префикс к атрибуту: :

<input value="Ada">

<output :text="'Hello, ' + q('previous input').value"></output>
<button :disabled="!q('previous input').value">Continue</button>
Ada    → Hello, Ada  → Continue enabled
empty  → Hello,      → Continue disabled

См. раздел Атрибуты, где перечислены все цели привязки.

Поиск элементов

Используйте q(), чтобы обратиться к состоянию DOM за пределами текущего элемента:

q('previous input')  // nearby
q('#name')           // by ID
q('.item')           // every match
q('closest .field')  // nearest matching ancestor

Чтение возвращает первый найденный элемент. Запись обновляет все найденные элементы:

q('.item').aria.busy = true

Полный синтаксис селекторов описан в разделе q().

Обработка события

Используйте hx-on, чтобы изменить состояние после события:

<button aria-pressed="false"
        hx-on:click="aria.pressed = !aria.pressed">
  Mute
</button>
[aria-pressed="true"] {
  background: var(--selected);
}

Общий доступ к состоянию

Разместите общее состояние в ближайшем общем предке:

<section data-count="0">
  <button hx-on:click="data.count++">Add</button>
  <output :text="data.count"></output>
</section>

Если новое значение зависит от текущего:

data.items = items => [...items, next]

Типизированные значения, поиск ближайшего элемента и функциональные присваивания описаны в разделе data.

Выполнение асинхронного кода

Временное поведение может определяться ответом сервера.

Запрос:

<form hx-post="/profile"
      hx-target="#status"
      hx-swap="outerHTML">
  <input name="name">
  <button>Save</button>
</form>

<output id="status"></output>

Ответ:

<output id="status"
        class="notice"
        hx-on:load="
          await timeout('3s')
          class.leaving = true
          await forEvent('transitionend', 500)
          this.remove()
        ">
  Saved
</output>
.notice {
  transition: opacity 200ms;
}

.notice.leaving {
  opacity: 0;
}

timeout() ожидает перед переходом. forEvent() ожидает перехода или истечения времени ожидания.

Атрибуты

:<attr>

Добавьте префикс : к любому HTML-атрибуту и укажите в нём выражение. Результат будет записан в атрибут.

<input id="name">
<button :disabled="!q('#name').value">Submit</button>

Такая же форма работает с любым атрибутом:

<a :href="'/users/' + q('#user-id').value">profile</a>
<button :hidden="q('.row').count === 0">Clear all</button>
<input :required="q('#mode').value === 'final'">

⚠️ Конфликт с Alpine.js: Краткая форма : использует тот же синтаксис, что и Alpine.js (x-bind:). Если во время инициализации на странице обнаружена Alpine, hx-live автоматически отключает краткую форму : и выводит предупреждение в консоль. Это поведение можно переопределить, явно задав config.live.bindPrefix.

Способы записи значений разных атрибутов (логические значения, ARIA, свойства DOM, обычные атрибуты) описаны в разделе Правила записи атрибутов.

hx-live:<attr>

Полная форма. Работает так же, как :<attr>.

<button hx-live:disabled="!q('#name').value">Submit</button>

Используйте её, если ваша система сборки удаляет атрибуты с префиксом :.

:.<class>

Свяжите один класс с выражением. Истинное значение добавляет класс, ложное — удаляет.

<input type="number" value="0">
<p :.warn="q('previous input').value < 0">Negative balance</p>

:class

Строковая форма: задаёт перечисленные классы.

<input type="number" value="0">
<div :class="q('previous input').value < 18 ? 'warn big' : 'ok'"></div>

Форма объекта: каждый ключ добавляется или удаляется в зависимости от истинности его значения.

<input type="number" value="0">
<div :class="{
    warn: q('previous input').value < 18,
    ok:   q('previous input').value >= 18
}"></div>

Ключ может содержать несколько классов с общим условием. Если в ключе есть пробелы, заключите его в кавычки.

<input id="strict" type="checkbox">
<div :class="{ 'warn big': q('#strict').checked }">Notice</div>

:class управляет только классами, которые сама записывает. Другие классы, заданные в HTML, не затрагиваются. Если класс задан и статически, и в привязке, приоритет имеет привязка.

:text

Свяжите textContent элемента с выражением.

<input type="number" value="2">
<input type="number" value="3">
<p :text="q('first input').value * q('last input').value"></p>

Числа и другие значения, не являющиеся строками, преобразуются в строки.

:html

Свяжите innerHTML элемента с выражением.

<input value="world">
<div :html="`<b>${q('previous input').value}</b>`"></div>

Обязательно очищайте любые недоверенные данные.

:style

Строковая форма: строка объявлений CSS.

<input id="pct" type="range" value="50">
<div :style="`width: ${q('#pct').value}%; height: 8px; background: tomato`"></div>

Форма объекта: каждый ключ задаёт CSS-свойство. Ключи в camelCase преобразуются в kebab-case.

<input id="pct" type="range" value="50">
<input id="color" type="color" value="#ff0000">
<div :style="{
    width: q('#pct').value + '%',
    backgroundColor: q('#color').value,
    height: '8px'
}"></div>

:style управляет только свойствами, которые сама записывает. Другие встроенные свойства стиля не затрагиваются. Если свойство задано и статически, и в привязке, приоритет имеет привязка.

hx-live

Запасной вариант. Используйте его, если ни один :<attr> не подходит, или для многошаговой логики и побочных эффектов.

<input placeholder="search">
<div hx-live="
    let term = q('previous input').value;
    if (!term) { this.textContent = ''; return; }
    await debounce(250);
    this.textContent = await fetch('/search?q=' + encodeURIComponent(term))
                              .then(r => r.text());
"></div>

Вспомогательные функции

Вспомогательные функции работают в привязках hx-live и областях выражений htmx. В разделе Публичный API перечислены функции, доступные обычному JavaScript.

htmx.live.q('.row').attr.hidden = true;

В выражениях this обозначает элемент, полный API htmx доступен без префикса, а await работает на верхнем уровне (выражения являются функциями async).

<button hx-on:click="
    attr.disabled = true;
    await ajax('POST', '/save');
    delete attr.disabled;
">Save</button>

q()

q() возвращает прокси для набора элементов.

Если совпадений нет, чтение состояния возвращает undefined, а запись ничего не делает, в том числе через .closest.

q('.row')                       // every .row in the document
q('#bar')                       // single element by id
q(element)                      // wrap an existing element
q(nodeList)                     // wrap a collection

q('.row').count                 // number of matches
q('.row').arr()                 // Array<Element>
for (let e of q('.row')) {...}  // iterate

q('input').value                // value of the first match
q('input').value = ''           // assign to every match

q('.row').classList.add('done') // method calls chain through
q('.row').dataset.state = 'on'
q('button').click()

Синтаксис селекторов

q('first .foo')                 // first match in document order
q('last .foo')                  // last match
q('next .foo')                  // first match after this element
q('previous .foo')              // closest match before this element
q('closest .foo')               // nearest ancestor matching .foo
q('.foo in #scope')             // restrict to a specific root
q('.foo in this')               // restrict to the current element

next, previous и closest разрешаются относительно this. Для них нужна область выражения с текущим элементом. Для поиска предка, работающего в любом контексте, используйте closest(selector).

Цепочки вызовов

Вызов .q(...) для прокси повторно применяет синтаксис селекторов, используя каждый элемент как точку отсчёта:

q('.error').q('closest .field')   // surrounding .field of each .error
q('section').q('first .item')     // first .item per section
q('.row').q('next .row')          // each row's successor

Для обычного поиска потомков короче использовать CSS: q('.card .title') и q('.card').q('.title') эквивалентны. Используйте цепочки, если нужно направленное сопоставление для каждого найденного элемента.

Состояние и методы

Запись состояния и вызовы методов применяются ко всем найденным элементам:

q('input').attr.disabled = true          // set attribute on all
q('.row').toggle('.selected')            // toggle class on each
q('.tab.active').take('.active', '.tab') // move a class from peers to self
q('.tab').trigger('select', { id: 1 })   // CustomEvent on each
q('.list').insert('end', '<li>new</li>') // before / after / start / end

Локальное состояние и состояние ближайшего элемента

Для каждого набора состояния задана область видимости по умолчанию. data имеет область видимости DOM. У всех остальных наборов область видимости — элемент.

Набор Область видимости по умолчанию Разрешается в
data.* DOM ближайший элемент с атрибутом data-*
attr.* элемент сам элемент
aria.* элемент сам элемент
class.* элемент сам элемент

Значение по умолчанию одинаково для обычного выражения и для q(...):

data.count             // nearest data-count, starting at this element
q('.item').data.count  // nearest data-count, starting at each .item
attr.hidden            // the hidden attribute on this element
q('.item').attr.hidden // the hidden attribute on each .item

Два набора переопределяют значение по умолчанию. local принудительно задаёт область видимости элемента. closest принудительно задаёт область видимости DOM.

Область видимости элемента Область видимости DOM
Текущее выражение local.data.count closest.attr.hidden
Выбранные элементы q('.item').local.data.count q('.item').closest.attr.hidden

При записи в области видимости DOM, если владелец отсутствует, используется сам элемент. Удаление в области видимости DOM ничего не делает, если владельца нет. Если у нескольких элементов один владелец, hx-live обновляет его один раз.

closest(selector)

closest также принимает селектор. Он возвращает прокси запроса для ближайшего подходящего предка, начиная с самого элемента.

closest('.card')                  // nearest .card, including this element
closest('.card').data.name        // that card's nearest data-name
closest('.card').local.data.name  // that card's own data-name
q('.item').closest('.card')       // one entry per distinct card

Если совпадений нет, результат содержит ноль элементов, поэтому чтение возвращает undefined, а запись ничего не делает.

attr

Чтение и запись HTML-атрибутов этого элемента.

attr.hidden                     // boolean attribute presence
attr.hidden = true              // add hidden
delete attr.hidden              // remove hidden
attr['aria-expanded'] = false   // write aria-expanded="false"
attr.contenteditable = false    // write contenteditable="false"
class.active = true             // typed class state
attr.value = 'hello'            // set the value
delete attr['data-x']           // remove data-x

Используйте скобочную запись для имён, которые не являются идентификаторами JavaScript, а также для вычисляемых имён.

checked, selected и value считывают актуальное состояние элемента управления и записывают и свойство, и атрибут, поэтому их значения всегда совпадают.

Для <input type="number"> и <input type="range"> значение value считывается как число, а если поле пустое — как null. Все остальные элементы управления возвращают строку, поэтому <input type="text" value="007"> остаётся "007".

Числовые атрибуты (tabindex, colspan, rowspan, maxlength, minlength, size, span, start, rows, cols, width, height, min, max, step, low, high, optimum) при чтении возвращают числовые значения. Такие значения, как даты и step="any", остаются строками.

Используйте delete, чтобы удалить атрибут. Присваивание false записывает "false".

delete attr['data-x']
attr['data-x'] = null

Для состояния классов и ARIA используйте class.* и aria.*.

toggle(name, values?)

Переключайте или циклически меняйте класс, атрибут ARIA или атрибут этого элемента.

toggle('.active')                      // toggle class
toggle('aria-expanded')                // flip "true" ↔ "false"
toggle('hidden')                       // toggle attribute presence
toggle('data-view', 'grid', 'list')    // cycle attribute through values
toggle('.size', 'sm', 'md', 'lg')      // cycle classes (one at a time)
toggle('data-open', 'on', '')          // cycle: 'on' ↔ absent

Значения также можно передать одной строкой с разделителем | или одним массивом:

toggle('data-view', 'grid|list|table')
toggle('data-view', ['grid', 'list', 'table'])

Значения цикла сохраняют свои типы. Например, '1' остаётся строкой, а 1 — числом.

take(name, scope?)

Переместите класс или атрибут с соседних элементов на этот элемент. Передайте селектор scope, чтобы расширить или ограничить набор исходных элементов.

take('.selected', '.tab')              // become the selected tab among .tab
take('aria-current', 'nav a')          // become the current nav item
take('.active')                        // implicit scope: parent element's subtree

class

class, attr.class и attr['class'] возвращают одно и то же состояние классов. Читайте и задавайте принадлежность классу с помощью логических свойств:

<button class="pending"
        hx-on:click="
            class.pending = false;
            class.done = true
        ">
    Finish
</button>

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

class['is-active'] = true
delete class.pending

Задавайте сразу несколько классов с помощью class.assign({ ... }). Истинные значения добавляют классы, ложные — удаляют, а неуказанные классы сохраняются:

<button hx-on:click="class.assign({ active: true, loading: false })">Finish</button>

class расширяет встроенный DOMTokenList:

class.add('a', 'b')        // add classes
class.remove('a', 'b')     // remove classes
class.toggle('x', force?)  // toggle, optional force
class.replace('a', 'b')    // replace one class with another
class.contains('x')        // membership
class.value                // complete class attribute
class.length               // number of classes
class.assign({...})        // group add/remove by truthiness

Используйте каноническую форму, если прокси само является значением:

[...attr.class]            // class names
'x' in attr.class          // membership
use(attr.class)            // function argument

closest.class поддерживает assign, add, remove, toggle, replace и contains. Агрегатные свойства списка, такие как value, length и итерация, остаются локальными, поскольку для каждого класса может найтись свой ближайший элемент.

При чтении приоритет имеют встроенные свойства. Используйте class.contains('toggle'), чтобы прочитать класс с именем, совпадающим со встроенным свойством. Запись по ключу по-прежнему меняет принадлежность к классу, поэтому class.toggle = false удаляет класс с именем toggle.

Если выбрано несколько элементов, запись классов и изменяющие состояние методы DOMTokenList обновляют все совпадения. Чтение и возвращаемые значения используют первое совпадение.

Используйте q(), чтобы обратиться к другому элементу:

q('#menu').class.open = true

toggle() и take() работают с классами по имени:

toggle('.active')
take('.selected')

aria

Чтение и запись типизированного состояния ARIA этого элемента:

<div aria-busy="false">
    <button hx-on:click="q(this).closest.aria.busy = !q(this).closest.aria.busy">Toggle</button>
    <output :hidden="!q(this).closest.aria.busy">Busy</output>
</div>

Используйте closest.aria.*, если нужно явно выбрать ближайшее совпадение. Если при записи совпадений нет, состояние добавляется текущему элементу:

aria.busy                   // aria-busy on this element
closest.aria.busy           // nearest aria-busy, starting at this
q('#form').aria.busy        // aria-busy on the selected form
q('#form').closest.aria.busy // nearest aria-busy from #form up

Для переходов используйте toggle() и take():

<button aria-pressed="false"
        hx-on:click="toggle('aria-pressed')">
    Mute
</button>

<button aria-sort="ascending"
        hx-on:click="toggle('aria-sort', 'ascending', 'descending')">
    Name
</button>

<div role="tablist">
    <button role="tab" aria-selected="true">One</button>
    <button role="tab" aria-selected="false"
            hx-on:click="take('aria-selected')">Two</button>
</div>

toggle() переключает логическое значение ARIA между "true" и "false". take() записывает "false" для остальных элементов, а затем "true" для этого элемента.

Во всех формах действуют одинаковые правила для значений. Их можно использовать как логические значения, числа и массивы:

<button aria-busy="false"
        aria-controls="panel status"
        hx-on:click="
            aria.busy = !aria.busy;
            aria.controls = [...aria.controls, 'help'];
            q('#progress').aria.valueNow++
        ">
    Update
</button>

<section id="panel">...</section>
<p id="help">...</p>
<output id="status"></output>
<div id="progress" role="progressbar"
     aria-valuemin="0" aria-valuemax="100" aria-valuenow="51"></div>

После одного щелчка:

<button aria-busy="true" aria-controls="panel status help">Update</button>
<div id="progress" role="progressbar"
     aria-valuemin="0" aria-valuemax="100" aria-valuenow="52"></div>

Для удаления атрибута используйте любую из форм:

aria.current = null
delete aria.current

Типы значений

hx-live использует типы значений из спецификации WAI-ARIA 1.2.

Числа записываются в синтаксисе JSON. Например, aria-valuenow="0.5" считывается как 0.5, а aria-valuenow=".5" остаётся строкой ".5".

Логическое значение

  • aria-atomic
  • aria-busy
  • aria-checked
  • aria-current
  • aria-disabled
  • aria-expanded
  • aria-grabbed
  • aria-haspopup
  • aria-hidden
  • aria-invalid
  • aria-modal
  • aria-multiline
  • aria-multiselectable
  • aria-pressed
  • aria-readonly
  • aria-required
  • aria-selected

Число

  • aria-colcount
  • aria-colindex
  • aria-colspan
  • aria-level
  • aria-posinset
  • aria-rowcount
  • aria-rowindex
  • aria-rowspan
  • aria-setsize
  • aria-valuemax
  • aria-valuemin
  • aria-valuenow

Список токенов (string[])

  • aria-dropeffect
  • aria-relevant

Список ссылок на идентификаторы (string[])

  • aria-controls
  • aria-describedby
  • aria-flowto
  • aria-labelledby
  • aria-owns

Все остальные атрибуты aria-* остаются строками.

Значения aria.* можно использовать в hx-live, привязках, hx-on, значениях атрибутов js: и фильтрах hx-trigger.

data

Читайте и записывайте атрибуты data-* как JSON или обычный текст.

<div data-size="medium">
    <button hx-on:click="data.size = 'small'">S</button>
    <button hx-on:click="data.size = 'medium'">M</button>
    <button hx-on:click="data.size = 'large'">L</button>
    <p :text="`Size: ${data.size}`"></p>
</div>

data-* хранит состояние, общее для поддерева, поэтому data.* поднимается до ближайшего элемента с этим атрибутом. Для всех остальных пространств имён используется текущий элемент:

data.count                    // nearest data-count, starting at this
q(this).data.count            // data-count on this element only
q('#cart').data.count         // data-count on the selected cart
q('#cart').closest.data.count // nearest data-count from #cart up

При записи hx-live преобразует логические значения, числа, массивы и объекты в JSON. При чтении он преобразует JSON обратно в значения JavaScript:

<div data-count="1" data-active="false" data-cart="[]">
    <input id="sku" placeholder="Product code">
    <button hx-on:click="data.cart = [...data.cart, {sku: q('#sku').value, qty: data.count}]">Add to cart</button>
    <button hx-on:click="data.count++">+</button>
    <button hx-on:click="data.count--">−</button>
    <button hx-on:click="data.active = !data.active">Toggle details</button>
    <p :text="`Qty: ${data.count} | ${data.cart.length} items in cart`"></p>
</div>

Обычные строки, которые не являются допустимым JSON, возвращаются без изменений.

При записи строки, похожие на JSON, заключаются в кавычки:

data.code = '123' // data-code='"123"', reads as '123'
data.code = 123   // data-code="123", reads as 123

Для переходов используйте toggle() и take():

<button data-active
        hx-on:click="toggle('data-active')">Toggle details</button>

<button data-view="grid"
        hx-on:click="toggle('data-view', 'grid', 'list')">Change view</button>

Если значения не указаны, toggle() добавляет или удаляет атрибут. Передайте значения, чтобы циклически переключать их.

Используйте take(), чтобы перемещать состояние между соседними элементами:

<div>
    <button data-active="">One</button>
    <button hx-on:click="take('data-active')" data-active="">Two</button>
</div>

Щелчок по Two удаляет data-active из One и оставляет на Two пустой атрибут data-active="".

Прокси data допускает перечисление, поэтому работают spread-оператор объектов, деструктуризация с rest и Object.keys()/Object.entries():

<section data-x="1" data-y="2">
    <button data-y="3"
            hx-post="/cursor"
            hx-vals="js:{ ...data }">
        Send cursor
    </button>
</section>

Здесь hx-vals получает { x: 1, y: 3 }.

Удалите значение или присвойте undefined, чтобы удалить его атрибут:

data.count = undefined         // remove the nearest data-count
delete data.count              // remove the nearest data-count
delete closest.data.count      // remove the nearest data-count
delete q('#cart').data.count   // remove data-count from the selected cart

data.count = null записывает data-count="null". data.count = '' записывает пустой атрибут data-count="".

Используйте dataset, если нужны исходные строки:

this.dataset.count
q('#cart').dataset.count

Присваивание функции

Присвойте функцию, если новое значение зависит от текущего:

<section data-cart='[{"id":"1"}]'>
    <button value="2" hx-on:click="
        data.cart = cart => [...cart, { id: this.value }]
    ">
        Add
    </button>
</section>
[{"id":"1"}]  →  [{"id":"1"},{"id":"2"}]

Функция должна синхронно возвращать значение. Это также работает со свойствами DOM и attr.*:

q('#panel').hidden = hidden => !hidden
attr.hidden = hidden => !hidden

Поскольку :<attr> работает с data-*, производные значения также можно хранить в DOM:

<div data-first="Ada" data-last="Lovelace"
     :data-full="data.first + ' ' + data.last">
    <span :text="data.full"></span>
</div>

style

Сокращённая форма для this.style.

<input type="color" value="#ff0000">
<button hx-on:click="style.setProperty('--accent', q('previous input').value)">Apply</button>

matches(selector)

Сокращённая форма для this.matches(selector).

<button :aria-busy="matches('.htmx-request')" hx-post="/save">Save</button>

trigger(type, detail?, bubbles?)

Отправьте событие CustomEvent от этого элемента.

<li hx-on:click="trigger('select', { id: this.dataset.id })" data-id="42">Item</li>

insert(position, html)

Вставьте строку HTML, а затем обработайте добавленное содержимое, чтобы атрибуты htmx и hx-live в нём заработали.

insert('start',   '<li>first</li>')  // first child
insert('end',     '<li>last</li>')   // last child
insert('before',  '<hr>')            // sibling before
insert('after',   '<hr>')            // sibling after
insert('into',    '<p>fresh</p>')    // replace the children
insert('replace', '<p>fresh</p>')    // replace the element itself

Разбор сохраняет контекстную зависимость, поэтому элемент <tr>, вставленный в <tbody>, окажется в правильном месте. После replace исходный элемент отсоединяется.

<ul hx-on:click="insert('end', '<li>+</li>')">Click to add a row</ul>

Очищайте любые недоверенные данные.

debounce(ms)

Подождите ms миллисекунд. Если до завершения вызвать функцию ещё раз для того же элемента, предыдущий вызов будет отменён.

<input placeholder="search">
<div hx-live="
    await debounce(200);
    this.textContent = await fetch('/q?term=' + q('previous input').value).then(r => r.text());
"></div>

У каждого элемента свой канал.

forEvent(...args)

Завершите выполнение при следующем подходящем событии. Сочетайте имена событий, миллисекунды, интервалы и целевые элементы. Побеждает то, что произойдёт первым.

await forEvent('click')                 // next click on this element
await forEvent('click', 1000)           // click OR 1s timeout
await forEvent('a', 'b', '5s')          // any number of events / intervals
await forEvent(window, 'resize', '2s')  // event on an explicit target

Результатом будет выигравшее Event или исходное число либо строка интервала, если первым сработает тайм-аут.

Типичный вариант использования: ожидание завершения CSS-перехода с защитным тайм-аутом.

<button hx-on:click="
    class.add('fade-out');
    await forEvent('transitionend', 500);
    this.remove();
">Dismiss</button>

nextFrame()

Завершите выполнение на следующем кадре анимации.

<button hx-on:click="
    class.remove('shake');
    await nextFrame();
    class.add('shake');
">Replay shake</button>

ARIA как состояние

Атрибуты ARIA выполняют две задачи: описывают компонент для вспомогательных технологий и хранят состояние интерфейса.

Связывайте их с помощью :aria-* и используйте тот же атрибут для управления CSS. Так вы избежите классов .is-open, .active и .loading.

Атрибут Значение Типичное применение в интерфейсе
aria-expanded «открыт» Раскрывающийся блок, меню, аккордеон
aria-selected «активный» Вкладки, параметр списка
aria-pressed «переключатель включён» Кнопка-переключатель (жирный шрифт, отключение звука)
aria-checked «состояние флажка» Пользовательские флажки и переключатели
aria-busy «загрузка» Форма во время отправки, список во время загрузки данных
aria-disabled «взаимодействие недоступно» Неактивный элемент управления, не являющийся кнопкой
aria-current «текущий» Элемент навигации, хлебная крошка, шаг
aria-hidden «скрыт от вспомогательных технологий» Декоративное содержимое

Раскрывающийся блок.

Для одного встроенного раздела подойдёт стандартный элемент <details>. Используйте aria-expanded, если кнопка и целевой элемент находятся в разных частях DOM.

<header>
    <button hx-on:click="aria.expanded = !aria.expanded" aria-expanded="false">Menu</button>
</header>
<aside :hidden="!q('header button').aria.expanded">...</aside>

Кнопка-переключатель.

<button hx-on:click="aria.pressed = !aria.pressed" aria-pressed="false">Bold</button>
[aria-pressed="true"] { background: lightblue }

Вкладки.

<div role="tablist">
    <button role="tab" hx-on:click="take('aria-selected')" aria-selected="true">A</button>
    <button role="tab" hx-on:click="take('aria-selected')" aria-selected="false">B</button>
    <button role="tab" hx-on:click="take('aria-selected')" aria-selected="false">C</button>
</div>

take('aria-selected') записывает "false" для всех остальных вкладок, а затем "true" для текущей.

Состояние загрузки.

<form :aria-busy="matches('.htmx-request')" hx-post="/save">
    <input name="email">
    <button type="submit">Save</button>
</form>
[aria-busy="true"] { opacity: 0.5; pointer-events: none }

Нелогические значения ARIA. Строки передаются без изменений, поэтому в привязках работают aria-current="page", aria-pressed="mixed" и числовые значения ARIA (aria-valuenow="50"):

<a :aria-current="location.pathname === '/home' ? 'page' : false" href="/home">Home</a>
<button :aria-pressed="state.bold ? 'mixed' : !!state.bold">Bold</button>
<div role="slider" :aria-valuenow="q('#slider').value"></div>

Расширенные примеры

Автоматическое скрытие временных сообщений

Ответ сервера:

HX-Trigger: {"flash":{"target":"#flash", "level":"success", "message":"Saved"}}

Состояние клиента:

<style>
#flash:empty { display: none; }
</style>

<div id="flash"
     data-message=""
     data-level=""
     hx-on="flash -> data.message = message; data.level = level;
                     await timeout('3s');
                     data.message = ''"
     :text="data.message"
     :.success="data.level === 'success'"
     :.error="data.level === 'error'"></div>

Как это работает

Триггеры повторного запуска

Каждое из этих изменений приводит к повторному вычислению всех активных выражений:

  • добавление и удаление элементов DOM, изменение атрибутов и текста
  • события input или change любого элемента управления
  • завершение подстановки htmx (повторные вычисления приостанавливаются в середине подстановки и выполняются один раз в конце)

Несколько синхронных изменений объединяются в одно повторное вычисление.

Самоизменения безопасны

Записи, выполняемые hx-live, не запускают повторное вычисление.

Медленные выражения

После изменения hx-live один раз запускает каждое активное выражение. Если это занимает больше 16ms, hx-live выводит одно предупреждение:

htmx: hx-live expressions took 18.4ms.

Предупреждение не останавливает выполнение выражений.

Согласование с подстановками htmx

Повторные вычисления откладываются между событиями htmx:before:swap и htmx:finally:swap. После завершения подстановки выполняется одно сводное повторное вычисление независимо от объёма изменённой разметки.

Очистка

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

Потомки hx-ignore не регистрируются.

Логические атрибуты, ARIA и другие типы атрибутов

:<attr> записывает значение по-разному в зависимости от атрибута, следуя соглашениям HTML.

Логические атрибуты (disabled, required, open, readonly, inert, …). Истинное значение добавляет атрибут, ложное — удаляет.

<button  :disabled="truthyExpr">   <!-- <button disabled="">  -->
<button  :disabled="falsyExpr">    <!-- <button>              -->
<input   :required="truthyExpr">   <!-- <input required="">   -->
<input   :required="falsyExpr">    <!-- <input>               -->
<details :open="truthyExpr">       <!-- <details open="">     -->
<details :open="falsyExpr">        <!-- <details>             -->
<input   :readonly="truthyExpr">   <!-- <input readonly="">   -->
<input   :readonly="falsyExpr">    <!-- <input>               -->
<div     :inert="truthyExpr">      <!-- <div inert="">        -->
<div     :inert="falsyExpr">       <!-- <div>                 -->

Атрибуты ARIA (aria-*). Значения преобразуются в строки. null или undefined удаляют атрибут.

<button :aria-expanded="truthyExpr">    <!-- <button aria-expanded="true">  -->
<button :aria-expanded="falsyExpr">     <!-- <button aria-expanded="false"> -->
<button :aria-pressed="'mixed'">        <!-- <button aria-pressed="mixed">  -->

Строковые логические атрибуты (contenteditable, draggable, spellcheck, writingsuggestions). Значения JSON разбираются, остальные сохраняются как строки.

<div :contenteditable="true">                <!-- <div contenteditable="true">           -->
<div :contenteditable="false">               <!-- <div contenteditable="false">          -->
<div :contenteditable="'plaintext-only'">    <!-- <div contenteditable="plaintext-only"> -->

Атрибуты, связанные со свойствами DOM (checked, value, selected, hidden). Синхронизируются и свойство DOM, и HTML-атрибут. hidden сохраняет состояние "until-found".

<input type="checkbox" :checked="true">     <!-- .checked = true,  checked=""        -->
<input type="checkbox" :checked="false">    <!-- .checked = false, attribute removed -->
<input :value="'hello'">                    <!-- .value = "hello", value="hello"     -->

Все остальные атрибуты. Значение преобразуется в строку. null или undefined удаляют атрибут.

<a :href="'/profile'">    <!-- <a href="/profile"> -->
<a :href="null">          <!-- <a>                 -->
<a :href="false">         <!-- <a href="false">    -->
<a :href="''">            <!-- <a href="">         -->

Публичный API

Используйте эти вспомогательные функции из обычного JavaScript:

htmx.live.q('.row')
htmx.live.$('.row')
htmx.live.take('.tab.active', '.active', '.tab')
htmx.live.toggle('.tab', 'data-view', 'grid', 'list')
htmx.live.debounce(200)
htmx.live.forEvent(window, 'resize', '2s')
htmx.live.nextFrame()
htmx.live.q('.row').local.data.count    // force element scoping
htmx.live.q('.row').closest.data.count  // force DOM scoping
htmx.live.q('.row').closest('.card')    // nearest matching ancestor

htmx.live.refresh() принудительно запускает повторное вычисление. Используйте его, если выражение читает данные из источника, за которым наблюдатель не может следить (переменная JS, геттер, внешнее хранилище), и вы только что изменили их.

window.appState = 'loading';
htmx.live.refresh();

Направленные селекторы (next, previous, closest) требуют текущего элемента, поэтому строковые формы не работают в htmx.live.q. Вызов closest(selector) работает в любом месте, поскольку начинается с выбранных элементов:

htmx.live.q('.item').closest('.card').data.name

Конфигурация

config.live.inputDebounce

Задаёт, сколько времени hx-live ждёт после события input. Укажите количество миллисекунд или строку интервала. По умолчанию используется 100ms.

<meta name="htmx-config" content="live.inputDebounce:20ms">

config.live.bindPrefix

Управляет кратким префиксом для атрибутов привязки. По умолчанию используется ':' (или префикс автоматически отключается при обнаружении Alpine.js).

Значение Эффект Пример атрибута
undefined (по умолчанию) :attr включён, если не обнаружена Alpine :hidden, :text, :.active
':' :attr краткая форма принудительно включена :hidden, :text, :.active
'' или ложное значение Краткая форма отключена, работает только hx-live:attr hx-live:hidden
'hx:' Пользовательский префикс hx:hidden, hx:text, hx:.active

Полная форма hx-live:<attr> работает всегда, независимо от этого параметра.

Автоопределение Alpine.js

Если при инициализации hx-live существует window.Alpine и не настроен bindPrefix, краткая форма : автоматически отключается и выводится предупреждение в консоль. Это можно исправить так:

  • Используйте полную форму hx-live:<attr> (она работает всегда).
  • Или явно задайте не конфликтующий префикс:
<!-- Use hx: as short form instead -->
<meta name="htmx-config" content='{"live":{"bindPrefix":"hx:"}}'>
  • Или принудительно включите :, если понимаете, что делаете:
<meta name="htmx-config" content='{"live":{"bindPrefix":":"}}'>

Отключение краткой формы вручную

Если Alpine загружается после hx-live (или вы хотите явно задать это поведение), отключите краткую форму самостоятельно:

<meta name="htmx-config" content='{"live":{"bindPrefix":""}}'>

При использовании bindPrefix: '' используйте каноническую полную форму:

<!-- Alpine handles :class, hx-live handles hx-live:text -->
<p :class="alpineVar" hx-live:text="q('#name').value"></p>

При использовании bindPrefix: 'hx:':

<!-- Alpine handles :class, hx-live handles hx:text -->
<p :class="alpineVar" hx:text="q('#name').value"></p>

config.live.useDollar

Включает $() как псевдоним для q():

<!-- Because jQuery rocks -->
<meta name="htmx-config" content="live.useDollar:true">

<input id="name">
<p :text="$('#name').value"></p>

Псевдоним работает в следующих случаях:

  • hx-live
  • привязки :attr
  • hx-on
  • атрибуты js:
  • фильтры hx-trigger

По умолчанию используется false.

Примечания

  • Источник истины — DOM. Для совместного использования состояния выражениями применяйте атрибуты ARIA, атрибуты data-* (прокси data упрощает работу с ними) или скрытые поля ввода.
  • При использовании стилей подстановки с морфингом (innerMorph / outerMorph) ответы сервера по умолчанию перезаписывают атрибуты data-*. Чтобы сохранять состояние на стороне клиента при морфинге, добавьте префикс к morphIgnore — например, morphIgnore:["data-"] защитит от перезаписи все атрибуты data-*. Подстановки без морфинга (innerHTML, outerHTML) полностью заменяют DOM, поэтому состояние следует хранить в элементе-предке, который не заменяется.
  • Выражения должны быть безопасны для многократного выполнения. Избегайте безусловных вызовов fetch(). Используйте debounce или проверяйте, изменилось ли значение.

Асинхронный код

Используйте await на верхнем уровне в hx-live. Не добавляйте асинхронную обёртку.

<!-- Good: htmx handles errors -->
<div hx-live="await update()"></div>

<!-- Bad: errors go unhandled -->
<div hx-live="(async () => { await update() })()"></div>

См. также

  • hx-on (атрибут)
  • Локальность поведения (эссе)

Licensed under the Zero-Clause BSD License.
https://four.htmx.org/extensions/hx-live/

Spec-Zone.ru

Настройки Оффлайн Что нового Помощь О нас
Spec-Zone .ru
спецификации, руководства, описания, API