diff --git a/src/ext/hx-live.js b/src/ext/hx-live.js index fbbef26e1..27476ef95 100644 --- a/src/ext/hx-live.js +++ b/src/ext/hx-live.js @@ -69,96 +69,107 @@ }); } - let BOOLEAN_ATTRS = new Set([ - 'disabled','hidden','required','readonly','open','inert', - 'multiple','autofocus','novalidate','default','reversed', - 'loop','muted','controls','autoplay','playsinline', - 'formnovalidate','async','defer','ismap','typemustmatch', - 'allowfullscreen','itemscope','nomodule' - ]); - let PROPERTY_ATTRS = new Set(['checked','value','selected']); - let STRINGY_BOOLEAN_ATTRS = new Set(['contenteditable','draggable','spellcheck']); + let BOOLEAN_ATTRS = new Set('disabled required readonly open inert multiple autofocus novalidate default reversed loop muted controls autoplay playsinline formnovalidate async defer ismap typemustmatch allowfullscreen itemscope nomodule alpha headingreset'.split(' ')); + let PROPERTY_BINDING_ATTRS = new Set('checked value selected hidden'.split(' ')); + let STRING_BOOLEAN_ATTRS = new Set('contenteditable draggable spellcheck writingsuggestions'.split(' ')); + let NUMERIC_ATTRS = new Set('tabindex colspan rowspan maxlength minlength size span start rows cols width height min max step low high optimum'.split(' ')); - /** - * Get or set an attribute, class, or property-backed value on one or more elements. - * - * @param {Element[]} elts - Target elements. - * @param {string} name - Class (`.foo`), `'class'`, or attribute name. - * @param {*} [value] - Value to set. Omit for getter (reads from first element). - * @returns {*} Getter result; setter returns nothing. - * - * @example - * attr('hidden') // boolean: is hidden present? - * attr('hidden', true) // set hidden="" - * attr('.active') // boolean: has class .active? - * attr('.active', cond) // add/remove class - * attr('class', 'foo bar') // multi-class string - * attr('class', { active: cond }) // multi-class object - * attr('aria-expanded', open) // ARIA: always "true"/"false" - * attr('value', 'hello') // sync DOM property + attribute - * attr('contenteditable', false) // "false", not removed - * attr('data-x', null) // remove attribute - */ - function applyAttr(elts, name, ...rest) { - let isClass = name.startsWith('.'); - let isMultiClass = name === 'class'; - let isAria = name.startsWith('aria-'); - let isPropAttr = PROPERTY_ATTRS.has(name); - - if (rest.length === 0) { - let e = elts[0]; - if (!e) return undefined; - if (isClass) return e.classList.contains(name.slice(1)); - if (isMultiClass) return e.getAttribute('class'); - if (isAria) return e.getAttribute(name) === 'true'; - if (BOOLEAN_ATTRS.has(name)) return e.hasAttribute(name); - if (isPropAttr) return e[name]; - return e.getAttribute(name); + function normalizeAttrName(elt, name) { + return elt instanceof HTMLElement ? name.toLowerCase() : name; + } + + function readAttr(element, name) { + name = normalizeAttrName(element, name); + if (name.startsWith('aria-')) return readAria(element, name.slice(5)); + if (name.startsWith('data-')) return readData(element, name); + if (name === 'value' && (element.type === 'number' || element.type === 'range')) { + return element.value === '' ? null : element.valueAsNumber; } + if (PROPERTY_BINDING_ATTRS.has(name)) return element[name]; + if (BOOLEAN_ATTRS.has(name) || name.startsWith('shadowroot') && name !== 'shadowrootmode' && name !== 'shadowrootslotassignment') return element.hasAttribute(name); + let value = element.getAttribute(name); + if (value != null && STRING_BOOLEAN_ATTRS.has(name)) try { return JSON.parse(value.toLowerCase()); } catch {} + if (NUMERIC_ATTRS.has(name) && value?.trim() && isFinite(value)) return +value; + return value; + } - let value = rest[0]; - for (let e of elts) { - if (isClass) { - e.classList.toggle(name.slice(1), !!value); - if (e.classList.length === 0) e.removeAttribute('class'); - } else if (isMultiClass) { - applyMultiClass(e, value); - } else if (isAria) { - // Strings and numbers pass through (e.g. aria-current="page", - // aria-pressed="mixed", aria-valuenow="50"). Other values coerce - // to "true"/"false". Never removed. - let attrVal = (typeof value === 'string' || typeof value === 'number') - ? String(value) - : (value ? 'true' : 'false'); - e.setAttribute(name, attrVal); - } else if (isPropAttr) { - if (name === 'checked' || name === 'selected') { - let present = !!value; - e[name] = present; - e.toggleAttribute(name, present); - } else if (value === false || value == null) { - e[name] = (typeof e[name] === 'boolean') ? false : ''; - e.removeAttribute(name); - } else if (value === true) { - e[name] = true; - e.setAttribute(name, ''); - } else { - e[name] = value; - e.setAttribute(name, String(value)); + function writeAttr(element, name, value) { + name = normalizeAttrName(element, name); + if (typeof value === 'function') { + value = value(readAttr(element, name)); + if (typeof value?.then === 'function') throw new TypeError('hx-live: assignment returned a promise'); + } + if (name.startsWith('aria-')) { + writeAria(element, name.slice(5), value); + } else if (name.startsWith('data-')) { + writeData(element, name, value); + } else if (PROPERTY_BINDING_ATTRS.has(name)) { + applyPropertyBinding(element, name, value); + } else if (BOOLEAN_ATTRS.has(name) || name.startsWith('shadowroot') && name !== 'shadowrootmode' && name !== 'shadowrootslotassignment') { + element.toggleAttribute(name, !!value); + } else if (value == null) { + element.removeAttribute(name); + } else { + element.setAttribute(name, String(value)); + } + } + + function writeTargets(elts, target) { + return target ? new Set(elts.map(target)) : elts; + } + + function attrName(state, prop) { + let prefix = state.prefix; + return prefix + (prefix === 'data-' ? camelToKebab(prop) : prefix ? prop.toLowerCase() : prop); + } + + function targetFor(state, elt, name) { + if (!state.cascades) return state.prefix ? elt.hasAttribute(name) && elt : elt; + while (elt && !elt.hasAttribute(name)) elt = elt.parentElement; + return elt; + } + + function writeProxy(state, prop, value, remove) { + if (typeof prop !== 'string') return false; + let name = attrName(state, prop); + writeTargets(state.elts, state.cascades && (elt => targetFor(state, elt, name) || !remove && elt)) + .forEach(elt => elt && writeAttr(elt, name, value)); + return true; + } + + let attrHandler = { + get: (state, prop) => { + if (!state.prefix && prop === 'class') return state.scope.class; + if (typeof prop !== 'string') return undefined; + let name = attrName(state, prop); + let target = state.elts[0] && targetFor(state, state.elts[0], name); + return target ? readAttr(target, name) : undefined; + }, + set: (state, prop, value) => writeProxy(state, prop, value), + deleteProperty: (state, prop) => writeProxy(state, prop, undefined, true), + has: (state, prop) => state.prefix === 'data-' && typeof prop === 'string' && + !!state.elts[0] && !!targetFor(state, state.elts[0], attrName(state, prop)), + ownKeys: state => { + if (state.prefix !== 'data-') return []; + let result = [], seen = new Set(); + for (let node = state.elts[0]; node; node = state.cascades ? node.parentElement : null) { + for (let key of Object.keys(node.dataset)) if (key !== 'htmxPowered' && !seen.has(key)) { + seen.add(key); + result.push(key); } - } else if (BOOLEAN_ATTRS.has(name)) { - if (value) e.setAttribute(name, ''); - else e.removeAttribute(name); - } else if (STRINGY_BOOLEAN_ATTRS.has(name)) { - if (value === null || value === undefined) e.removeAttribute(name); - else if (value === true) e.setAttribute(name, 'true'); - else if (value === false) e.setAttribute(name, 'false'); - else e.setAttribute(name, String(value)); - } else { - if (value === null || value === undefined || value === false) e.removeAttribute(name); - else e.setAttribute(name, value === true ? '' : String(value)); + } + return result; + }, + getOwnPropertyDescriptor: (state, prop) => { + if (state.prefix !== 'data-' || typeof prop !== 'string' || prop === 'htmxPowered') return; + if (state.elts[0] && targetFor(state, state.elts[0], attrName(state, prop))) { + return { enumerable: true, configurable: true }; } } + }; + + function makeAttrProxy(elts, cascades, scope, prefix = '') { + return new Proxy({ elts, cascades, scope, prefix }, attrHandler); } function applyStyleBinding(elt, value) { @@ -194,72 +205,204 @@ return s.replace(/[A-Z]/g, m => '-' + m.toLowerCase()); } - // `data.foo` reads/writes to closest ancestor with `data-foo`. - // `has` trap lets `hx-on:click="with (data) { x++; y-- }"` work: data-* keys - // bind to the proxy, all other identifiers fall through to outer scope. - function makeDataProxy(elt) { - return new Proxy({}, { - get: (_, prop) => { - if (typeof prop !== 'string') return undefined; - let kebab = camelToKebab(prop); - let ancestor = elt.closest('[data-' + kebab + ']'); - if (!ancestor) return undefined; - let raw = ancestor.dataset[prop]; - try { return JSON.parse(raw); } catch { return raw; } - }, - set: (_, prop, val) => { - if (typeof prop !== 'string') return false; - let kebab = camelToKebab(prop); - let target = elt.closest('[data-' + kebab + ']') || elt; - target.dataset[prop] = typeof val === 'string' ? val : JSON.stringify(val); - return true; + function parseJSON(value) { + try { return JSON.parse(value); } catch { return value; } + } + + function readData(elt, name) { + let raw = elt.getAttribute(name); + return raw === null ? undefined : parseJSON(raw); + } + + // Protect quoted text and regex literals, then recurse into template expressions. + let CLASS_TOKEN = /(['"`\/])(?:\\.|(?!\1).)*\1|(? { + if (!quote) return 'attr.class'; + if (quote === '`') return token.replace( + /\$\{((?:[^{}]|\{[^{}]*\})*)\}/g, + (_, code) => '${' + rewriteClass(code) + '}' + ); + return token; + }); + } + + let stringAria = new Set('activedescendant details errormessage keyshortcuts label placeholder roledescription valuetext'.split(' ')); + let listAria = new Set('controls describedby dropeffect flowto labelledby owns relevant'.split(' ')); + function readClass(elt, name) { + return !!elt?.classList.contains(name); + } + + function writeClass(elt, name, value) { + if (typeof value === 'function') { + value = value(readClass(elt, name)); + if (typeof value?.then === 'function') throw new TypeError('hx-live: assignment returned a promise'); + } + elt.classList.toggle(name, !!value); + if (!elt.classList.length) elt.removeAttribute('class'); + } + + function makeClassProxy(elts, cascades = false) { + let first = elts[0]; + let classTarget = (elt, name) => cascades ? elt.closest('.' + CSS.escape(name)) : elt; + let read = name => readClass(first && classTarget(first, name), name); + let write = (name, value) => writeTargets(elts, elt => classTarget(elt, name) || elt) + .forEach(elt => writeClass(elt, name, value)); + let methods = { + assign(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + console.warn('hx-live: class.assign expects an object.', { elts }); + return; + } + writeClasses(write, value); }, - has: (_, prop) => { - if (typeof prop !== 'string') return false; - let kebab = camelToKebab(prop); - return !!elt.closest('[data-' + kebab + ']'); + add: (...classes) => classes.forEach(name => write(name, true)), + remove: (...classes) => classes.forEach(name => write(name, false)), + contains: read, + toggle(name, force) { + let result = force ?? !read(name); + write(name, current => force ?? !current); + return result; }, - ownKeys: () => { - let result = []; - let seen = new Set(); - for (let node = elt; node; node = node.parentElement) { - for (let key of Object.keys(node.dataset)) { - if (key !== 'htmxPowered' && !seen.has(key)) { - seen.add(key); - result.push(key); - } - } + replace(oldClass, newClass) { + if (cascades) { + if (!read(oldClass)) return false; + write(oldClass, false); + write(newClass, true); + return true; + } + let result; + for (let i = 0; i < elts.length; i++) { + let next = elts[i].classList.replace(oldClass, newClass); + if (i === 0) result = next; } return result; - }, - getOwnPropertyDescriptor: (_, prop) => { - if (typeof prop !== 'string' || prop === 'htmxPowered') return; - let kebab = camelToKebab(prop); - if (elt.closest('[data-' + kebab + ']')) return { enumerable: true, configurable: true }; } + }; + return new Proxy({}, { + get: (_, name) => { + if (typeof name === 'string' && methods[name]) return methods[name]; + let list = !cascades && first?.classList; + if (list && name in list) { + let member = list[name]; + return typeof member === 'function' ? member.bind(list) : member; + } + if (!first) return name === Symbol.iterator ? () => [][Symbol.iterator]() : undefined; + return typeof name === 'string' ? read(name) : undefined; + }, + set: (_, name, value) => { + if (typeof name !== 'string') return false; + if (!cascades && name === 'value') for (let elt of elts) elt.classList.value = value; + else write(name, value); + return true; + }, + deleteProperty: (_, name) => { + if (typeof name !== 'string') return false; + write(name, false); + return true; + }, + has: (_, name) => typeof name === 'string' && read(name), + ownKeys: () => !cascades && first ? [...first.classList] : [], + getOwnPropertyDescriptor: (_, name) => !cascades && read(name) + ? { enumerable: true, configurable: true } + : undefined }); } - function applyMultiClass(elt, value) { - let prop = api.htmxProp(elt); - let oldManaged = prop.liveClasses || new Set(); - let newManaged = new Set(); + function makeStateScope(elts, cascades) { + let data, aria, classes, attr; + let scope = { + get data() { return data ||= makeAttrProxy(elts, cascades, null, 'data-'); }, + get aria() { return aria ||= makeAttrProxy(elts, cascades, null, 'aria-'); }, + get class() { return classes ||= makeClassProxy(elts, cascades); }, + get attr() { return attr ||= makeAttrProxy(elts, cascades, scope); } + }; + return scope; + } - if (typeof value === 'string') { - for (let c of value.trim().split(/\s+/).filter(Boolean)) { - newManaged.add(c); - elt.classList.add(c); - } - } else if (value && typeof value === 'object') { - for (let [key, cond] of Object.entries(value)) { - for (let c of key.trim().split(/\s+/).filter(Boolean)) { - newManaged.add(c); - elt.classList.toggle(c, !!cond); - } + function makeExpressionScope(elt) { + let local = makeStateScope([elt], false); + let closest = makeStateScope([elt], true); + return { + q: makeQ(elt), + forEvent: (...args) => forEvent(elt, ...args), + nextFrame: () => new Promise(r => requestAnimationFrame(r)), + trigger: (type, detail, bubbles) => htmx.trigger(elt, type, detail, bubbles), + debounce: getDebounce(elt), + take: (name, scope) => applyTake([elt], name, scope), + toggle: (name, ...values) => applyToggle(elt, name, ...values), + attr: local.attr, + insert: (pos, html) => elt.insertAdjacentHTML(positions[pos], html), + matches: sel => elt.matches(sel), + style: elt.style, + data: closest.data, + aria: local.aria, + closest + }; + } + + function writeAria(elt, key, value) { + let name = 'aria-' + key; + if (value == null) elt.removeAttribute(name); + else elt.setAttribute(name, listAria.has(key) && Array.isArray(value) ? value.join(' ') : String(value)); + } + + function readAria(elt, key) { + let value = elt?.getAttribute('aria-' + key); + if (value == null) return undefined; + if (stringAria.has(key)) return value; + if (listAria.has(key)) return value.trim() ? value.trim().split(/\s+/) : []; + return parseJSON(value); + } + + function writeData(elt, name, value) { + if (value === undefined) elt.removeAttribute(name); + else elt.setAttribute(name, typeof value === 'object' || parseJSON(value) !== value ? JSON.stringify(value) : value); + } + + function applyPropertyBinding(elt, name, value) { + if (name === 'checked' || name === 'selected') { + let present = !!value; + elt[name] = present; + elt.toggleAttribute(name, present); + } else if (value === false || value == null) { + elt[name] = typeof elt[name] === 'boolean' ? false : ''; + elt.removeAttribute(name); + } else if (value === true) { + elt[name] = true; + elt.setAttribute(name, ''); + } else { + elt[name] = value; + elt.setAttribute(name, String(value)); + } + } + + function applyClassBinding(elt, name, value) { + if (name === 'class') { + applyMultiClass(elt, value); + } else { + writeClass(elt, name.slice(1), value); + } + } + + function writeClasses(write, value) { + let written = []; + if (typeof value === 'string') value = { [value]: true }; + if (value && typeof value === 'object') for (let [classes, enabled] of Object.entries(value)) { + for (let name of classes.trim().split(/\s+/).filter(Boolean)) { + written.push(name); + write(name, !!enabled); } } - for (let c of oldManaged) if (!newManaged.has(c)) elt.classList.remove(c); - if (elt.classList.length === 0) elt.removeAttribute('class'); + return written; + } + + function applyMultiClass(elt, value) { + let prop = api.htmxProp(elt); + let oldManaged = prop.liveClasses || new Set(); + let newManaged = new Set(writeClasses((name, value) => writeClass(elt, name, value), value)); + for (let c of oldManaged) if (!newManaged.has(c)) writeClass(elt, c, false); prop.liveClasses = newManaged; } @@ -272,7 +415,7 @@ : scope.nodeType ? scope : null; let sources = root ? [root, ...root.querySelectorAll(auto)] - : document.querySelectorAll(typeof scope === 'string' ? scope : scope?.from || auto); + : targets.length ? document.querySelectorAll(typeof scope === 'string' ? scope : scope?.from || auto) : []; let targetSet = new Set(targets); for (let s of sources) { if (targetSet.has(s)) continue; @@ -294,21 +437,20 @@ function forEvent(elt, ...args) { let target = elt || document; - for (let a of args) if (a?.nodeType) target = a; + for (let a of args) if (a?.addEventListener) target = a; return new Promise(resolve => { let cleanups = [], done = false; let fire = v => { if (done) return; done = true; for (let c of cleanups) c(); resolve(v); }; for (let a of args) { - if (a == null || a?.nodeType) continue; + if (a == null || a?.addEventListener) continue; let ms = typeof a === 'number' ? a : (typeof a === 'string' ? htmx.parseInterval(a) : undefined); - if (ms !== undefined && ms > 0) { + if (ms > 0) { let id = setTimeout(() => fire(a), ms); cleanups.push(() => clearTimeout(id)); } else if (typeof a === 'string') { - let h = evt => fire(evt); - target.addEventListener(a, h, { once: true }); - cleanups.push(() => target.removeEventListener(a, h)); + target.addEventListener(a, fire, { once: true }); + cleanups.push(() => target.removeEventListener(a, fire)); } } }); @@ -317,27 +459,27 @@ /** * Toggle or cycle a class, ARIA attribute, or attribute on an element. * - * @param {string} name - Class (`.foo`) or attribute name. - * @param {string|string[]} [values] - Cycle list (pipe-delimited string or array). Omit for binary flip. * @param {Element} element - DOM element to mutate. + * @param {string} name - Class (`.foo`) or attribute name. + * @param {...(string|string[])} values - Cycle list, as separate arguments, a pipe-delimited string, or an array. Omit for binary flip. * * @example * toggle('.active') // toggle class * toggle('aria-expanded') // flip "true" <-> "false" * toggle('hidden') // toggle attribute presence - * toggle('data-view', 'grid|list|table') // cycle attribute through values + * toggle('data-view', 'grid', 'list') // cycle attribute through values + * toggle('data-view', 'grid|list|table') // same, pipe-delimited * toggle('.size', 'sm|md|lg') // cycle classes (one at a time) * toggle('data-open', 'on|') // 'on' <-> absent slot */ - function applyToggle(name, values, element) { + function applyToggle(element, name, ...values) { let isClass = name.startsWith('.'); let key = isClass ? name.slice(1) : name; let isAria = name.startsWith('aria-'); - let asArray = values && (typeof values === 'string' - ? values.split('|').map(v => v.trim()) - : values); + let list = values.length > 1 ? values : values[0]; + if (typeof list === 'string') list = list.split('|').map(value => value.trim()); - if (!asArray) { + if (!list) { if (isClass) element.classList.toggle(key); else if (isAria) { let cur = element.getAttribute(name); @@ -348,16 +490,14 @@ return; } if (isClass) { - let cur = asArray.findIndex(v => v && element.classList.contains(v)); - if (cur >= 0) element.classList.remove(asArray[cur]); - let next = asArray[(cur + 1) % asArray.length]; + let cur = list.findIndex(v => v && element.classList.contains(v)); + if (cur >= 0) element.classList.remove(list[cur]); + let next = list[(cur + 1) % list.length]; if (next) element.classList.add(next); } else { - let curVal = element.getAttribute(name) ?? ''; - let cur = asArray.indexOf(curVal); - let next = asArray[(cur + 1) % asArray.length]; - if (next === '') element.removeAttribute(name); - else element.setAttribute(name, next); + let cur = list.indexOf(readAttr(element, name) ?? ''); + let next = list[(cur + 1) % list.length]; + writeAttr(element, name, next === '' ? undefined : next); } } @@ -444,13 +584,12 @@ }; } - let arrayMethods = new Set(['map', 'filter', 'reduce', 'reduceRight', 'forEach', 'some', 'every', - 'find', 'findIndex', 'findLast', 'findLastIndex', 'flatMap', 'flat', - 'slice', 'indexOf', 'lastIndexOf', 'includes', 'join', 'at']); + let arrayMethods = 'map filter reduce reduceRight forEach some every find findIndex findLast findLastIndex flatMap flat slice indexOf lastIndexOf includes join at'.split(' '); let positions = { before: 'beforebegin', after: 'afterend', start: 'afterbegin', end: 'beforeend' }; function qProxy(elts) { + let local, closest; let proxy = new Proxy({}, { get: (_, p) => { if (p === 'count') return elts.length; @@ -464,21 +603,29 @@ if (p === 'trigger') return (t, d, b) => { elts.forEach(e => htmx.trigger(e, t, d, b)); return proxy; }; if (p === 'insert') return (pos, s) => { elts.forEach(e => e.insertAdjacentHTML(positions[pos], s)); return proxy; }; if (p === 'take') return (name, scope) => { applyTake(elts, name, scope); return proxy; }; - if (p === 'toggle') return (name, values) => { elts.forEach(e => applyToggle(name, values, e)); return proxy; }; - if (p === 'attr') return (name, ...rest) => { - if (rest.length === 0) return applyAttr(elts, name); - applyAttr(elts, name, ...rest); - return proxy; - }; - if (p === 'data') return elts[0] ? makeDataProxy(elts[0]) : undefined; - if (arrayMethods.has(p)) return elts[p].bind(elts); + if (p === 'toggle') return (name, ...values) => { elts.forEach(e => applyToggle(e, name, ...values)); return proxy; }; + if (p === 'attr' || p === 'data' || p === 'class' || p === 'aria') { + return (local ||= makeStateScope(elts, false))[p]; + } + if (p === 'closest') return closest ||= makeStateScope(elts, true); + if (arrayMethods.includes(p)) return elts[p].bind(elts); let v = elts[0]?.[p]; if (typeof v === 'function') return (...a) => elts.map(e => e[p](...a))[0]; if (v && typeof v === 'object') return qProxy(elts.map(e => e[p])); return v; }, - set: (_, p, v) => { - elts.forEach(e => e[p] = v); + set: (_, prop, value) => { + elts.forEach(elt => { + let current = elt[prop]; + if (current == null || typeof current === 'function') elt[prop] = value; + else if (typeof value === 'function') { + let next = value(current); + if (typeof next?.then === 'function') throw new TypeError('hx-live: assignment returned a promise'); + elt[prop] = next; + } else { + elt[prop] = value; + } + }); schedule(); return true; } @@ -486,7 +633,7 @@ return proxy; } - let liveQuery, bindPrefixes, bodyAttrs; + let liveQuery, bindPrefixes, hxLiveNames; function buildLiveQuery() { let mc = htmx.config.metaCharacter || ':'; @@ -497,17 +644,17 @@ if (extra === undefined) { if (window.Alpine) { extra = ''; - console.warn('hx-live: Alpine.js detected; ":" short-form bindings disabled. Set htmx.config.live.bindPrefix to configure.'); + console.warn('hx-live: Alpine detected; set config.live.bindPrefix.'); } else { extra = ':'; } } if (extra) bindPrefixes.push(extra); - bodyAttrs = ['hx-live']; - if (p) bodyAttrs.push(p + 'live'); + hxLiveNames = ['hx-live']; + if (p) hxLiveNames.push(p + 'live'); let bind = bindPrefixes.map(bp => `starts-with(name(), "${bp}")`).join(' or '); - let body = bodyAttrs.map(n => `@${n}`).join(' or '); - liveQuery = new XPathEvaluator().createExpression(`.//*[@*[${bind}] or ${body}]`); + let effect = hxLiveNames.map(n => `@${n}`).join(' or '); + liveQuery = new XPathEvaluator().createExpression(`.//*[@*[${bind}] or ${effect}]`); } function extractBindingName(attrName) { @@ -521,45 +668,26 @@ if (!prop?.liveRuns) return; for (let run of prop.liveRuns) fns.delete(run); delete prop.liveRuns; - delete prop.liveRegistered; - delete prop.liveAttrs; + delete prop.effectRegistered; + delete prop.bindings; } function processElement(elt) { if (elt.closest('[hx-ignore]')) return; let prop = api.htmxProp(elt); - if (!prop.liveRegistered) { - let bodyAttr = bodyAttrs.find(n => elt.hasAttribute(n)); - if (bodyAttr) { - prop.liveRegistered = true; - ensureActive(); - let code = elt.getAttribute(bodyAttr) - let debounce = getDebounce(elt); - let exec; - let run = async () => { - if (!elt.isConnected) { - fns.delete(run); - return; - } - try { - exec ||= api.executeJavaScript(elt, { debounce }, code, false, true, true); - await exec(); - } catch (e) { - if (e !== dbSym) console.error('htmx: hx-live expression threw', e, { elt }); - } - }; - fns.add(run); - prop.liveRuns = prop.liveRuns || new Set(); - prop.liveRuns.add(run); - run(); + if (!prop.effectRegistered) { + let hxLiveName = hxLiveNames.find(n => elt.hasAttribute(n)); + if (hxLiveName) { + prop.effectRegistered = true; + registerLive(elt, elt.getAttribute(hxLiveName)); } } - prop.liveAttrs ||= new Set(); + prop.bindings ||= new Set(); for (let a of elt.attributes) { let name = extractBindingName(a.name); - if (!name || prop.liveAttrs.has(name)) continue; - prop.liveAttrs.add(name); - registerSimpleLive(elt, name, a.value); + if (!name || prop.bindings.has(name)) continue; + prop.bindings.add(name); + registerLive(elt, a.value, name); } } @@ -571,23 +699,33 @@ for (node of nodes) processElement(node); } - function registerSimpleLive(elt, attrName, code) { + function registerLive(elt, code, attrName) { ensureActive(); + let binding = attrName !== undefined; let debounce = getDebounce(elt); - let isAsync = /\bawait\b/.test(code); + let hasAwait = /\bawait\b/.test(code); + let overlapping = !binding && hasAwait; + let isAsync = !binding || hasAwait; let exec; + let running = false; let run = async () => { if (!elt.isConnected) { fns.delete(run); return; } + if (overlapping && running) return; + running = overlapping; try { - exec ||= api.executeJavaScript(elt, { debounce }, code, true, isAsync, true); + exec ||= api.executeJavaScript(elt, { debounce }, code, binding, isAsync, true); let value = isAsync ? await exec() : exec(); - writeAttrBinding(elt, attrName, value); - if (isAsync) observer?.takeRecords(); + if (binding) { + writeAttrBinding(elt, attrName, value); + if (isAsync) observer?.takeRecords(); + } } catch (e) { - if (e !== dbSym) console.error('htmx: hx-live expression threw', e, { elt, attr: attrName }); + if (e !== dbSym) console.error('hx-live expression failed', e, binding ? { elt, attr: attrName } : { elt }); + } finally { + if (overlapping) queueMicrotask(() => running = false); } }; fns.add(run); @@ -598,6 +736,7 @@ } function writeAttrBinding(elt, attrName, value) { + if (typeof value === 'function') throw new TypeError('hx-live: binding returned a function'); if (attrName === 'text') { let s = value == null ? '' : String(value); if (elt.textContent !== s) elt.textContent = s; @@ -609,24 +748,20 @@ return; } if (attrName === 'style') { applyStyleBinding(elt, value); return; } - // Always write aria-* and property-backed attrs (getter type differs from setter). - // For everything else skip if unchanged. - if (!attrName.startsWith('aria-') && !PROPERTY_ATTRS.has(attrName) && applyAttr([elt], attrName) === value) return; - applyAttr([elt], attrName, value); + if (attrName === 'class' || attrName.startsWith('.')) { + applyClassBinding(elt, attrName, value); + return; + } + if (readAttr(elt, attrName) === value) return; + writeAttr(elt, attrName, value); } - let asTargets = t => t == null ? [] - : typeof t === 'string' ? document.querySelectorAll(t) - : t.nodeType ? [t] - : t; - htmx.live = { q: s => makeQ(document.documentElement)(s), debounce: makeDebounce(), refresh: () => schedule(), - take: (target, name, scope) => applyTake([...asTargets(target)], name, scope), - toggle: (target, name, values) => [...asTargets(target)].forEach(e => applyToggle(name, values, e)), - attr: (target, name, ...rest) => applyAttr([...asTargets(target)], name, ...rest), + take: (target, name, scope) => applyTake(htmx.live.q(target).arr(), name, scope), + toggle: (target, name, ...values) => htmx.live.q(target).forEach(e => applyToggle(e, name, ...values)), forEvent: (...args) => forEvent(null, ...args), nextFrame: () => new Promise(r => requestAnimationFrame(r)) }; @@ -652,21 +787,9 @@ if (--swaps === 0 && fns.size > 0) schedule(); }, htmx_scope: (elt, detail) => { - Object.assign(detail.scope, { - q: makeQ(elt), - forEvent: (...args) => forEvent(elt, ...args), - nextFrame: () => new Promise(r => requestAnimationFrame(r)), - trigger: (type, detail, bubbles) => htmx.trigger(elt, type, detail, bubbles), - debounce: getDebounce(elt), - take: (name, scope) => applyTake([elt], name, scope), - toggle: (name, values) => applyToggle(name, values, elt), - attr: (name, ...rest) => applyAttr([elt], name, ...rest), - insert: (pos, html) => elt.insertAdjacentHTML(positions[pos], html), - matches: (sel) => elt.matches(sel), - style: elt.style, - classList: elt.classList, - data: makeDataProxy(elt) - }); + let prop = api.htmxProp(elt); + Object.assign(detail.scope, prop.liveScope ||= makeExpressionScope(elt)); + detail.code = rewriteClass(detail.code); if (htmx.config.live?.useDollar) detail.scope.$ = detail.scope.q; } }); diff --git a/src/htmx.d.ts b/src/htmx.d.ts index 3a3ff82ea..63e5cf294 100644 --- a/src/htmx.d.ts +++ b/src/htmx.d.ts @@ -1,21 +1,23 @@ -/** Configures the hx-live extension. */ -export interface HtmxLiveConfig { - /** - * Debounces `input` events by a number of milliseconds or an interval string. - * @default 100 - */ - inputDebounce?: number | string; - /** - * Sets the short binding prefix (`':'` -> `:text`, `'hx:'` -> `hx:text`, `''`/`false` -> disabled). - * Alpine.js detection disables the default. - * @default ":" - */ - bindPrefix?: string | false; - /** - * Adds `$()` as a `q()` alias in `hx-live`, `:attr`, `hx-on`, `js:` attributes, and `hx-trigger` filters. - * @default false - */ - useDollar?: boolean; +export namespace HxLive { + /** Configures the hx-live extension. */ + export interface Config { + /** + * Debounces `input` events by a number of milliseconds or an interval string. + * @default 100 + */ + inputDebounce?: number | string; + /** + * Sets the short binding prefix (`':'` -> `:text`, `'hx:'` -> `hx:text`, `''`/`false` -> disabled). + * Alpine.js detection disables the default. + * @default ":" + */ + bindPrefix?: string | false; + /** + * Adds `$()` as a `q()` alias in `hx-live`, `:attr`, `hx-on`, `js:` attributes, and `hx-trigger` filters. + * @default false + */ + useDollar?: boolean; + } } export interface HtmxConfig { @@ -139,7 +141,7 @@ export interface HtmxConfig { */ defaultSwapEmpty?: boolean; /** Requires hx-live. */ - live?: HtmxLiveConfig; + live?: HxLive.Config; } /** Context object passed to `htmx.swap()` */ @@ -166,79 +168,171 @@ export interface HtmxSwapContext { anchor?: string; } -export interface QProxy { - /** Number of matched elements. */ - count: number; - /** Returns a plain array of the matched elements. */ - arr(): Element[]; - /** - * Re-runs the selector grammar with each matched element as the anchor. - * Supports `next`, `previous`, `closest`, `first`, `last`, and `in` scoping. - */ - q(selector: string): QProxy; - /** - * Get an attribute, class, or property from the first matched element. - * - `.foo`: class presence as `true`/`false` - * - `aria-*`: coerces `"true"`/`"false"` to boolean - * - boolean attrs (`hidden`, `disabled`, etc.): `true`/`false` - * - `value`, `checked`, `selected`: DOM property - * - anything else: `getAttribute(name)` - */ - attr(name: string): any; - /** - * Set an attribute, class, or property on all matched elements. Returns the proxy for chaining. - * - `.foo`: adds/removes class by truthiness - * - `'class'`: space-separated string or `{ className: condition }` object - * - `aria-*`: strings/numbers pass through; others coerce to `"true"`/`"false"` - * - `value`, `checked`, `selected`: syncs DOM property and HTML attribute - * - boolean attrs: truthy adds, falsy removes - * - anything else: `null`/`undefined`/`false` removes; otherwise sets as string - */ - attr(name: string, value: any): QProxy; - /** - * Move a class or attribute from sibling/scoped elements to all matched elements. - * @param scope - CSS selector, DOM node, or `{ from: string }`. Defaults to parent element. - */ - take(name: string, scope?: string | Node | { from: string }): QProxy; - /** - * Toggle (binary flip) or cycle (with `values`) a class or attribute on all matched elements. - * @param values - Pipe-delimited string (`'grid|list'`) or array to cycle through. - */ - toggle(name: string, values?: string | string[]): QProxy; - /** - * Dispatch a `CustomEvent` from all matched elements. - * @param bubbles - Defaults to `true`. - */ - trigger(type: string, detail?: any, bubbles?: boolean): QProxy; - /** - * Insert HTML relative to all matched elements. - * - `'before'`/`'after'`: sibling before/after - * - `'start'`/`'end'`: first/last child - */ - insert(pos: 'before' | 'after' | 'start' | 'end', html: string): QProxy; - /** - * Cascading `data-*` proxy. Reads/writes the closest ancestor with the matching `data-*` attribute. - * Values are JSON-parsed on read and JSON-serialized on write. - */ - data?: Record; - /** Iterate over matched elements. */ - [Symbol.iterator](): IterableIterator; - /** DOM property passthrough: reads from first element, writes to all. */ - [key: string]: any; +export namespace HxLive { + /** Boolean class membership and class operations. */ + export interface ClassProxy { + assign(classes: Record): void; + add(...classes: string[]): void; + remove(...classes: string[]): void; + toggle(className: string, force?: boolean): boolean; + replace(oldClass: string, newClass: string): boolean; + contains(className: string): boolean; + [name: string]: any; + } + + export type Updater = (current: Current) => Next; + type AriaWrite = T | null | undefined | Updater; + type AriaTristate = boolean | 'mixed' | 'undefined'; + type AriaOptionalBoolean = boolean | 'undefined'; + type AriaCurrent = boolean | 'page' | 'step' | 'location' | 'date' | 'time'; + type AriaHasPopup = boolean | 'menu' | 'listbox' | 'tree' | 'grid' | 'dialog'; + type AriaInvalid = boolean | 'grammar' | 'spelling'; + type AriaDropEffect = Array<'copy' | 'execute' | 'link' | 'move' | 'none' | 'popup'>; + type AriaRelevant = Array<'additions' | 'removals' | 'text' | 'all'>; + + /** Typed WAI-ARIA 1.2 state. Assign a function to update the current value. */ + export interface AriaProxy { + /** Strings and ID references. */ + get activeDescendant(): string | undefined; set activeDescendant(value: AriaWrite); + get details(): string | undefined; set details(value: AriaWrite); + get errorMessage(): string | undefined; set errorMessage(value: AriaWrite); + get keyShortcuts(): string | undefined; set keyShortcuts(value: AriaWrite); + get label(): string | undefined; set label(value: AriaWrite); + get placeholder(): string | undefined; set placeholder(value: AriaWrite); + get roleDescription(): string | undefined; set roleDescription(value: AriaWrite); + get valueText(): string | undefined; set valueText(value: AriaWrite); + + /** Booleans and states. */ + get atomic(): boolean | undefined; set atomic(value: AriaWrite); + get busy(): boolean | undefined; set busy(value: AriaWrite); + get checked(): AriaTristate | undefined; set checked(value: AriaWrite); + get disabled(): boolean | undefined; set disabled(value: AriaWrite); + get expanded(): AriaOptionalBoolean | undefined; set expanded(value: AriaWrite); + get grabbed(): AriaOptionalBoolean | undefined; set grabbed(value: AriaWrite); + get hidden(): AriaOptionalBoolean | undefined; set hidden(value: AriaWrite); + get modal(): boolean | undefined; set modal(value: AriaWrite); + get multiLine(): boolean | undefined; set multiLine(value: AriaWrite); + get multiSelectable(): boolean | undefined; set multiSelectable(value: AriaWrite); + get pressed(): AriaTristate | undefined; set pressed(value: AriaWrite); + get readOnly(): boolean | undefined; set readOnly(value: AriaWrite); + get required(): boolean | undefined; set required(value: AriaWrite); + get selected(): AriaOptionalBoolean | undefined; set selected(value: AriaWrite); + + /** Tokens. */ + get autoComplete(): 'inline' | 'list' | 'both' | 'none' | undefined; set autoComplete(value: AriaWrite<'inline' | 'list' | 'both' | 'none'>); + get current(): AriaCurrent | undefined; set current(value: AriaWrite); + get hasPopup(): AriaHasPopup | undefined; set hasPopup(value: AriaWrite); + get invalid(): AriaInvalid | undefined; set invalid(value: AriaWrite); + get live(): 'assertive' | 'off' | 'polite' | undefined; set live(value: AriaWrite<'assertive' | 'off' | 'polite'>); + get orientation(): 'horizontal' | 'undefined' | 'vertical' | undefined; set orientation(value: AriaWrite<'horizontal' | 'undefined' | 'vertical'>); + get sort(): 'ascending' | 'descending' | 'none' | 'other' | undefined; set sort(value: AriaWrite<'ascending' | 'descending' | 'none' | 'other'>); + + /** Integers and numbers. */ + get colCount(): number | undefined; set colCount(value: AriaWrite); + get colIndex(): number | undefined; set colIndex(value: AriaWrite); + get colSpan(): number | undefined; set colSpan(value: AriaWrite); + get level(): number | undefined; set level(value: AriaWrite); + get posInSet(): number | undefined; set posInSet(value: AriaWrite); + get rowCount(): number | undefined; set rowCount(value: AriaWrite); + get rowIndex(): number | undefined; set rowIndex(value: AriaWrite); + get rowSpan(): number | undefined; set rowSpan(value: AriaWrite); + get setSize(): number | undefined; set setSize(value: AriaWrite); + get valueMax(): number | undefined; set valueMax(value: AriaWrite); + get valueMin(): number | undefined; set valueMin(value: AriaWrite); + get valueNow(): number | undefined; set valueNow(value: AriaWrite); + + /** ID reference lists and token lists. */ + get controls(): string[] | undefined; set controls(value: AriaWrite); + get describedBy(): string[] | undefined; set describedBy(value: AriaWrite); + get flowTo(): string[] | undefined; set flowTo(value: AriaWrite); + get labelledBy(): string[] | undefined; set labelledBy(value: AriaWrite); + get owns(): string[] | undefined; set owns(value: AriaWrite); + get dropEffect(): AriaDropEffect | undefined; set dropEffect(value: AriaWrite); + get relevant(): AriaRelevant | undefined; set relevant(value: AriaWrite); + } + + export interface AttrProxy { + readonly class: ClassProxy & DOMTokenList; + [name: string]: any; + } + + /** Typed application-defined `data-*` state. */ + export interface DataProxy { + [name: string]: any; + } + + /** State bags that resolve each key from the nearest owning element. */ + export interface Scope { + /** Typed attributes from the nearest element carrying each attribute. */ + readonly attr: AttrProxy; + /** Typed `data-*` values from the nearest element carrying each key. */ + readonly data: DataProxy; + /** Typed ARIA values from the nearest element carrying each attribute. */ + readonly aria: AriaProxy; + /** Class membership from the nearest element carrying each class. */ + readonly class: ClassProxy; + } + + export interface Query { + /** Number of matched elements. */ + count: number; + /** Returns a plain array of the matched elements. */ + arr(): Element[]; + /** + * Re-runs the selector grammar with each matched element as the anchor. + * Supports `next`, `previous`, `closest`, `first`, `last`, and `in` scoping. + */ + q(selector: string): Query; + /** Typed attributes on the selected elements themselves. */ + readonly attr: AttrProxy; + /** Typed `data-*` values on the selected elements themselves. */ + readonly data: DataProxy; + /** Typed `aria-*` values on the selected elements themselves. */ + readonly aria: AriaProxy; + /** Class state and `classList` methods on the selected elements themselves. */ + readonly class: ClassProxy & DOMTokenList; + /** Typed state on the closest match for each selected element. */ + readonly closest: Scope; + /** + * Move a class or attribute from sibling/scoped elements to all matched elements. + * @param scope - CSS selector, DOM node, or `{ from: string }`. Defaults to parent element. + */ + take(name: string, scope?: string | Node | { from: string }): Query; + /** + * Toggle (binary flip) or cycle (with `values`) a class or attribute on all matched elements. + * @param values - Pipe-delimited string (`'grid|list'`) or array to cycle through. + */ + toggle(name: string, ...values: any[]): Query; + /** + * Dispatch a `CustomEvent` from all matched elements. + * @param bubbles - Defaults to `true`. + */ + trigger(type: string, detail?: any, bubbles?: boolean): Query; + /** + * Insert HTML relative to all matched elements. + * - `'before'`/`'after'`: sibling before/after + * - `'start'`/`'end'`: first/last child + */ + insert(pos: 'before' | 'after' | 'start' | 'end', html: string): Query; + /** Iterate over matched elements. */ + [Symbol.iterator](): IterableIterator; + /** DOM property passthrough: reads from first element, writes to all. */ + [key: string]: any; + } } -export interface HtmxLive { +export interface HxLive { /** - * Returns a `QProxy` over elements matching a selector, element, or collection. + * Returns a query proxy over elements matching a selector, element, or collection. * Directional keywords (`next`, `previous`, `closest`) only work inside `hx-live`/`hx-on` expressions. */ - q(selector: string): QProxy; - q(element: Element): QProxy; - q(elements: Iterable): QProxy; + q(selector: string): HxLive.Query; + q(element: Element): HxLive.Query; + q(elements: Iterable): HxLive.Query; /** Aliases `q()`. */ - $(selector: string): QProxy; - $(element: Element): QProxy; - $(elements: Iterable): QProxy; + $(selector: string): HxLive.Query; + $(element: Element): HxLive.Query; + $(elements: Iterable): HxLive.Query; /** * Awaitable debounce: resolves after `ms` ms. Cancels any pending call on the same element. */ @@ -253,18 +347,14 @@ export interface HtmxLive { /** Move a class or attribute from sibling/scoped elements to the target. */ take(target: string | Element | NodeList, name: string, scope?: string | Node | { from: string }): void; /** Toggle or cycle a class or attribute on the target. */ - toggle(target: string | Element | NodeList, name: string, values?: string | string[]): void; - /** Get an attribute, class, or property from the first matched element. See `QProxy.attr`. */ - attr(target: string | Element | NodeList, name: string): any; - /** Set an attribute, class, or property on all matched elements. See `QProxy.attr`. */ - attr(target: string | Element | NodeList, name: string, value: any): void; + toggle(target: string | Element | NodeList, name: string, ...values: any[]): void; /** * Resolves on the next matching event, timeout, or interval, whichever fires first. - * - `string`: event name on the current element + * - `string`: event name or interval * - `number`: timeout in ms * - `EventTarget`: redirects listeners to that target */ - forEvent(...args: (string | number | EventTarget)[]): Promise; + forEvent(...args: (string | number | EventTarget)[]): Promise; /** * Resolves on the next animation frame. Useful to force a style recalc between two DOM writes. */ @@ -566,7 +656,7 @@ export interface Htmx { /** Global htmx configuration */ config: HtmxConfig; /** hx-live extension API, available when the extension is loaded */ - live?: HtmxLive; + live?: HxLive; /** * Issues an htmx-style AJAX request programmatically. * Returns a Promise that resolves after the response has been swapped into the DOM. diff --git a/src/htmx.js b/src/htmx.js index 485087a62..7d26cf977 100644 --- a/src/htmx.js +++ b/src/htmx.js @@ -893,7 +893,9 @@ var htmx = (() => { let args = {} Object.assign(args, this.__apiMethods(thisArg)) let scope = {}; - this.__triggerExtensions(thisArg, "htmx:scope", { scope }); + let detail = { scope, code }; + this.__triggerExtensions(thisArg, "htmx:scope", detail); + code = detail.code; Object.assign(args, scope); Object.assign(args, obj) let keys = Object.keys(args); diff --git a/test/manual/hx-live/index.html b/test/manual/hx-live/index.html index b1242c75e..080d1b3c1 100644 --- a/test/manual/hx-live/index.html +++ b/test/manual/hx-live/index.html @@ -44,7 +44,7 @@

hx-live playground

-

1. Simple form: :attr="expr" — boolean attributes

+

1. Binding: :attr="expr" — boolean attributes

diff --git a/test/tests/ext/hx-live.js b/test/tests/ext/hx-live.js index b69617373..c31461619 100644 --- a/test/tests/ext/hx-live.js +++ b/test/tests/ext/hx-live.js @@ -195,7 +195,7 @@ describe('hx-live extension', function () { elt.dataset.v.should.equal('done'); }); - it('hx-live body supports top-level await directly', async function() { + it('effect supports top-level await directly', async function() { let elt = createProcessedHTML( `` ); @@ -204,6 +204,41 @@ describe('hx-live extension', function () { elt.dataset.v.should.equal('done'); }); + it('does not rerun after an async effect writes to the DOM', async function() { + window.__asyncEffectRuns = 0; + let elt = createProcessedHTML(` + + `); + await htmx.timeout(30); + elt.textContent.should.equal('done'); + let settledRuns = window.__asyncEffectRuns; + await htmx.timeout(20); + window.__asyncEffectRuns.should.equal(settledRuns); + assert.isAtMost(settledRuns, 2); + delete window.__asyncEffectRuns; + }); + + it('does not rerun after an async effect writes before another await', async function() { + window.__multiStepEffectRuns = 0; + playground().innerHTML = ` + + `; + await htmx.timeout(1); + htmx.process(playground()); + let elt = playground().querySelector('output'); + await htmx.timeout(10); + window.__multiStepEffectRuns.should.equal(1); + await htmx.timeout(20); + elt.classList.contains('done').should.equal(true); + delete window.__multiStepEffectRuns; + }); + it('forEvent(event, ms) resolves on event before timeout', async function() { let elt = createProcessedHTML(''); await htmx.timeout(5); @@ -228,6 +263,18 @@ describe('hx-live extension', function () { delete window.__waitResultLive; }); + it('forEvent listens on an explicit EventTarget', async function() { + window.__waitResultLive = null; + createProcessedHTML( + `` + ); + await htmx.timeout(5); + window.dispatchEvent(new Event('live-resize')); + await htmx.timeout(5); + window.__waitResultLive.type.should.equal('live-resize'); + delete window.__waitResultLive; + }); + it('forEvent races multiple events and timeouts', async function() { let elt = createProcessedHTML(''); await htmx.timeout(5); @@ -469,7 +516,7 @@ describe('hx-live extension', function () {
- + `; htmx.process(playground()); @@ -999,18 +1046,6 @@ describe('hx-live extension', function () { assert.isFunction(htmx.live.toggle); }); - it('classList scope helper accesses this.classList', function() { - playground().innerHTML = ` - - `; - htmx.process(playground()); - let btn = playground().querySelector('button'); - btn.classList.add('pending'); - btn.click(); - btn.classList.contains('done').should.equal(true); - btn.classList.contains('pending').should.equal(false); - }); - it('htmx.live.toggle(target, name) toggles across matches', function() { playground().innerHTML = `
@@ -1022,6 +1057,18 @@ describe('hx-live extension', function () { tabs[1].classList.contains('active').should.equal(false); }); + it('htmx.live.take(target, name, scope) moves state to the target', function() { + playground().innerHTML = ` + + + `; + + htmx.live.take('#b', '.selected', '.tab'); + + playground().querySelector('#a').classList.contains('selected').should.equal(false); + playground().querySelector('#b').classList.contains('selected').should.equal(true); + }); + it('htmx.live.refresh() recomputes live expressions even when no DOM event triggered', async function() { // Using a non-reactive external value: the expression reads window.__refreshSrcLive. // Mutating that value will not trigger any DOM input/change/mutation listener, @@ -1105,230 +1152,707 @@ describe('hx-live extension', function () { }); // ------------------------------------------------------------------------- - // attr() scope helper + // DOM state // ------------------------------------------------------------------------- - it('attr() getter: boolean attr returns boolean', function() { - playground().innerHTML = ''; - htmx.live.attr('#a', 'disabled').should.equal(true); - htmx.live.attr('#b', 'disabled').should.equal(false); + it('state bags read and write the current element', function() { + let button = createProcessedHTML(` +
+ +
+ `).querySelector('button'); + + button.click(); + + button.parentElement.dataset.count.should.equal('2'); + button.getAttribute('aria-pressed').should.equal('true'); + button.disabled.should.equal(true); + button.classList.contains('active').should.equal(true); }); - it('attr() getter: ARIA returns boolean from "true"/"false"', function() { - playground().innerHTML = '
'; - htmx.live.attr('#a', 'aria-expanded').should.equal(true); - htmx.live.attr('#b', 'aria-expanded').should.equal(false); + it('bare class supports member access', function() { + let button = createProcessedHTML(``); + + button.click(); + + window.__bareClassMembers.should.deep.equal([true, true, true, true]); + button.classList.contains('ready').should.equal(true); + delete window.__bareClassMembers; }); - it('attr() getter: .class returns boolean (has class)', function() { - playground().innerHTML = '
'; - htmx.live.attr('#a', '.foo').should.equal(true); - htmx.live.attr('#b', '.foo').should.equal(false); + it('attr.class supports first-class value operations', function() { + let button = createProcessedHTML(``); + + button.click(); + + window.__classValue.should.deep.equal([['active'], true, true, 4, 'object']); + delete window.__classValue; }); - it('attr() getter: class returns full class string', function() { - playground().innerHTML = '
'; - htmx.live.attr('#a', 'class').should.equal('foo bar baz'); + it('bare class works in nested template expressions', function() { + let button = createProcessedHTML(``); + + button.click(); + + window.__bareClassTemplates.should.deep.equal(['simple:true', 'nested:true']); + delete window.__bareClassTemplates; }); - it('attr() getter: regular attr returns string or null', function() { - playground().innerHTML = '
'; - htmx.live.attr('#a', 'data-x').should.equal('hello'); - assert.isNull(htmx.live.attr('#b', 'data-x')); + it('attr.class works in expression compilation', function() { + let button = createProcessedHTML(''); + let result = htmx.__executeJavaScript(button, {}, `({ + spread: [...attr.class], + contains: 'active' in attr.class, + same: attr.class === attr.class + })`, true, false); + + result.spread.should.deep.equal(['active']); + result.contains.should.equal(true); + result.same.should.equal(true); }); - it('attr() getter: checked returns property value', function() { - playground().innerHTML = ''; - htmx.live.attr('#a', 'checked').should.equal(true); - htmx.live.attr('#b', 'checked').should.equal(false); + it('bare class rewriting preserves native JavaScript syntax', function() { + let button = createProcessedHTML(``); + + button.click(); + + window.__nativeClassSyntax.should.deep.equal([ + ['field', 'private'], 'anonymous', 'property', 'method', + 'class.active', 'class\\.active', 'raw class.active', + 'multi-line\n class.active' + ]); + delete window.__nativeClassSyntax; + }); + + it('q() reads the first match and writes every match', function() { + playground().innerHTML = ''; + let items = htmx.live.q('.item'); + + items.data.state.should.equal('first'); + items.attr.hidden = true; + items.aria.busy = false; + items.class.ready = true; + + for (let item of playground().querySelectorAll('.item')) { + item.hidden.should.equal(true); + item.getAttribute('aria-busy').should.equal('false'); + item.classList.contains('ready').should.equal(true); + } }); - it('attr() getter: value returns property value', function() { - playground().innerHTML = ''; - let inp = playground().querySelector('#a'); - // After user interaction the property and attribute can diverge - inp.value = 'world'; - htmx.live.attr('#a', 'value').should.equal('world'); - }); - - it('attr() setter: boolean attr truthy sets, falsy removes', function() { - playground().innerHTML = ''; - htmx.live.attr('#a', 'disabled', true); - playground().querySelector('#a').hasAttribute('disabled').should.equal(true); - htmx.live.attr('#a', 'disabled', false); - playground().querySelector('#a').hasAttribute('disabled').should.equal(false); - }); - - it('attr() setter: ARIA writes "true"/"false", never removes', function() { - playground().innerHTML = '
'; - let div = playground().querySelector('#a'); - htmx.live.attr('#a', 'aria-expanded', true); - div.getAttribute('aria-expanded').should.equal('true'); - htmx.live.attr('#a', 'aria-expanded', false); - div.getAttribute('aria-expanded').should.equal('false'); - // null/undefined also writes "false". ARIA is never removed. - htmx.live.attr('#a', 'aria-expanded', null); - div.getAttribute('aria-expanded').should.equal('false'); - }); - - it('attr() setter: aria-* strings and numbers pass through', function() { - playground().innerHTML = '
'; - // String values (tristate, tokens) pass through unchanged. - htmx.live.attr('#a', 'aria-pressed', 'mixed'); - playground().querySelector('#a').getAttribute('aria-pressed').should.equal('mixed'); - htmx.live.attr('#b', 'aria-current', 'page'); - playground().querySelector('#b').getAttribute('aria-current').should.equal('page'); - // Numbers stringify (e.g. aria-valuenow). - htmx.live.attr('#c', 'aria-valuenow', 50); - playground().querySelector('#c').getAttribute('aria-valuenow').should.equal('50'); - }); - - it('attr() setter: .class true/false add/remove', function() { - playground().innerHTML = '
'; - let div = playground().querySelector('#a'); - htmx.live.attr('#a', '.active', true); - div.classList.contains('active').should.equal(true); - htmx.live.attr('#a', '.active', false); - div.classList.contains('active').should.equal(false); + it('bare state is local except for cascading data', function() { + let button = createProcessedHTML(` + + `).querySelector('button'); + + button.click(); + + window.__ownership.should.deep.equal([false, undefined, false, 1, true, false, true, 1]); + delete window.__ownership; }); - it('attr() setter: class (string) sets managed class list', function() { - playground().innerHTML = '
'; - let div = playground().querySelector('#a'); - htmx.live.attr('#a', 'class', 'foo bar'); - div.classList.contains('external').should.equal(true); - div.classList.contains('foo').should.equal(true); - div.classList.contains('bar').should.equal(true); + it('q() state is local and q().closest state cascades', function() { + playground().innerHTML = ` + + `; + let item = htmx.live.q('#item'); - // Re-apply with different set. Previous managed dropped, external untouched. - htmx.live.attr('#a', 'class', 'baz'); - div.classList.contains('external').should.equal(true); - div.classList.contains('foo').should.equal(false); - div.classList.contains('bar').should.equal(false); - div.classList.contains('baz').should.equal(true); + item.attr.hidden.should.equal(false); + assert.isUndefined(item.data.count); + assert.isUndefined(item.aria.busy); + item.class.active.should.equal(false); + + item.closest.attr.hidden.should.equal(true); + item.closest.data.count.should.equal(1); + item.closest.aria.busy.should.equal(false); + item.closest.class.active.should.equal(true); }); - it('attr() setter: class (object) toggles each independently', function() { - playground().innerHTML = '
'; - let div = playground().querySelector('#a'); - htmx.live.attr('#a', 'class', { foo: true, bar: false }); - div.classList.contains('foo').should.equal(true); - div.classList.contains('bar').should.equal(false); - div.classList.contains('external').should.equal(true); + it('closest class supports class operations', function() { + playground().innerHTML = ` +
+
+
+ `; + let classes = htmx.live.q('#item').closest.class; + + classes.contains('active').should.equal(true); + classes.add('selected'); + classes.remove('busy'); + classes.toggle('active').should.equal(false); + classes.replace('old', 'new').should.equal(true); + classes.assign({ new: false, ready: true }); + + let item = playground().querySelector('#item'); + item.classList.contains('selected').should.equal(true); + item.classList.contains('ready').should.equal(true); + item.parentElement.hasAttribute('class').should.equal(false); + item.parentElement.parentElement.hasAttribute('class').should.equal(false); + }); + + it('closest writes to the current element when no match exists', function() { + playground().innerHTML = ''; + let closest = htmx.live.q('#item').closest; + + closest.attr.hidden = true; + closest.data.count = 1; + closest.aria.busy = false; + closest.class.active = true; + + playground().querySelector('#item').outerHTML.should.equal( + '' + ); + }); + + it('closest updates each shared match once', function() { + playground().innerHTML = ` + + + `; + let calls = { attr: 0, data: 0, aria: 0, class: 0 }; + let closest = htmx.live.q('.item').closest; + + closest.attr.hidden = hidden => { calls.attr++; return !hidden; }; + closest.data.count = count => { calls.data++; return count + 1; }; + closest.aria.busy = busy => { calls.aria++; return !busy; }; + closest.class.active = active => { calls.class++; return !active; }; + + [...playground().querySelectorAll('section')].map(elt => [ + elt.hidden, + elt.dataset.count, + elt.getAttribute('aria-busy'), + elt.classList.contains('active') + ]).should.deep.equal([ + [false, '2', 'true', false], + [false, '5', 'true', false] + ]); + calls.should.deep.equal({ attr: 2, data: 2, aria: 2, class: 2 }); + }); + + it('state bags delete local and closest state', function() { + playground().innerHTML = ` + + `; + let item = htmx.live.q('#item'); + + delete item.attr.title; + delete item.data.local; + delete item.aria.current; + delete item.class.selected; + delete item.closest.attr.hidden; + delete item.closest.data.open; + delete item.closest.aria.busy; + delete item.closest.class.active; + + item.arr()[0].outerHTML.should.equal(''); + item.arr()[0].parentElement.attributes.length.should.equal(0); + }); + + it('attr data deletion removes while null assignment stores null', function() { + playground().innerHTML = ` +
+ +
+ `; + let item = htmx.live.q('#item'); + + delete item.attr['data-local']; + delete item.closest.attr['data-count']; + item.attr['data-value'] = null; + + let element = playground().querySelector('#item'); + element.hasAttribute('data-local').should.equal(false); + element.parentElement.hasAttribute('data-count').should.equal(false); + element.getAttribute('data-value').should.equal('null'); + (item.attr['data-value'] === null).should.equal(true); + }); + + it('string boolean attributes have typed reads and writes', function() { + playground().innerHTML = '
'; + let attr = htmx.live.q('#item').attr; + + [attr.contenteditable, attr.draggable, attr.spellcheck, attr.writingsuggestions] + .should.deep.equal(['plaintext-only', true, false, 123]); - // Flip foo, set bar - htmx.live.attr('#a', 'class', { foo: false, bar: true }); - div.classList.contains('foo').should.equal(false); - div.classList.contains('bar').should.equal(true); + attr.spellcheck = value => !value; + attr.contenteditable = true; + attr.draggable = false; + delete attr.writingsuggestions; + + let element = playground().querySelector('#item'); + [element.getAttribute('contenteditable'), element.getAttribute('draggable'), element.getAttribute('spellcheck')] + .should.deep.equal(['true', 'false', 'true']); + element.hasAttribute('writingsuggestions').should.equal(false); }); - it('attr() setter: class (object) supports space-separated keys', function() { - playground().innerHTML = '
'; - let div = playground().querySelector('#a'); - htmx.live.attr('#a', 'class', { 'foo bar': true, baz: false }); - div.classList.contains('foo').should.equal(true); - div.classList.contains('bar').should.equal(true); - div.classList.contains('baz').should.equal(false); + it('property-backed attributes preserve native typed state', function() { + playground().innerHTML = ` + + + + + `; + let check = htmx.live.q('#check').attr; + let option = htmx.live.q('#option').attr; + let number = htmx.live.q('#number').attr; + let hidden = htmx.live.q('#hidden').attr; + + [check.checked, option.selected, number.value, hidden.hidden] + .should.deep.equal([true, true, 3, 'until-found']); + + check.checked = value => !value; + option.selected = value => !value; + number.value = value => value + 1; + hidden.hidden = value => value === 'until-found' ? false : 'until-found'; + + [check.checked, option.selected, number.value, hidden.hidden] + .should.deep.equal([false, false, 4, false]); + [['#check', 'checked'], ['#option', 'selected'], ['#hidden', 'hidden']].forEach(([selector, name]) => { + playground().querySelector(selector).hasAttribute(name).should.equal(false); + }); + playground().querySelector('#number').getAttribute('value').should.equal('4'); + + hidden.hidden = 'until-found'; + hidden.hidden.should.equal('until-found'); + playground().querySelector('#hidden').getAttribute('hidden').should.equal('until-found'); }); - it('attr() setter: checked syncs property and attribute', function() { - playground().innerHTML = ''; - let inp = playground().querySelector('#a'); - htmx.live.attr('#a', 'checked', true); - inp.checked.should.equal(true); - inp.hasAttribute('checked').should.equal(true); - htmx.live.attr('#a', 'checked', false); - inp.checked.should.equal(false); - inp.hasAttribute('checked').should.equal(false); + it('current HTML boolean attributes use presence semantics', function() { + playground().innerHTML = '
'; + let template = document.createElement('template'); + template.id = 'shadow'; + template.setAttribute('shadowrootmode', 'open'); + template.setAttribute('shadowrootslotassignment', 'manual'); + for (let name of ['shadowrootclonable', 'shadowrootcustomelementregistry', 'shadowrootdelegatesfocus', 'shadowrootserializable']) { + template.setAttribute(name, ''); + } + playground().append(template); + + let attributes = [ + [htmx.live.q('#color').attr, 'alpha'], + [htmx.live.q('#heading').attr, 'headingreset'], + ...['shadowrootclonable', 'shadowrootcustomelementregistry', 'shadowrootdelegatesfocus', 'shadowrootserializable'] + .map(name => [htmx.live.q('#shadow').attr, name]) + ]; + + for (let [attr, name] of attributes) { + attr[name].should.equal(true); + attr[name] = false; + attr[name].should.equal(false); + } + let shadow = htmx.live.q('#shadow').attr; + shadow.shadowrootmode.should.equal('open'); + shadow.shadowrootslotassignment.should.equal('manual'); + }); + + it('generic attributes stringify booleans and remove null', function() { + playground().innerHTML = '
'; + let attr = htmx.live.q('#item').attr; + + attr.title = true; + playground().querySelector('#item').getAttribute('title').should.equal('true'); + attr.title = false; + playground().querySelector('#item').getAttribute('title').should.equal('false'); + attr.title = null; + playground().querySelector('#item').hasAttribute('title').should.equal(false); + }); + + it('numeric HTML attributes read numbers', function() { + playground().innerHTML = ` +
+
+ +
    + + + + `; + let values = [ + ['#global', 'tabindex', -1], + ['#cell', 'colspan', 3], ['#cell', 'rowspan', 4], + ['#input', 'maxlength', 10], ['#input', 'minlength', 2], ['#input', 'size', 20], + ['#column', 'span', 2], ['#list', 'start', -2], + ['#text', 'rows', 5], ['#text', 'cols', 30], + ['#canvas', 'width', 640], ['#canvas', 'height', 480], + ['#input', 'min', 0.5], ['#input', 'max', 10], ['#input', 'step', 0.25], + ['#meter', 'low', 0.2], ['#meter', 'high', 0.8], ['#meter', 'optimum', 0.5] + ]; + + values.forEach(([selector, name, expected]) => { + htmx.live.q(selector).attr[name].should.equal(expected); + }); }); - it('attr() setter: value syncs property and attribute', function() { - playground().innerHTML = ''; - let inp = playground().querySelector('#a'); - htmx.live.attr('#a', 'value', 'hello'); - inp.value.should.equal('hello'); - inp.getAttribute('value').should.equal('hello'); - htmx.live.attr('#a', 'value', null); - inp.value.should.equal(''); - inp.hasAttribute('value').should.equal(false); + it('numeric HTML attributes preserve non-numbers and support updates', function() { + playground().innerHTML = ''; + let date = htmx.live.q('#date').attr; + let meter = htmx.live.q('#meter').attr; + + date.min.should.equal('2025-01-01'); + date.step.should.equal('any'); + (date.max === null).should.equal(true); + + meter.optimum = value => value + 0.25; + meter.optimum.should.equal(0.75); + playground().querySelector('#meter').getAttribute('optimum').should.equal('0.75'); }); - it('attr() setter: regular attr null removes', function() { - playground().innerHTML = '
    '; - htmx.live.attr('#a', 'data-x', null); - playground().querySelector('#a').hasAttribute('data-x').should.equal(false); + it('class is the typed class attribute', function() { + playground().innerHTML = ''; + let item = htmx.live.q('#item'); + + (item.class === item.attr.class).should.equal(true); + (item.class === item.attr['class']).should.equal(true); + item.class.idle.should.equal(true); }); - it('attr() setter: regular attr stringifies non-string values', function() { - playground().innerHTML = '
    '; - htmx.live.attr('#a', 'data-x', 42); - playground().querySelector('#a').getAttribute('data-x').should.equal('42'); + it('class supports grouped writes and DOMTokenList', function() { + playground().innerHTML = ''; + let classes = htmx.live.q('#item').class; + + classes.assign({ idle: false, ready: true }); + classes.add('selected'); + + classes.length.should.equal(3); + classes.value.should.equal('pending ready selected'); + [...classes].should.deep.equal(['pending', 'ready', 'selected']); + classes.item(0).should.equal('pending'); + + classes.value = 'one two'; + [...playground().querySelector('#item').classList].should.deep.equal(['one', 'two']); }); - it('attr() setter: contenteditable false writes "false" string, not removes', function() { - playground().innerHTML = '
    '; - htmx.live.attr('#a', 'contenteditable', false); - playground().querySelector('#a').getAttribute('contenteditable').should.equal('false'); + it('class supports remove and replace', function() { + playground().innerHTML = ''; + let classes = htmx.live.q('#item').class; + + classes.remove('idle'); + classes.replace('ready', 'done').should.equal(true); + + classes.contains('idle').should.equal(false); + classes.contains('done').should.equal(true); }); - it('attr() setter: draggable false writes "false" string', function() { - playground().innerHTML = '
    '; - htmx.live.attr('#a', 'draggable', false); - playground().querySelector('#a').getAttribute('draggable').should.equal('false'); + it('class methods write every match and return the first result', function() { + playground().innerHTML = ''; + let classes = htmx.live.q('.item').class; + + classes.toggle('active').should.equal(true); + + [...playground().querySelectorAll('.item')].map(elt => elt.classList.contains('active')) + .should.deep.equal([true, false]); }); - it('attr() setter: spellcheck false writes "false" string', function() { - playground().innerHTML = '
    '; - htmx.live.attr('#a', 'spellcheck', false); - playground().querySelector('#a').getAttribute('spellcheck').should.equal('false'); + it('native class members win on read and class membership remains accessible', function() { + playground().innerHTML = ''; + let classes = htmx.live.q('#item').class; + + (typeof classes.toggle).should.equal('function'); + classes.value.should.equal('toggle value length'); + classes.length.should.equal(3); + classes.contains('toggle').should.equal(true); + classes.contains('value').should.equal(true); + classes.contains('length').should.equal(true); + + classes.toggle = false; + classes.contains('toggle').should.equal(false); }); - it('attr() setter: contenteditable null removes attribute', function() { - playground().innerHTML = '
    '; - htmx.live.attr('#a', 'contenteditable', null); - playground().querySelector('#a').hasAttribute('contenteditable').should.equal(false); + it('state is empty when q() has no matches', function() { + let empty = htmx.live.q('.missing'); + + assert.isUndefined(empty.attr.hidden); + assert.isUndefined(empty.data.count); + assert.isUndefined(empty.aria.busy); + assert.isUndefined(empty.class.active); + assert.isUndefined(empty.closest.attr.hidden); + assert.isUndefined(empty.closest.data.count); + assert.isUndefined(empty.closest.aria.busy); + assert.isUndefined(empty.closest.class.active); + + empty.attr.hidden = true; + empty.data.count = 1; + empty.aria.busy = true; + empty.class.active = true; + empty.closest.data.count = 1; + + [...empty.class].should.deep.equal([]); + playground().children.length.should.equal(0); }); - it('q().attr() applies setter to all matched elements', function() { - playground().innerHTML = ''; - htmx.live.q('.x').attr('disabled', true); - let inputs = playground().querySelectorAll('.x'); - for (let inp of inputs) inp.hasAttribute('disabled').should.equal(true); + it('take does nothing when q() has no matches', function() { + playground().innerHTML = ''; + + htmx.live.q('.missing').take('.active').take('aria-selected'); + + let item = playground().querySelector('#item'); + item.classList.contains('active').should.equal(true); + item.getAttribute('aria-selected').should.equal('true'); }); - it('q().attr() getter returns from first matched element', function() { - playground().innerHTML = '
    '; - htmx.live.q('.x').attr('data-i').should.equal('a'); + it('state reads typed DOM values', function() { + playground().innerHTML = ''; + let item = htmx.live.q('#item'); + + item.attr.value.should.equal(3); + item.attr.hidden.should.equal(true); + item.aria.busy.should.equal(false); + item.aria.valueNow.should.equal(4); + item.aria.controls.should.deep.equal(['a', 'b']); }); - it('q().attr() returns proxy for chaining', function() { - playground().innerHTML = ''; - let r = htmx.live.q('.x').attr('role', 'button').attr('.active', true); - r.count.should.equal(1); - let btn = playground().querySelector('.x'); - btn.getAttribute('role').should.equal('button'); - btn.classList.contains('active').should.equal(true); + it('ARIA booleans, tristates, and undefined states use booleans and tokens', function() { + playground().innerHTML = ''; + let element = playground().querySelector('#item'); + let aria = htmx.live.q('#item').aria; + let booleanNames = [ + 'atomic', 'busy', 'disabled', 'modal', 'multiline', 'multiselectable', + 'readonly', 'required', 'expanded', 'grabbed', 'hidden', 'selected', + 'checked', 'pressed' + ]; + + for (let name of booleanNames) { + element.setAttribute('aria-' + name, 'true'); + aria[name].should.equal(true); + element.setAttribute('aria-' + name, 'false'); + aria[name].should.equal(false); + } + + for (let name of ['checked', 'pressed']) { + element.setAttribute('aria-' + name, 'mixed'); + aria[name].should.equal('mixed'); + } + for (let name of ['expanded', 'grabbed', 'hidden', 'selected']) { + element.setAttribute('aria-' + name, 'undefined'); + aria[name].should.equal('undefined'); + } }); - it('attr() is available in hx-on scope bound to element', function() { - playground().innerHTML = ''; - htmx.process(playground()); - let btn = playground().querySelector('button'); - btn.click(); - btn.getAttribute('data-clicked').should.equal('yes'); + it('ARIA tokens preserve named values and type boolean tokens', function() { + playground().innerHTML = ''; + let element = playground().querySelector('#item'); + let aria = htmx.live.q('#item').aria; + let tokens = { + autocomplete: 'list', + current: 'page', + haspopup: 'menu', + invalid: 'spelling', + live: 'polite', + orientation: 'vertical', + sort: 'ascending' + }; + + for (let [name, value] of Object.entries(tokens)) { + element.setAttribute('aria-' + name, value); + aria[name].should.equal(value); + } + + for (let name of ['current', 'haspopup', 'invalid']) { + element.setAttribute('aria-' + name, 'true'); + aria[name].should.equal(true); + element.setAttribute('aria-' + name, 'false'); + aria[name].should.equal(false); + } }); - it('attr() in hx-live expression operates on current element', async function() { - let elt = createProcessedHTML( - `` - ); - await htmx.timeout(5); - elt.classList.contains('flipped').should.equal(true); + it('ARIA integers and numbers read as numbers', function() { + playground().innerHTML = '
    '; + let element = playground().querySelector('#item'); + let aria = htmx.live.q('#item').aria; + let integers = [ + 'colcount', 'colindex', 'colspan', 'level', 'posinset', + 'rowcount', 'rowindex', 'rowspan', 'setsize' + ]; + + for (let name of integers) { + element.setAttribute('aria-' + name, '2'); + aria[name].should.equal(2); + } + + element.setAttribute('aria-valuemin', '-1.5'); + element.setAttribute('aria-valuemax', '2.5'); + element.setAttribute('aria-valuenow', '0.5'); + aria.valueMin.should.equal(-1.5); + aria.valueMax.should.equal(2.5); + aria.valueNow.should.equal(0.5); + }); + + it('ARIA numbers use JSON number syntax', function() { + playground().innerHTML = '
    '; + let element = playground().querySelector('#item'); + let aria = htmx.live.q('#item').aria; + + aria.valueNow.should.equal(0.5); + element.setAttribute('aria-valuenow', '.5'); + aria.valueNow.should.equal('.5'); + }); + + it('ARIA strings and ID references preserve JSON-looking text', function() { + playground().innerHTML = '
    '; + let element = playground().querySelector('#item'); + let aria = htmx.live.q('#item').aria; + let strings = { + activedescendant: '50', + details: 'true', + errormessage: 'null', + keyshortcuts: '[]', + label: 'false', + placeholder: '{}', + roledescription: '0', + valuetext: '1' + }; + + for (let [name, value] of Object.entries(strings)) { + element.setAttribute('aria-' + name, value); + aria[name].should.equal(value); + } }); - // ------------------------------------------------------------------------- - // matches() scope helper - // ------------------------------------------------------------------------- + it('ARIA ID reference lists and token lists read as arrays', function() { + playground().innerHTML = '
    '; + let element = playground().querySelector('#item'); + let aria = htmx.live.q('#item').aria; + let lists = { + controls: ['a', 'b'], + describedby: ['a', 'b'], + flowto: ['a', 'b'], + labelledby: ['a', 'b'], + owns: ['a', 'b'], + dropeffect: ['copy', 'move'], + relevant: ['additions', 'text'] + }; + + for (let [name, value] of Object.entries(lists)) { + element.setAttribute('aria-' + name, value.join(' ')); + aria[name].should.deep.equal(value); + } + }); + + it('ARIA writes serialize typed values and updater results', function() { + playground().innerHTML = ''; + let element = playground().querySelector('#item'); + let aria = htmx.live.q('#item').aria; + + aria.expanded = expanded => !expanded; + aria.valueNow = 2.5; + aria.current = 'page'; + aria.controls = ['menu', 'help']; + aria.label = 'true'; + + element.getAttribute('aria-expanded').should.equal('true'); + element.getAttribute('aria-valuenow').should.equal('2.5'); + element.getAttribute('aria-current').should.equal('page'); + element.getAttribute('aria-controls').should.equal('menu help'); + element.getAttribute('aria-label').should.equal('true'); + }); + + it('updater functions receive each current value', function() { + playground().innerHTML = ''; + + htmx.live.q('.item').value = value => value.trim(); + htmx.live.q('.item').attr.title = title => title || 'ready'; + + [...playground().querySelectorAll('.item')].map(elt => [elt.value, elt.title]) + .should.deep.equal([['a', 'ready'], ['b', 'ready']]); + }); + + it('state updater functions receive typed current values', function() { + playground().innerHTML = ''; + let item = htmx.live.q('#item'); + + item.attr.hidden = hidden => !hidden; + item.data.count = count => count + 1; + item.aria.busy = busy => !busy; + item.class.active = active => !active; + + let element = playground().querySelector('#item'); + element.hidden.should.equal(false); + element.dataset.count.should.equal('2'); + element.getAttribute('aria-busy').should.equal('true'); + element.classList.contains('active').should.equal(false); + }); + + it('toggle cycles through variadic values', function() { + playground().innerHTML = ''; + + htmx.live.q('#item').toggle('data-view', 'grid', 'list'); + + playground().querySelector('#item').dataset.view.should.equal('list'); + }); + + it('toggle cycles typed attribute values', function() { + playground().innerHTML = ''; + let item = htmx.live.q('#item'); + + item.toggle('data-code', '123', '456'); + item.toggle('data-count', 1, 2); + item.toggle('aria-checked', false, true); + + item.data.code.should.equal('456'); + item.data.count.should.equal(2); + item.aria.checked.should.equal(true); + playground().querySelector('#item').getAttribute('data-code').should.equal('"456"'); + }); it('matches() is available in hx-on scope bound to element', function() { playground().innerHTML = ''; @@ -1350,15 +1874,6 @@ describe('hx-live extension', function () { let div = playground().querySelector('[hx-live]'); div.dataset.has.should.equal('true'); }); - - it('htmx.live.attr is exposed on public API', function() { - assert.isFunction(htmx.live.attr); - }); - - // ------------------------------------------------------------------------- - // cascading data proxy - // ------------------------------------------------------------------------- - it('data.foo reads this.dataset.foo when present locally', async function() { playground().innerHTML = `
    { + data[index] = value; + data[index].should.equal(value); + element.getAttribute('data-' + index).should.equal(JSON.stringify(value)); + }); + + data.plain = 'ready'; + data.plain.should.equal('ready'); + element.getAttribute('data-plain').should.equal('ready'); + }); + it('data proxy: null round-trips through JSON', async function() { playground().innerHTML = `
    @@ -1721,7 +2253,7 @@ describe('hx-live extension', function () { }); // ------------------------------------------------------------------------- - // Simple form: :attr / hx-live:attr + // Bindings: :attr / hx-live:attr // ------------------------------------------------------------------------- it(':hidden truthy sets attribute, falsy removes', async function() { @@ -1798,7 +2330,7 @@ describe('hx-live extension', function () { img.getAttribute('src').should.equal('/avatar/bob'); }); - it('simple form supports top-level await', async function() { + it('binding supports top-level await', async function() { playground().innerHTML = ``; htmx.process(playground()); await htmx.timeout(20); @@ -2067,14 +2599,14 @@ describe('hx-live extension', function () { div.classList.contains('big').should.equal(true); }); - it('simple form: hx-ignore skips :attr discovery', function() { + it('binding: hx-ignore skips :attr discovery', function() { playground().innerHTML = '
    '; htmx.process(playground()); let span = playground().querySelector('span'); span.textContent.should.equal(''); }); - it('simple form: matches() works in :attr expressions', async function() { + it('binding: matches() works in :attr expressions', async function() { playground().innerHTML = `
    @@ -2093,7 +2625,7 @@ describe('hx-live extension', function () { fs.hasAttribute('disabled').should.equal(false); }); - it('simple form: registration is idempotent across re-process', function() { + it('binding: registration is idempotent across re-process', function() { window.__liveCallCountSimple = 0; playground().innerHTML = ''; htmx.process(playground()); @@ -2110,7 +2642,7 @@ describe('hx-live extension', function () { describe('morph integration', function() { - it('hx-live body: morph changing expression adopts new code, does not duplicate', async function() { + it('effect: morph changing expression adopts new code, does not duplicate', async function() { window.__morphLiveCount = 0; playground().innerHTML = '
    '; htmx.process(playground()); @@ -2134,7 +2666,7 @@ describe('hx-live extension', function () { delete window.__morphLiveCount; }); - it('hx-live body: morph removing hx-live stops the fn from running', async function() { + it('effect: morph removing hx-live stops the fn from running', async function() { window.__morphRemovedCount = 0; playground().innerHTML = '
    '; htmx.process(playground()); diff --git a/test/tests/unit/__executeJavaScript.js b/test/tests/unit/__executeJavaScript.js index 41c62d634..7237bc72a 100644 --- a/test/tests/unit/__executeJavaScript.js +++ b/test/tests/unit/__executeJavaScript.js @@ -13,6 +13,20 @@ describe('__executeJavaScript', function() { run().should.equal(42); }); + it('lets extensions rewrite code before compilation', function() { + let extensions = backupExtensions(); + clearExtensions(); + htmx.registerExtension('rewrite-test', { + htmx_scope: (_, detail) => detail.code = 'value * 2' + }); + try { + let elt = document.createElement('div'); + htmx.__executeJavaScript(elt, { value: 21 }, 'value + 1', true, false).should.equal(42); + } finally { + restoreExtensions(extensions); + } + }); + it('applies nonce to inline scripts when config.inlineScriptNonce is set', async function () { mockResponse('GET', '/test', '
    '); htmx.config.inlineScriptNonce = 'test-nonce-123'; diff --git a/www/package.json b/www/package.json index 98f626f37..177c540ce 100644 --- a/www/package.json +++ b/www/package.json @@ -11,6 +11,7 @@ "preview": "astro preview", "astro": "astro", "test": "playwright test", + "test:types": "tsc -p tests/types", "test:ui": "playwright test --ui", "test:headed": "playwright test --headed" }, diff --git a/www/src/content/extensions/06-hx-live.md b/www/src/content/extensions/06-hx-live.md index ee4839813..f40ce0eca 100644 --- a/www/src/content/extensions/06-hx-live.md +++ b/www/src/content/extensions/06-hx-live.md @@ -6,14 +6,18 @@ icon: "icon-[mdi--lightning-bolt]" keywords: ["live", "reactive", "bind", "q", "selector"] --- -Expressions live in HTML attributes. They read from the page, write to it, and re-run as it changes. +The `hx-live` extension binds DOM state with inline expressions. -```html - -

    -``` +## Mental Model -The paragraph updates as you type. +```text +Can HTML provide the behavior? +├─ yes → use HTML +└─ no + Can CSS derive the presentation? + ├─ yes → use HTML + CSS + └─ no → use hx-live +``` ## Installing @@ -22,6 +26,125 @@ The paragraph updates as you type. ``` +## Usage + +### Bind an Attribute + +Prefix an attribute with `:`: + +```html + + + + +``` + +```text +Ada → Hello, Ada → Continue enabled +empty → Hello, → Continue disabled +``` + +See [Attributes](#attributes) for every binding target. + +### Find Elements + +Use `q()` to reach DOM state outside the current element: + +```js +q('previous input') // nearby +q('#name') // by ID +q('.item') // every match +q('closest .field') // nearest matching ancestor +``` + +Reads use the first match. Writes update every match: + +```js +q('.item').aria.busy = true +``` + +See [`q()`](#q) for its full selector grammar. + +### Handle an Event + +Use [`hx-on`](/reference/attributes/hx-on) to change state after an event: + +```html tab="HTML" + +``` + +```css tab="CSS" +[aria-pressed="true"] { + background: var(--selected); +} +``` + +### Share State + +Put shared state on the nearest common ancestor: + +```html +
    + + +
    +``` + +When the next value depends on the current value: + +```js +data.items = items => [...items, next] +``` + +See [`data`](#data) for typed values, closest lookup, and functional assignments. + +### Run Async Code + +A server response can own its transient behavior. + +Request: + +```html +
    + + +
    + + +``` + +Response: + +```html tab="HTML" + + Saved + +``` + +```css tab="CSS" +.notice { + transition: opacity 200ms; +} + +.notice.leaving { + opacity: 0; +} +``` + +[`timeout()`](/reference/methods/htmx-timeout) waits before the transition. [`forEvent()`](#foreventargs) waits for the transition or its fallback timeout. + ## Attributes ### `:` @@ -47,7 +170,7 @@ How each attribute is written (booleans, ARIA, property-backed, generic) is desc ### `hx-live:` -The full form. Behaves identically to `:`. +The long form. Behaves identically to `:`. ```html @@ -61,7 +184,7 @@ Bind a single class to an expression. Truthy adds it, falsy removes it. ```html -

    Negative balance

    +

    Negative balance

    ``` ### `:class` @@ -70,7 +193,7 @@ String form: set the listed classes. ```html -
    +
    ``` Object form: each key is added or removed by the truthiness of its value. @@ -78,8 +201,8 @@ Object form: each key is added or removed by the truthiness of its value. ```html
    ``` @@ -99,7 +222,7 @@ Bind the element's [`textContent`](https://developer.mozilla.org/en-US/docs/Web/ ```html -

    +

    ``` Numbers and other non-strings are stringified. @@ -155,25 +278,27 @@ An escape hatch. Use it when no single `:` fits, or for multi-step logic a ## Helpers -The helpers work inside `hx-live` expressions, inside [`hx-on`](/reference/attributes/hx-on) event handlers, and from regular JavaScript via `htmx.live.*`. +The helpers work in hx-live bindings and htmx expression scopes. The [Public API](#public-api) lists the helpers available to regular JavaScript. ```js -htmx.live.q('.row').attr('hidden', true); +htmx.live.q('.row').attr.hidden = true; ``` Inside expressions, `this` is the element, the full htmx API is available unprefixed, and `await` works at the top level (expressions are `async` functions). ```html ``` ### `q()` -`q()` returns a proxy over a set of elements. Read from the first match, write to all. +`q()` returns a proxy over a set of elements. + +With no matches, state reads return `undefined` and writes do nothing, including through `.closest`. ```js q('.row') // every .row in the document @@ -205,7 +330,7 @@ q('.foo in #scope') // restrict to a specific root q('.foo in this') // restrict to the current element ``` -`next`, `previous`, and `closest` resolve against `this` (the element that owns the expression). They only work inside `hx-live` / `hx-on` scopes. +`next`, `previous`, and `closest` resolve against `this`. They require an expression scope with a current element. **Chaining** @@ -219,48 +344,86 @@ q('.row').q('next .row') // each row's successor For plain descendant queries, CSS is shorter: `q('.card .title')` and `q('.card').q('.title')` are equivalent. Use chaining when you need a directional per matched element. -**Built-in methods** +**State and methods** -The helpers below also work as methods on the proxy, applying across all matched elements: +State writes and method calls apply to every matched element: ```js -q('input').attr('disabled', true) // set attribute on all +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', '
  1. new
  2. ') // before / after / start / end ``` -### `attr(name, value?)` +### Local and Closest State + +Bare `attr.*`, `aria.*`, and `class.*` use the current element. Bare `data.*` uses the closest element carrying that `data-*` attribute. + +`q(...)` makes every state bag local to the selected elements. Add `.closest` to use the closest match instead: + +| Current expression | Selected elements | Closest match | +|---|---|---| +| `attr.hidden` | `q('.item').attr.hidden` | `q('.item').closest.attr.hidden` | +| `data.count` | `q('.item').data.count` | `q('.item').closest.data.count` | +| `aria.busy` | `q('.item').aria.busy` | `q('.item').closest.aria.busy` | +| `class.active` | `q('.item').class.active` | `q('.item').closest.class.active` | + +A closest write uses the selected element when no match exists. A closest delete does nothing when no match exists. When several selected elements share a match, hx-live updates it once. + +### `attr` + +Read and write HTML attributes on this element. + +```js +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 +``` + +Use bracket notation for names that are not JavaScript identifiers, and for computed names. -Get or set an attribute, class, or property on this element. Pass one argument to read, two to write. +`checked`, `selected`, and `value` read the live control state and write both the property and the attribute, so the two never drift apart. + +On `` and ``, `value` reads as a number, and as `null` when the field is empty. Every other control reads as a string, so `` stays `"007"`. + +Numeric attributes (`tabindex`, `colspan`, `rowspan`, `maxlength`, `minlength`, `size`, `span`, `start`, `rows`, `cols`, `width`, `height`, `min`, `max`, `step`, `low`, `high`, `optimum`) read numeric values as numbers. Values such as dates and `step="any"` remain strings. + +Use `delete` to remove an attribute. Assigning `false` writes `"false"`. ```js -attr('hidden') // is hidden present? -attr('hidden', true) // add (false/null/undefined removes) -attr('.active') // has class .active? -attr('.active', q('#src').checked) // add/remove class -attr('class', 'foo bar') // multi-class string -attr('class', { active: matches('.tab') }) // multi-class object -attr('aria-expanded', matches('.open')) // any aria-*: writes "true"/"false" -attr('value', 'hello') // value/checked/selected: syncs property + attribute -attr('data-x', null) // remove +delete attr['data-x'] +attr['data-x'] = null ``` +Use [`class.*`](#class) and [`aria.*`](#aria) for class and ARIA state. + ### `toggle(name, values?)` -Toggle (no `values`) or cycle (with `values`) a class or attribute on this element. +Toggle or cycle a class, ARIA attribute, or attribute on this element. ```js toggle('.active') // toggle class toggle('aria-expanded') // flip "true" ↔ "false" toggle('hidden') // toggle attribute presence -toggle('data-view', 'grid|list|table') // cycle attribute through values -toggle('.size', 'sm|md|lg') // cycle classes (only one at a time) -toggle('data-open', 'on|') // cycle: 'on' ↔ absent +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 +``` + +Values can also arrive as one `|`-separated string or one array: + +```js +toggle('data-view', 'grid|list|table') +toggle('data-view', ['grid', 'list', 'table']) ``` -`values` accepts a `|`-separated string or an array. +Cycle values keep their types. For example, `'1'` remains a string while `1` remains a number. ### `take(name, scope?)` @@ -272,9 +435,212 @@ take('aria-current', 'nav a') // become the current nav item take('.active') // implicit scope: parent element's subtree ``` +### `class` + +`class`, `attr.class`, and `attr['class']` return the same class state. Read and write membership with boolean properties: + +```html + +``` + +Use bracket notation for class names that are not JavaScript identifiers: + +```js +class['is-active'] = true +delete class.pending +``` + +Set several classes at once with `class.assign({ ... })`. Truthy values add, falsy values remove, unmentioned classes survive: + +```html + +``` + +`class` extends the native [`DOMTokenList`](https://developer.mozilla.org/en-US/docs/Web/API/DOMTokenList): + +```js +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 +``` + +Use the canonical form when the proxy itself is a value: + +```js +[...attr.class] // class names +'x' in attr.class // membership +use(attr.class) // function argument +``` + +`closest.class` supports `assign`, `add`, `remove`, `toggle`, `replace`, and `contains`. Aggregate list properties such as `value`, `length`, and iteration remain local because each class can have a different closest match. + +Native members win on read. Use `class.contains('toggle')` to read a class whose name collides with a native member. Key writes still change membership, so `class.toggle = false` removes the class named `toggle`. + +With multiple selected elements, class writes and mutating `DOMTokenList` methods update every match. Reads and return values use the first match. + +Use `q()` to access another element: + +```js +q('#menu').class.open = true +``` + +`toggle()` and `take()` work on classes by name: + +```js +toggle('.active') +take('.selected') +``` + +### `aria` + +Read and write typed ARIA state on this element: + +```html +
    + + Busy +
    +``` + +Use `closest.aria.*` when you explicitly want the closest match. A write with +no match adds the state to the current element: + +```js +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 +``` + +Use `toggle()` and `take()` for transitions: + +```html + + + + +
    + + +
    +``` + +`toggle()` flips boolean ARIA between `"true"` and `"false"`. `take()` writes `"false"` on the other elements, then `"true"` on this element. + +Each form uses the same value rules. You can use these values as booleans, numbers, and arrays: + +```html + + +
    ...
    +

    ...

    + +
    +``` + +After one click: + +```html + +
    +``` + +Use either form to remove an attribute: + +```js +aria.current = null +delete aria.current +``` + +#### Value types + +hx-live uses the value types from [WAI-ARIA 1.2](https://www.w3.org/TR/wai-aria-1.2/). + +Numbers use JSON syntax. For example, `aria-valuenow="0.5"` reads as `0.5`, while `aria-valuenow=".5"` remains the string `".5"`. + +**Boolean** + +- `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` + +**Number** + +- `aria-colcount` +- `aria-colindex` +- `aria-colspan` +- `aria-level` +- `aria-posinset` +- `aria-rowcount` +- `aria-rowindex` +- `aria-rowspan` +- `aria-setsize` +- `aria-valuemax` +- `aria-valuemin` +- `aria-valuenow` + +**Token list (`string[]`)** + +- `aria-dropeffect` +- `aria-relevant` + +**ID reference list (`string[]`)** + +- `aria-controls` +- `aria-describedby` +- `aria-flowto` +- `aria-labelledby` +- `aria-owns` + +All other `aria-*` attributes remain strings. + +You can use `aria.*` in `hx-live`, bindings, `hx-on`, `js:` attribute values, and `hx-trigger` filters. + ### `data` -Read or write `data-*` attributes on the closest ancestor that has them. Lets components share state up the tree. +Read and write `data-*` attributes as JSON or plain text. ```html
    @@ -285,9 +651,16 @@ Read or write `data-*` attributes on the closest ancestor that has them. Lets co
    ``` -`data.foo` reads from the closest `[data-foo]` ancestor. Writing assigns to that ancestor too. +`data-*` holds state shared by a subtree, so `data.*` walks up to the nearest element that has the attribute. Every other namespace reads this element: + +```js +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 +``` -Values are automatically JSON-serialized on write and parsed on read. Booleans, numbers, arrays, and objects round-trip transparently: +On write, hx-live converts booleans, numbers, arrays, and objects to JSON. On read, it converts the JSON back to JavaScript values: ```html
    @@ -302,7 +675,37 @@ Values are automatically JSON-serialized on write and parsed on read. Booleans, Plain strings that aren't valid JSON are returned as-is. -The `data` proxy is enumerable. Object spread, rest destructuring, and `Object.keys()`/`Object.entries()` use the same cascading lookup rules: +Writes preserve strings that look like JSON by quoting them: + +```js +data.code = '123' // data-code='"123"', reads as '123' +data.code = 123 // data-code="123", reads as 123 +``` + +Use `toggle()` and `take()` for transitions: + +```html + + + +``` + +Without values, `toggle()` adds or removes the attribute. Pass values to cycle through them. + +Use `take()` to move state between siblings: + +```html +
    + + +
    +``` + +Clicking Two removes `data-active` from One and leaves an empty `data-active=""` on Two. + +The `data` proxy is enumerable, so object spread, rest destructuring, and `Object.keys()`/`Object.entries()` work: ```html
    @@ -316,14 +719,48 @@ The `data` proxy is enumerable. Object spread, rest destructuring, and `Object.k Here, `hx-vals` receives `{ x: 1, y: 3 }`. -`data` is also available on `q()` proxies via `q(selector).data`. It cascades from the first matched element: +Delete a value or assign `undefined` to remove its attribute: ```js -q('#cart-panel').data.items // read: JSON-parsed value from closest [data-items] ancestor -q('#cart-panel').data.items = [{id: 1}] // write: JSON-stringified to that ancestor +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 ``` -For direct, this-only access, use `this.dataset` instead (note: `this.dataset` is always strings). For per-element writes across a set, use `q('.row').dataset.state = 'on'`. +`data.count = null` writes `data-count="null"`. `data.count = ''` writes an empty `data-count=""` attribute. + +Use `dataset` when you need raw strings: + +```js +this.dataset.count +q('#cart').dataset.count +``` + +#### Assign a Function + +Assign a function when the next value depends on the current value: + +```html +
    + +
    +``` + +```text +[{"id":"1"}] → [{"id":"1"},{"id":"2"}] +``` + +The function must return a value synchronously. It also works with DOM properties and `attr.*`: + +```js +q('#panel').hidden = hidden => !hidden +attr.hidden = hidden => !hidden +``` Because `:` works on `data-*`, you can also store derived values in the DOM: @@ -343,14 +780,6 @@ Shorthand for `this.style`. ``` -### `classList` - -Shorthand for `this.classList`. - -```html - -``` - ### `matches(selector)` Shorthand for `this.matches(selector)`. @@ -406,13 +835,16 @@ Resolve on the next matching event. Mix event names, milliseconds, intervals, an 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 ``` +The result is the winning `Event`, or the original number or interval string when a timeout wins. + Typical use: wait for a CSS transition to finish, with a safety timeout. ```html @@ -424,9 +856,9 @@ Resolve on the next animation frame. ```html ``` @@ -453,15 +885,15 @@ For a single inline section, native [`
    `](https://developer.mozilla.org/ ```html
    - +
    - + ``` **Toggle button.** ```html - + ``` ```css @@ -472,13 +904,13 @@ For a single inline section, native [`
    `](https://developer.mozilla.org/ ```html
    - - - + + +
    ``` -`take('aria-selected', '[role=tab]')` writes `"false"` on every `[role=tab]`, then `"true"` on this one. +`take('aria-selected')` writes `"false"` on every other tab, then `"true"` on this one. **Loading state.** @@ -493,12 +925,12 @@ For a single inline section, native [`
    `](https://developer.mozilla.org/ [aria-busy="true"] { opacity: 0.5; pointer-events: none } ``` -**Non-boolean ARIA.** Strings pass through, so `aria-current="page"`, `aria-pressed="mixed"`, and numeric ARIA (`aria-valuenow="50"`) work in the simple form: +**Non-boolean ARIA.** Strings pass through, so `aria-current="page"`, `aria-pressed="mixed"`, and numeric ARIA (`aria-valuenow="50"`) work in bindings: ```html Home -
    +
    ``` ## Advanced Examples @@ -522,7 +954,7 @@ Client state: data-message="" data-level="" hx-on="flash -> data.message = message; data.level = level; - await timeout(3000); + await timeout('3s'); data.message = ''" :text="data.message" :.success="data.level === 'success'" @@ -533,17 +965,17 @@ Client state: ### Re-run triggers -A single document-wide `MutationObserver` and `input` / `change` listeners trigger a recompute of every live expression. Any of these schedule one: +These changes rerun every live expression: - DOM additions, removals, attribute changes, text changes - `input` or `change` events from any control - completion of an htmx swap (recomputes pause mid-swap, run once at the end) -Each expression is pre-compiled once when registered. All pre-compiled expressions then run in a single microtask, so multiple synchronous mutations coalesce into one recompute. +Multiple synchronous changes coalesce into one recompute. ### Self-mutation is safe -When an expression writes to the DOM, the observer drains its own pending records inside the same microtask. Writes made by `hx-live` cannot trigger a feedback loop. +Writes made by hx-live do not trigger another recompute. ### Slow expressions @@ -569,13 +1001,11 @@ When an `hx-live` element is removed, its expression drops out on the next sched [`:`](#attr) writes the value differently depending on the attribute, following HTML conventions. -**Boolean attributes** (`disabled`, `hidden`, `required`, `open`, `readonly`, `inert`, ...). Truthy adds the attribute; falsy removes it. +**Boolean attributes** (`disabled`, `required`, `open`, `readonly`, `inert`, ...). Truthy adds the attribute; falsy removes it. ```html